diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 9b93a4b..b6fe4e0 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -20,6 +20,7 @@ import { FormsModule } from '@angular/forms'; import { MatBadgeModule } from '@angular/material/badge'; import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatChipsModule } from '@angular/material/chips'; @@ -82,6 +83,8 @@ import { ChangeUiTemplateComponent } from './dialogs/change-ui-template/change-u import { ReactiveFormsModule } from '@angular/forms'; import { TemplateDetailsComponent } from './components-small/template-details/template-details.component'; import { ExperimentWorkspaceDetailComponent } from './components/experiment-workspace-detail/experiment-workspace-detail.component'; +import { PluginFilterNodeComponent } from './components-small/plugin-filter-node/plugin-filter-node.component'; +import { PluginFilterEditorComponent } from './components-small/plugin-filter-editor/plugin-filter-editor.component'; import { TabGroupListComponent } from './components/tab-group-list/tab-group-list.component'; @NgModule({ @@ -125,6 +128,8 @@ import { TabGroupListComponent } from './components/tab-group-list/tab-group-lis TemplateDetailsComponent, ExperimentWorkspaceDetailComponent, ImportExperimentComponent, + PluginFilterNodeComponent, + PluginFilterEditorComponent, TabGroupListComponent, ], imports: [ @@ -138,6 +143,7 @@ import { TabGroupListComponent } from './components/tab-group-list/tab-group-lis MatCardModule, MatButtonModule, MatButtonToggleModule, + MatSlideToggleModule, MatCommonModule, MatTabsModule, MatRippleModule, diff --git a/src/app/components-small/plugin-filter-editor/plugin-filter-editor.component.html b/src/app/components-small/plugin-filter-editor/plugin-filter-editor.component.html new file mode 100644 index 0000000..2f10e4a --- /dev/null +++ b/src/app/components-small/plugin-filter-editor/plugin-filter-editor.component.html @@ -0,0 +1,64 @@ +
+

Filter Editor

+
+ + Editor Mode {{ showEditor ? '(UI)' : '(JSON)' }} + + +
+ + + + Filter String: + + + Invalid JSON + +
+ Filter String Info + + Enter a filter string as JSON object. Filter strings have the following keys: +
+
name
Represents the name of a plugin.
+
tag
Allows filtering elements by their assigned tags.
+
version
Uses PEP 440 version specifier to filter elements based on specific versions or version ranges.
+
not
Specifies a filter string to exclude certain elements.
+
and
Includes multiple filter strings, with elements passing all conditions included in the filtered results (intersection).
+
or
Includes multiple filter strings, with elements meeting at least one condition included in the filtered results (union).
+
+ +

Examples:

+ + + + + + + + + +
+
+
diff --git a/src/app/components-small/plugin-filter-editor/plugin-filter-editor.component.sass b/src/app/components-small/plugin-filter-editor/plugin-filter-editor.component.sass new file mode 100644 index 0000000..8540462 --- /dev/null +++ b/src/app/components-small/plugin-filter-editor/plugin-filter-editor.component.sass @@ -0,0 +1,21 @@ +.form-field + width: 100% + +.copy-button + position: absolute + top: 0 + right: 0 + +.filter-editor + display: flex + flex-direction: column + gap: 1rem + padding-bottom: 0px + +.editor-description + margin: 0 + +.editor-header + display: flex + justify-content: space-between + align-items: center \ No newline at end of file diff --git a/src/app/components-small/plugin-filter-editor/plugin-filter-editor.component.spec.ts b/src/app/components-small/plugin-filter-editor/plugin-filter-editor.component.spec.ts new file mode 100644 index 0000000..74ce911 --- /dev/null +++ b/src/app/components-small/plugin-filter-editor/plugin-filter-editor.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PluginFilterEditorComponent } from './plugin-filter-editor.component'; + +describe('PluginFilterEditorComponent', () => { + let component: PluginFilterEditorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ PluginFilterEditorComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(PluginFilterEditorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components-small/plugin-filter-editor/plugin-filter-editor.component.ts b/src/app/components-small/plugin-filter-editor/plugin-filter-editor.component.ts new file mode 100644 index 0000000..51eb9b0 --- /dev/null +++ b/src/app/components-small/plugin-filter-editor/plugin-filter-editor.component.ts @@ -0,0 +1,80 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { AbstractControl, FormControl, Validators, ValidatorFn } from '@angular/forms'; +import { ApiLink } from 'src/app/services/api-data-types'; +import { PluginRegistryBaseService } from 'src/app/services/registry.service'; +import { TemplateTabApiObject } from 'src/app/services/templates.service'; + +export function isJSONValidator(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } | null => { + try { + JSON.parse(control.value); + return null; + } catch (e) { + return { invalidJSON: true }; + } + }; +} + +@Component({ + selector: 'qhana-plugin-filter-editor', + templateUrl: './plugin-filter-editor.component.html', + styleUrls: ['./plugin-filter-editor.component.sass'] +}) +export class PluginFilterEditorComponent implements OnInit { + @Input() tabLink: ApiLink | null = null; + @Output() filterEmitter: EventEmitter = new EventEmitter(); + + filterString: string = "{}"; + // TODO: Add JSON validator for filter strings + filterControl = new FormControl(this.filterString, [isJSONValidator()]); + + filterObject: any = {}; + + showEditor: boolean = true; + + constructor(private registry: PluginRegistryBaseService) { } + + ngOnInit(): void { + if (this.tabLink == null) { + return; + } + this.registry.getByApiLink(this.tabLink).then(response => { + this.filterString = response?.data?.filterString ?? this.filterString; + this.filterObject = JSON.parse(this.filterString); + this.updateFilter(this.filterObject); + }); + } + + updateFilter(value: any) { + this.filterObject = value; + this.filterString = JSON.stringify(this.filterObject, null, 2); + this.filterControl.setValue(this.filterString); + this.filterEmitter.emit(this.filterString); + } + + copyFilterString() { + navigator.clipboard.writeText(this.filterString); + } + + updateFilterEditor() { + const filterValue = this.filterControl.value; + if (filterValue == null) { + return; + } + if (this.filterControl.valid) { + try { + this.filterObject = JSON.parse(filterValue); + this.filterString = filterValue; + this.filterEmitter.emit(this.filterString); + } catch (e) { + console.warn("Invalid filter string", this.filterObject, "\nError:", e); + } + } + } + + deleteFilter() { + this.filterString = "{}"; + this.filterControl.setValue(this.filterString); + this.updateFilterEditor(); + } +} diff --git a/src/app/components-small/plugin-filter-node/plugin-filter-node.component.html b/src/app/components-small/plugin-filter-node/plugin-filter-node.component.html new file mode 100644 index 0000000..88c1d46 --- /dev/null +++ b/src/app/components-small/plugin-filter-node/plugin-filter-node.component.html @@ -0,0 +1,72 @@ +
+
+ + + +
+ + + +
+ + AND + OR + +
+ + + + +
+
+
+
+ + + Not: + + +
+ Not: + + Filter Type: + + Name + Tag + Version + + + + Filter String: + + + +
+ Invert - Negate this filter +
+
+
\ No newline at end of file diff --git a/src/app/components-small/plugin-filter-node/plugin-filter-node.component.sass b/src/app/components-small/plugin-filter-node/plugin-filter-node.component.sass new file mode 100644 index 0000000..f255b64 --- /dev/null +++ b/src/app/components-small/plugin-filter-node/plugin-filter-node.component.sass @@ -0,0 +1,57 @@ +.set-buttons + display: flex + flex-direction: row + justify-content: flex-start + gap: 10px + +.form-field + width: 100% + +.filter-node + border: 1px solid #7b7b7b + +.filter-node, .filter-node > mat-card-content + display: flex + flex-direction: column + justify-content: flex-start + gap: 10px + font-size: 12px + +.filter-leaf + padding: 8px 16px + +::ng-deep .mat-card-header-text + margin: 0px + +.config-header + width: 100% + display: flex + flex-direction: row + justify-content: space-between + +.config-header-buttons + display: flex + flex-direction: row + justify-content: flex-end + gap: 10px + +.config-header > mat-button-toggle-group + height: 32px + +.config-header > mat-button-toggle-group > mat-button-toggle + height: 32px + font-size: 12px + display: flex + align-items: center + +.config-filter + display: flex + flex-direction: row + justify-content: space-between + align-items: center + gap: 10px + font-size: 12px + +::ng-deep .config-filter > mat-form-field > .mat-form-field-wrapper + margin: 0px + padding: 0px diff --git a/src/app/components-small/plugin-filter-node/plugin-filter-node.component.spec.ts b/src/app/components-small/plugin-filter-node/plugin-filter-node.component.spec.ts new file mode 100644 index 0000000..a2a4b2e --- /dev/null +++ b/src/app/components-small/plugin-filter-node/plugin-filter-node.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PluginFilterNodeComponent } from './plugin-filter-node.component'; + +describe('PluginFilterNodeComponent', () => { + let component: PluginFilterNodeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ PluginFilterNodeComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(PluginFilterNodeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components-small/plugin-filter-node/plugin-filter-node.component.ts b/src/app/components-small/plugin-filter-node/plugin-filter-node.component.ts new file mode 100644 index 0000000..c69b9e6 --- /dev/null +++ b/src/app/components-small/plugin-filter-node/plugin-filter-node.component.ts @@ -0,0 +1,159 @@ +import { Component, EventEmitter, Input, OnInit, Output, SimpleChanges } from '@angular/core'; + +// Define filter types ('not' excluded) +// The PluginFilterNodeComponent component is designed to encapsulate a filter object and the information wether the filter is inverted ('not'). +// When the filter is inverted, the filter object is wrapped in a 'not' object and the 'inverted' property is set to true. +const filterTypes = ['and', 'or', 'name', 'tag', 'version'] as const; +type FilterType = (typeof filterTypes)[number]; +const isFilterType = (x: any): x is FilterType => filterTypes.includes(x); + +@Component({ + selector: 'qhana-plugin-filter-node', + templateUrl: './plugin-filter-node.component.html', + styleUrls: ['./plugin-filter-node.component.sass'] +}) +export class PluginFilterNodeComponent implements OnInit { + @Input() filterIn: any; + @Input() depth: number = 0; + @Output() filterOut = new EventEmitter(); + @Output() delete = new EventEmitter(); + + filterObject: any = {}; + type: FilterType | null = null; + children: any[] | null = null; + value: string | null = null; + inverted: boolean = false; + isEmpty: boolean = true; + + constructor() { } + + ngOnInit(): void { + this.setupFilter(); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.filterIn.previousValue != null) { + this.setupFilter(); + } + } + + setupFilter() { + this.isEmpty = Object.keys(this.filterIn).length === 0; + this.filterObject = JSON.parse(JSON.stringify(this.filterIn)); + const filter = this.filterObject.not ?? this.filterObject; + this.inverted = this.filterObject.not != null; + if (filter == null) { + console.warn("No filter object provided to plugin filter node component"); + return; + } + if (this.isEmpty) { + return; + } + const filterKeys = Object.keys(filter) + if (filterKeys.length != 1) { + console.error("Filters with more than one attribute are not supported! ", filterKeys); + } + const type = filterKeys[0]; + if (!isFilterType(type)) { + console.warn("Invalid filter type provided to plugin filter node component: ", type); + return; + } + this.type = type as FilterType; + const isLeaf = filter.and == null && filter.or == null; + if (isLeaf) { + this.value = filter[this.type]; + } else { + this.children = filter[this.type]; + } + } + + updateFilterObject() { + if (this.type == null) { + console.warn("No type provided to plugin filter node component"); + return; + } + const filter = JSON.parse(JSON.stringify(this.filterObject)); + if (this.inverted) { + filter.not[this.type] = this.children ?? this.value; + } else { + filter[this.type] = this.children ?? this.value; + } + this.filterOut.emit(filter); + } + + setFilter(type: FilterType) { + const isLeaf = type !== 'and' && type !== 'or'; + this.type = type; + this.children = isLeaf ? null : []; + this.value = isLeaf ? "" : null; + this.updateFilterObject(); + this.isEmpty = false; + } + + deleteChild(index: number) { + if (this.children == null) { + console.warn("Cannot delete child filter: Filter has no children!"); + return; + } + this.children.splice(index, 1); + this.updateFilterObject(); + } + + addFilter(type: FilterType = 'name') { + if (this.children == null) { + console.warn("Cannot add child filter because the plugin filter node component is a leaf node!"); + return; + } + const filterValue = (type === 'and' || type === 'or') ? [] : ""; + this.children.push({ [type]: filterValue }); + this.updateFilterObject(); + } + + updateChild(value: any, index: number) { + if (this.children == null) { + console.warn("No children provided to plugin filter node component"); + return; + } + if (value == null) { + this.children.splice(index, 1); + } else { + this.children[index] = value; + } + this.updateFilterObject(); + } + + changeType(type: FilterType) { + if (type == null || this.type === type) { + return; + } + const filter = this.filterObject.not ?? this.filterObject; + const isLeaf = type !== 'and' && type !== 'or'; + if (this.type != null) { + delete filter[this.type] + } + this.type = type; + if (isLeaf) { + this.children = null; + filter[type] = this.value ?? ""; + } else { + this.value = null; + filter[type] = this.children ?? []; + } + this.filterOut.emit(JSON.parse(JSON.stringify(this.filterObject))); + } + + updateFilterString(event: any) { + this.value = event.target.value; + this.updateFilterObject(); + } + + invertFilter(event: any) { + this.inverted = event.checked; + if (this.inverted) { + this.filterObject = { not: this.filterObject }; + } else { + this.filterObject = this.filterObject.not; + } + this.filterOut.emit(JSON.parse(JSON.stringify(this.filterObject))); + } +} diff --git a/src/app/components-small/template-details/template-details.component.html b/src/app/components-small/template-details/template-details.component.html index 67e9f55..c3ea833 100644 --- a/src/app/components-small/template-details/template-details.component.html +++ b/src/app/components-small/template-details/template-details.component.html @@ -20,50 +20,7 @@

Template Tab

Sort Key: - - Filter String: - - -
- Filter String Info - - Enter a filter string as JSON object. Filter strings have the following keys: -
-
name
Represents the name of a plugin.
-
tag
Allows filtering elements by their assigned tags.
-
version
Uses PEP 440 version specifier to filter elements based on specific versions or version ranges.
-
not
Specifies a filter string to exclude certain elements.
-
and
Includes multiple filter strings, with elements passing all conditions included in the filtered results (intersection).
-
or
Includes multiple filter strings, with elements meeting at least one condition included in the filtered results (union).
-
- -

Examples:

- - - - - - - - - -
+
\ No newline at end of file diff --git a/src/app/components-small/template-details/template-details.component.ts b/src/app/components-small/template-details/template-details.component.ts index f3c4a04..932622b 100644 --- a/src/app/components-small/template-details/template-details.component.ts +++ b/src/app/components-small/template-details/template-details.component.ts @@ -14,12 +14,6 @@ export function isInSetValidator(validValues: any[]): Validators { }; } -export interface Location { - value: string; - description: string; -} - - @Component({ selector: 'qhana-template-details', templateUrl: './template-details.component.html', @@ -29,13 +23,14 @@ export class TemplateDetailsComponent implements OnInit { @Input() templateLink: ApiLink | null = null; @Input() tabLink: ApiLink | null = null; - tabGroupNameOverrides = {...TAB_GROUP_NAME_OVERRIDES}; + filterString: string = "{}"; + + tabGroupNameOverrides = { ...TAB_GROUP_NAME_OVERRIDES }; private initialValues = { name: "", description: "", sortKey: 0, - filterString: "{}", location: "workspace" }; @@ -43,7 +38,6 @@ export class TemplateDetailsComponent implements OnInit { name: [this.initialValues.name, Validators.required], description: this.initialValues.description, sortKey: this.initialValues.sortKey, - filterString: [this.initialValues.filterString, Validators.minLength(2)], // TODO: validate using JSON schema location: [this.initialValues.location, [Validators.required, isInSetValidator(Object.keys(TAB_GROUP_NAME_OVERRIDES))]] }); @@ -56,7 +50,6 @@ export class TemplateDetailsComponent implements OnInit { name: response?.data?.name ?? this.initialValues.name, description: response?.data?.description ?? this.initialValues.description, sortKey: response?.data?.sortKey ?? this.initialValues.sortKey, - filterString: response?.data?.filterString ?? this.initialValues.filterString, location: response?.data?.location ?? this.initialValues.location }); }); @@ -82,7 +75,7 @@ export class TemplateDetailsComponent implements OnInit { name: this.templateForm.value.name, description: this.templateForm.value.description, sortKey: this.templateForm.value.sortKey, - filterString: this.templateForm.value.filterString, + filterString: this.filterString, location: this.templateForm.value.location }); if (this.templateLink != null) {