diff --git a/src/app/app.module.ts b/src/app/app.module.ts index a77fb44..b27e04d 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -91,6 +91,8 @@ import { UiTemplateComponent } from "./components-small/ui-template/ui-template. import { UiTemplateTabListComponent } from './components-small/ui-template-tab-list/ui-template-tab-list.component'; import { UiTemplateTabComponent } from './components-small/ui-template-tab/ui-template-tab.component'; import { PluginFilterViewComponent } from './components-small/plugin-filter-view/plugin-filter-view.component'; +import { UiTemplateTabFormComponent } from './components-small/ui-template-tab-form/ui-template-tab-form.component'; +import { PluginFilterFormComponent } from './components-small/plugin-filter-form/plugin-filter-form.component'; @NgModule({ declarations: [ @@ -141,7 +143,9 @@ import { PluginFilterViewComponent } from './components-small/plugin-filter-view UiTemplateComponent, UiTemplateTabListComponent, UiTemplateTabComponent, + UiTemplateTabFormComponent, PluginFilterViewComponent, + PluginFilterFormComponent, ], imports: [ BrowserModule, diff --git a/src/app/components-small/plugin-filter-form/plugin-filter-form.component.html b/src/app/components-small/plugin-filter-form/plugin-filter-form.component.html new file mode 100644 index 0000000..5075b4d --- /dev/null +++ b/src/app/components-small/plugin-filter-form/plugin-filter-form.component.html @@ -0,0 +1,90 @@ +@if (filterValue == null) { +

Flter Plugins by:

+ + Identifier + Name + Version + Type + Tag + + @if(depth < 4) { +

Or combine multiple filters with:

+ + AND (match all filters) + OR (match any filter) + + } +} @else { +
+ Exclude plugins matching this filter + +
+} +@if (isNestedFilter) { + + AND (match all filters) + OR (match any filter) + + +
+
+
+
{{nestedFilterCombinator.toUpperCase()}}
+
+
+
+ @if (filterValue?.length === 0) { +

Must have at least one plugin filter!

+ } + @for (f of filterValue; track $index) { +
+ + + + + + +
+ } + +
+
+} +@if (!isNestedFilter && filterType != null) { +
+ + Filter By: + + Identifier + Name + Tag + Version + Type + + + + Match against: + + + + + + + + + + Examples: ">= 0.1.0", "== 1.0.2", "< 2.0.0", ">= 1.0.0, < 2.0.0" , "!= 1.0.14" + + + Examples: "processing", "conversion", "dataloader", "visualization", "interaction" + + + +
+} +@if (depth > 0 && (filterValue == null || filterValue === "")) { +

Filter may not be empty!

+} + diff --git a/src/app/components-small/plugin-filter-form/plugin-filter-form.component.sass b/src/app/components-small/plugin-filter-form/plugin-filter-form.component.sass new file mode 100644 index 0000000..8ee194e --- /dev/null +++ b/src/app/components-small/plugin-filter-form/plugin-filter-form.component.sass @@ -0,0 +1,67 @@ +.toggle-group + height: 3em + +.filter-head + display: flex + align-items: center + justify-content: space-between + width: 100% + margin-block-end: 0.5rem + +.simple-filter + display: flex + align-items: start + gap: 0.5rem + width: 100% + +.filter-type-selec + width: 8rem + +.filter-value + width: unset + flex-grow: 1 + +.filter-button + height: 3.5rem + +.nested-filter-container + display: flex + gap: 1rem + align-items: stretch + margin-block: 1rem + +.filter-combinator + display: flex + flex-direction: column + align-items: center + min-width: 2.1rem + + .line + flex-grow: 1 + width: 0px + border-inline-start: 1px solid var(--text-washed) + +.child-filter-list + display: flex + flex-direction: column + gap: 0.8rem + flex-grow: 1 + +.child-filter-container + display: flex + align-items: stretch + gap: 0.5rem + +.child-filter + flex-grow: 1 + +.child-filter-remove-button + height: unset + min-width: 2rem + padding: 0px + display: flex + flex-direction: column + align-items: center + +.warning + color: var(--accent-text) diff --git a/src/app/components-small/plugin-filter-form/plugin-filter-form.component.ts b/src/app/components-small/plugin-filter-form/plugin-filter-form.component.ts new file mode 100644 index 0000000..2cf90d1 --- /dev/null +++ b/src/app/components-small/plugin-filter-form/plugin-filter-form.component.ts @@ -0,0 +1,242 @@ +import { Component, EventEmitter, Input, Output, SimpleChanges } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { ChoosePluginDialog } from 'src/app/dialogs/choose-plugin/choose-plugin.dialog'; +import { PluginApiObject } from 'src/app/services/qhana-api-data-types'; + +@Component({ + selector: 'qhana-plugin-filter-form', + templateUrl: './plugin-filter-form.component.html', + styleUrl: './plugin-filter-form.component.sass' +}) +export class PluginFilterFormComponent { + @Input() filterIn: any; + @Input() depth: number = 0; + @Output() filterOut = new EventEmitter(); + @Output() valid = new EventEmitter(); + + filterValue: string | Array | null = null; + isNestedFilter: boolean = false; + nestedFilterCombinator: "and" | "or" = "or"; + isNegated: boolean = false; + filterType: 'id' | 'name' | 'tag' | 'version' | 'type' | null = null; + + childFiltersOutput: Array = []; + childFiltersValid: Array = []; + + constructor(private dialog: MatDialog) { } + + ngOnInit(): void { } + + ngOnChanges(changes: SimpleChanges) { + if (changes.filterIn != null) { + this.setupFilter(); + } + } + + resetCurrentFilter() { + this.isNegated = false; + this.filterValue = null; + this.filterType = null; + this.isNestedFilter = false; + this.nestedFilterCombinator = "or"; + this.childFiltersOutput = []; + this.childFiltersValid = []; + } + + setupFilter() { + if (!this.filterIn) { + // null or empty + this.resetCurrentFilter(); + this.updateFilterObject(); + return; + } + let filter = this.filterIn + let filterKeys = Object.keys(filter); + if (filterKeys.length !== 1) { + this.resetCurrentFilter(); + this.updateFilterObject(); + return; + } + + if (filterKeys[0] === "not") { + this.isNegated = true; + filter = filter.not; + filterKeys = Object.keys(filter); + + if (filterKeys.length === 0) { + this.resetCurrentFilter(); + // specifically keep negated flag if child filter is empty! + this.isNegated = true; + this.updateFilterObject(); + return; + } else if (filterKeys.length > 1) { + this.resetCurrentFilter(); + this.updateFilterObject(); + return; + } + } + + if (filterKeys[0] === "and") { + this.isNestedFilter = true; + this.nestedFilterCombinator = "and"; + this.filterType = null; + this.childFiltersOutput = [...filter.and]; + this.childFiltersValid = filter.and.map(() => true); + this.filterValue = filter.and; + this.updateFilterObject(); + return; + } + + if (filterKeys[0] === "or") { + this.isNestedFilter = true; + this.nestedFilterCombinator = "or"; + this.filterType = null; + this.childFiltersOutput = [...filter.or]; + this.childFiltersValid = filter.or.map(() => true); + this.filterValue = filter.or; + this.updateFilterObject(); + return; + } + + if (['id', 'name', 'tag', 'version', 'type'].some(t => t === filterKeys[0])) { + this.isNestedFilter = false; + this.nestedFilterCombinator = "or"; + this.childFiltersOutput = []; + this.childFiltersValid = []; + this.filterType = filterKeys[0] as any; + this.filterValue = filter[filterKeys[0]]; + this.updateFilterObject(); + return; + } + + this.resetCurrentFilter(); + this.updateFilterObject(); + return; + } + + updateFilterObject() { + let filter: any = {}; + if (this.isNestedFilter) { + filter[this.nestedFilterCombinator] = this.childFiltersOutput.filter(v => Boolean(v)); + } else if (this.filterType != null) { + filter[this.filterType] = this.filterValue; + } + + let isValid = true; + if (this.isNestedFilter) { + if (this.childFiltersOutput.length === 0) { + isValid = false; // nested filters must have at least one child filter + } + if (this.childFiltersValid.some(valid => !valid)) { + isValid = false; // nested filters cannot have invalid child filters + } + } else if (this.depth > 0) { + if (this.filterType == null) { + isValid = false; // nested filters cannot be empty + } + if (this.filterValue == null || this.filterValue === "") { + isValid = false; // nested filters cannot be empty + } + } + + if (this.isNegated) { + filter = { not: filter }; + } + + if (this.childFiltersOutput === this.filterValue) { + console.warn("Possible infinite update loop, aborting update!"); + return + } + Promise.resolve().then(() => { + this.valid.emit(isValid); + this.filterOut.emit(filter); + }); + } + + deleteChild(index: number) { + if (!this.isNestedFilter || !Array.isArray(this.filterValue)) { + return; + } + if (this.childFiltersOutput.length >= index + 1) { + const newFilterValue = [...this.childFiltersOutput] + newFilterValue.splice(index, 1); + this.childFiltersOutput = newFilterValue + this.childFiltersValid.splice(index, 1); + this.filterValue = [...newFilterValue]; + this.updateFilterObject(); + } + } + + addChildFilter() { + if (!this.isNestedFilter || !Array.isArray(this.filterValue)) { + return; + } + const filter: any = {}; + this.childFiltersOutput.push(filter); + this.childFiltersValid.push(true); + this.filterValue.push(filter); + this.updateFilterObject(); + } + + setType(type: 'and' | 'or' | 'id' | 'name' | 'tag' | 'version' | 'type') { + if (this.isNestedFilter || this.filterType != null) { + return; // already chose a filter type + } + if (type === "and" || type === "or") { + this.isNestedFilter = true; + this.nestedFilterCombinator = type; + this.filterType = null; + // start with one empty filter + this.childFiltersOutput = [null]; + this.childFiltersValid = [true]; + this.filterValue = [null]; + this.updateFilterObject(); + return; + } + + this.isNestedFilter = false; + this.nestedFilterCombinator = "or"; + this.filterType = type; + this.childFiltersOutput = []; + this.childFiltersValid = [] + this.filterValue = ""; + this.updateFilterObject(); + } + + changeFilterCombinator(combinator: "and" | "or") { + if (!this.isNestedFilter || this.nestedFilterCombinator === combinator) { + return; + } + this.nestedFilterCombinator = combinator; + this.updateFilterObject(); + } + + changeSimpleFilterType(type: 'id' | 'name' | 'tag' | 'version' | 'type') { + if (this.isNestedFilter || this.filterType === type) { + return; + } + this.filterType = type; + this.updateFilterObject(); + } + + openPluginChooser() { + const dialogRef = this.dialog.open(ChoosePluginDialog, {}); + dialogRef.afterClosed().subscribe((result: PluginApiObject | null) => { + if (result == null) { + return; // nothing was selected + } + if (this.isNestedFilter) { + return; + } + if (this.filterType === "id") { + this.filterValue = result.identifier; + this.updateFilterObject(); + } + if (this.filterType === "name") { + this.filterValue = result.title; + this.updateFilterObject(); + } + }); + } + +} diff --git a/src/app/components-small/ui-template-tab-form/ui-template-tab-form.component.html b/src/app/components-small/ui-template-tab-form/ui-template-tab-form.component.html new file mode 100644 index 0000000..f775fc5 --- /dev/null +++ b/src/app/components-small/ui-template-tab-form/ui-template-tab-form.component.html @@ -0,0 +1,43 @@ +
+ + Name: + + Invalid tab name! + +
+

Description:

+ +
+
+

Show tab in:

+ + + {{location.value}} + + +
+ + Tab Group: + + The group key of the tab this tab should be grouped under. (Use '.' to separate group keys for nested groups.) + + + Sort Key: + + +
+ + Icon: + + + {{templateForm.value.icon || "extension"}} +
+ + Group Key: + + Establish this tab as its own tab group. (Cannot contain '.') + +

Group key cannot be used in experiment workspace tabs!

+ +
diff --git a/src/app/components-small/ui-template-tab-form/ui-template-tab-form.component.sass b/src/app/components-small/ui-template-tab-form/ui-template-tab-form.component.sass new file mode 100644 index 0000000..2d17ff5 --- /dev/null +++ b/src/app/components-small/ui-template-tab-form/ui-template-tab-form.component.sass @@ -0,0 +1,25 @@ +.form-field + width: 100% + margin-block-end: 0.5rem + +.filter-example + resize: none + overflow: hidden + +.location-chooser + margin-block-end: 1rem + +.icon-field-wrapper + display: flex + gap: 1rem + align-items: baseline + +.icon-preview + font-size: 24px + margin-inline: 1rem + +.submit-button + margin-block-start: 2rem + +.form-error + color: var(--warn-text) diff --git a/src/app/components-small/ui-template-tab-form/ui-template-tab-form.component.ts b/src/app/components-small/ui-template-tab-form/ui-template-tab-form.component.ts new file mode 100644 index 0000000..675569d --- /dev/null +++ b/src/app/components-small/ui-template-tab-form/ui-template-tab-form.component.ts @@ -0,0 +1,186 @@ +import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'; +import { TAB_GROUP_NAME_OVERRIDES, TemplateTabApiObject } from 'src/app/services/templates.service'; +import { FormBuilder, FormGroup, ValidationErrors, Validators, ValidatorFn, AbstractControl } from '@angular/forms'; +import { Subscription } from 'rxjs'; + +export function isInSetValidator(validValues: any[]): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } | null => { + if (!validValues.includes(control.value)) { + return { invalidValue: true }; + } + return null; + }; +} + +@Component({ + selector: 'qhana-ui-template-tab-form', + templateUrl: './ui-template-tab-form.component.html', + styleUrl: './ui-template-tab-form.component.sass' +}) +export class UiTemplateTabFormComponent implements OnChanges, OnDestroy, OnInit { + + @Input() tabData: TemplateTabApiObject | null = null; + + @Output() isDirty: EventEmitter = new EventEmitter(false); + @Output() isValid: EventEmitter = new EventEmitter(false); + @Output() data: EventEmitter = new EventEmitter(); + @Output() formSubmit: EventEmitter = new EventEmitter(); + + private formStatusSubscription: Subscription | null = null; + private formValueSubscription: Subscription | null = null; + + description: string = ""; + + currentPluginFilter: any = null; + + updatedPluginFilter: any = null; + pluginFilterValid: boolean = false; + + tabGroupNameOverrides = Object.entries(TAB_GROUP_NAME_OVERRIDES) + .map(entry => { return { key: entry[0], value: entry[1] }; }) + .sort((a, b) => { + if (a.key === "workspace") { + return -1; + } + else if (b.key === "workspace") { + return 1; + } + return a.value.localeCompare(b.value) + }); + + private initialValues = { + name: "", + icon: null, + sortKey: 0, + location: "workspace", + locationExtra: "", + groupKey: "", + }; + + templateForm: FormGroup | null = null; + + constructor(private fb: FormBuilder) { } + + ngOnInit(): void { + this.ngOnChanges({}); + } + + ngOnDestroy(): void { + this.formStatusSubscription?.unsubscribe(); + this.formValueSubscription?.unsubscribe(); + } + + ngOnChanges(changes: SimpleChanges): void { + this.formStatusSubscription?.unsubscribe(); + this.formValueSubscription?.unsubscribe(); + const templateForm = this.fb.group({ + name: [this.initialValues.name, [Validators.required, Validators.minLength(1)]], + icon: [this.initialValues.locationExtra, [Validators.maxLength(64)]], + sortKey: this.initialValues.sortKey, + location: [this.initialValues.location, [Validators.required, isInSetValidator(Object.keys(TAB_GROUP_NAME_OVERRIDES))]], + locationExtra: [this.initialValues.locationExtra], + groupKey: [this.initialValues.locationExtra, [Validators.maxLength(32)]], + }); + templateForm.addValidators((control): ValidationErrors | null => { + const loc = control.get("location")?.getRawValue() ?? ""; + if (loc === "workspace") { + const groupKey = control.get("groupKey")?.getRawValue() ?? ""; + if (groupKey) { + return { + groupKeyForbidden: true, + }; + } + } + return null; + }); + if (this.tabData != null) { + const location = this.tabData.location; + const [baseLocation, locationExtra] = location.split(".", 2); + this.description = this.tabData.description; + try { + this.currentPluginFilter = JSON.parse(this.tabData.filterString); + } catch { + this.currentPluginFilter = null; + } + templateForm.setValue({ + name: this.tabData.name, + icon: this.tabData.icon, + sortKey: this.tabData.sortKey, + groupKey: this.tabData.groupKey, + location: baseLocation, + locationExtra: locationExtra ?? "", + }); + } else { + this.description = ""; + } + this.formStatusSubscription = templateForm.statusChanges.subscribe(() => { + this.updateDirty(); + this.isValid.emit(!templateForm.invalid); + }); + this.formValueSubscription = templateForm.valueChanges.subscribe((value) => { + let location = value.location ?? "workspace"; + if (value.locationExtra) { + location = `${location}.${value.locationExtra}`; + } + const data: any = { + "name": value.name, + "icon": value.icon, + "sortKey": value.sortKey, + "location": location, + "groupKey": value.groupKey ?? "", + }; + data.description = this.description; + if (value.groupKey) { + data.filterString = "" + } else { + if (this.updatedPluginFilter) { + data.filterString = JSON.stringify(this.updatedPluginFilter); + } else { + data.filterString = "{}"; + } + } + this.data.emit(data); + }); + this.templateForm = templateForm; + } + + public submitForm() { + this.templateForm?.updateValueAndValidity(); + this.formSubmit.emit(); + } + + async onSubmit() { + this.formSubmit.emit(); + } + + updateDirty() { + let dirty = (this.templateForm?.dirty ?? false) || (this.description !== this.tabData?.description ?? ""); + + if (Boolean(this.currentPluginFilter) && Boolean(this.updatedPluginFilter)) { + if (JSON.stringify(this.currentPluginFilter) !== JSON.stringify(this.updatedPluginFilter)) { + dirty = true; + } + } else if (Boolean(this.currentPluginFilter) !== Boolean(this.updatedPluginFilter)) { + dirty = true; + } + + Promise.resolve().then(() => { + this.isDirty.emit(dirty); + }); + } + + updateValid() { + let isValid = true; + if (this.templateForm == null || this.templateForm.invalid) { + isValid = false; + } + if (!this.pluginFilterValid) { + isValid = false; + } + + Promise.resolve().then(() => { + this.isValid.emit(isValid); + }); + } + +} diff --git a/src/app/components-small/ui-template-tab-list/ui-template-tab-list.component.html b/src/app/components-small/ui-template-tab-list/ui-template-tab-list.component.html index 0ca7c65..79c848b 100644 --- a/src/app/components-small/ui-template-tab-list/ui-template-tab-list.component.html +++ b/src/app/components-small/ui-template-tab-list/ui-template-tab-list.component.html @@ -1,4 +1,12 @@ -
+ + + Create New Tab + + + + + +

Navigation Tab Groups

@for (tab of navigationGroup.tabs; track tab.href) { @@ -11,7 +19,7 @@

Navigation Tab Groups

(Group: {{navigationGroup.groupKey}}.{{hrefToTab.get(tab.href)?.groupKey}}) - + }
@@ -33,17 +41,13 @@

(Group: {{group.groupKey}}.{{hrefToTab.get(tab.href)?.groupKey}}) - + } } -
    -
  • {{navigationGroup.name}}
  • -
  • {{group.name}} - {{group.groupKey}}
  • -

-
+

Experiment Workspace Tabs

@for (tab of workspaceGroup.tabs; track tab.href) { @@ -54,12 +58,12 @@

Experiment Workspace Tabs

{{tab.name}} - + }
-
+

Experiment Navigation Tab Groups

@for (tab of experimentNavigationGroup.tabs; track tab.href) { @@ -72,7 +76,7 @@

Experiment Navigation Tab Groups

(Group: {{experimentNavigationGroup.groupKey}}.{{hrefToTab.get(tab.href)?.groupKey}}) - + }
@@ -94,13 +98,13 @@

(Group: {{group.groupKey}}.{{hrefToTab.get(tab.href)?.groupKey}}) - + } }

-
+

Unknown Tab Groups

@for (group of unknownGroups; track group.groupKey) {

@@ -119,7 +123,7 @@

(Group: {{group.groupKey}}.{{hrefToTab.get(tab.href)?.groupKey}}) - + } diff --git a/src/app/components-small/ui-template-tab-list/ui-template-tab-list.component.sass b/src/app/components-small/ui-template-tab-list/ui-template-tab-list.component.sass index 8890269..5f4b709 100644 --- a/src/app/components-small/ui-template-tab-list/ui-template-tab-list.component.sass +++ b/src/app/components-small/ui-template-tab-list/ui-template-tab-list.component.sass @@ -4,3 +4,6 @@ .group-title-extra color: var(--text-washed) + +.new-tab-button + margin-block-start: 1rem diff --git a/src/app/components-small/ui-template-tab-list/ui-template-tab-list.component.ts b/src/app/components-small/ui-template-tab-list/ui-template-tab-list.component.ts index ecbcc38..62e7c42 100644 --- a/src/app/components-small/ui-template-tab-list/ui-template-tab-list.component.ts +++ b/src/app/components-small/ui-template-tab-list/ui-template-tab-list.component.ts @@ -22,6 +22,8 @@ export class UiTemplateTabListComponent implements OnInit, OnChanges, OnDestroy @Input() templateLink: ApiLink | null = null; + isLoading: boolean = true; + navigationGroup: NavTabGroup | null = null; workspaceGroup: NavTabGroup | null = null; experimentNavigationGroup: NavTabGroup | null = null; @@ -31,6 +33,12 @@ export class UiTemplateTabListComponent implements OnInit, OnChanges, OnDestroy private allGroups: NavTabGroup[] = []; + // new tab + createTabLink: ApiLink | null = null; + newTabData: any = null; + isValid: boolean = false; + + hrefToTab: Map = new Map(); private newTabsSubscription: Subscription | null = null; @@ -76,6 +84,7 @@ export class UiTemplateTabListComponent implements OnInit, OnChanges, OnDestroy } ngOnChanges(changes: SimpleChanges): void { + this.isLoading = true; this.loadTemplateGroups(); } @@ -88,9 +97,10 @@ export class UiTemplateTabListComponent implements OnInit, OnChanges, OnDestroy this.navigationGroups = []; this.experimentNavigationGroups = []; this.unknownGroups = []; + this.isLoading = false; return; } - const templateResponse = await this.registry.getByApiLink(this.templateLink, null, false); + const templateResponse = await this.registry.getByApiLink(this.templateLink, null, true); if (templateResponse == null) { this.hrefToTab = new Map(); this.workspaceGroup = null; @@ -99,9 +109,12 @@ export class UiTemplateTabListComponent implements OnInit, OnChanges, OnDestroy this.navigationGroups = []; this.experimentNavigationGroups = []; this.unknownGroups = []; + this.isLoading = false; return; } + this.createTabLink = templateResponse?.links?.find?.(link => link.rel.some(rel => rel === "create") && link.resourceType == "ui-template-tab") ?? null; + const hrefToTab = new Map(); const tabPromises: Promise[] = []; @@ -118,7 +131,7 @@ export class UiTemplateTabListComponent implements OnInit, OnChanges, OnDestroy const groupIcons = new Map(); const groupPromises = templateResponse.data.groups.map(async (group) => { - const groupResponse = await this.registry.getByApiLink(group); + const groupResponse = await this.registry.getByApiLink(group, null, true); // fetch tab data groupResponse?.data?.items?.forEach?.(tabLink => { @@ -177,6 +190,7 @@ export class UiTemplateTabListComponent implements OnInit, OnChanges, OnDestroy this.experimentNavigationGroups = expNavGroups; this.unknownGroups = unknownGroups; this.allGroups = allGroups; + this.isLoading = false; } private async updateTemplateTab(tabLink: ApiLink) { @@ -288,7 +302,7 @@ export class UiTemplateTabListComponent implements OnInit, OnChanges, OnDestroy return; } if (group.tabs.some(t => t.href === tabLink.href)) { - group.tabs = group.tabs.filter(t => t.href !== t.href); + group.tabs = group.tabs.filter(t => t.href !== tabLink.href); } if (group.tabs.length === 0) { hasEmptyGroups = true; @@ -303,7 +317,7 @@ export class UiTemplateTabListComponent implements OnInit, OnChanges, OnDestroy let hasEmptyGroups = false; this.allGroups.forEach(group => { if (group.tabs.some(t => t.href === tabLink.href)) { - group.tabs = group.tabs.filter(t => t.href !== t.href); + group.tabs = group.tabs.filter(t => t.href !== tabLink.href); } if (group.tabs.length === 0) { hasEmptyGroups = true; @@ -331,4 +345,15 @@ export class UiTemplateTabListComponent implements OnInit, OnChanges, OnDestroy } } + async createNewTab() { + if (this.createTabLink == null) { + return; + } + if (!this.isValid || !this.newTabData) { + return; + } + + this.registry.submitByApiLink(this.createTabLink, this.newTabData); + } + } diff --git a/src/app/components-small/ui-template-tab/ui-template-tab.component.html b/src/app/components-small/ui-template-tab/ui-template-tab.component.html index 28e9091..1098ca0 100644 --- a/src/app/components-small/ui-template-tab/ui-template-tab.component.html +++ b/src/app/components-small/ui-template-tab/ui-template-tab.component.html @@ -1,13 +1,19 @@
- - + + +
-

Description: {{tabData?.description ? '' : '–'}}

- -@if(!isEditing && !(tabData?.groupKey ?? false) && tabFilterData) { +@if (!isEditing) { +

Description: {{tabData?.description ? '' : '–'}}

+ +} +@if (!isEditing && !(tabData?.groupKey ?? false) && tabFilterData) {

Plugin filter:

} - +@if (isEditing) { + + +} diff --git a/src/app/components-small/ui-template-tab/ui-template-tab.component.sass b/src/app/components-small/ui-template-tab/ui-template-tab.component.sass index d493905..1db4b10 100644 --- a/src/app/components-small/ui-template-tab/ui-template-tab.component.sass +++ b/src/app/components-small/ui-template-tab/ui-template-tab.component.sass @@ -4,3 +4,9 @@ justify-content: flex-end width: 100% gap: 0.5rem + margin-block-end: 0.5rem + +.header-buttons + display: flex + align-items: center + gap: 0.5rem diff --git a/src/app/components-small/ui-template-tab/ui-template-tab.component.ts b/src/app/components-small/ui-template-tab/ui-template-tab.component.ts index ee8b7ba..7339644 100644 --- a/src/app/components-small/ui-template-tab/ui-template-tab.component.ts +++ b/src/app/components-small/ui-template-tab/ui-template-tab.component.ts @@ -1,8 +1,11 @@ -import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; +import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { Subscription } from 'rxjs'; +import { DeleteDialog } from 'src/app/dialogs/delete-dialog/delete-dialog.dialog'; 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'; +import { UiTemplateTabFormComponent } from '../ui-template-tab-form/ui-template-tab-form.component'; @Component({ selector: 'qhana-ui-template-tab', @@ -12,17 +15,24 @@ import { TemplateTabApiObject } from 'src/app/services/templates.service'; export class UiTemplateTabComponent implements OnChanges, OnInit, OnDestroy { @Input() tabLink: ApiLink | null = null; + @Input() useCache: boolean = false; + + @ViewChild(UiTemplateTabFormComponent) tabFormChild: UiTemplateTabFormComponent | null = null; tabData: TemplateTabApiObject | null = null; tabFilterData: any = null; - templateUpdateLink: ApiLink | null = null; - templateDeleteLink: ApiLink | null = null; + tabUpdateLink: ApiLink | null = null; + tabDeleteLink: ApiLink | null = null; isEditing: boolean = false; + isValid: boolean = false; + isDirty: boolean = false; + newData: any = null; + private updateSubscription: Subscription | null = null; - constructor(private registry: PluginRegistryBaseService) { } + constructor(private registry: PluginRegistryBaseService, private dialog: MatDialog) { } ngOnInit(): void { this.updateSubscription = this.registry.apiObjectSubject.subscribe((apiObject) => { @@ -30,7 +40,16 @@ export class UiTemplateTabComponent implements OnChanges, OnInit, OnDestroy { return; } if (apiObject.self.resourceType === "ui-template-tab") { - this.tabData = apiObject as TemplateTabApiObject; + const tab = apiObject as TemplateTabApiObject; + this.tabData = tab; + + if (tab.filterString) { + try { + this.tabFilterData = JSON.parse(tab.filterString); + } catch { + // Ignore error + } + } } }); } @@ -40,7 +59,9 @@ export class UiTemplateTabComponent implements OnChanges, OnInit, OnDestroy { } ngOnChanges(changes: SimpleChanges): void { - this.loadTemplateTab(); + if (changes.tabLink != null) { + this.loadTemplateTab(); + } } private async loadTemplateTab() { @@ -48,7 +69,7 @@ export class UiTemplateTabComponent implements OnChanges, OnInit, OnDestroy { this.tabData = null; return; } - const tabResponse = await this.registry.getByApiLink(this.tabLink, null, true); + const tabResponse = await this.registry.getByApiLink(this.tabLink, null, !this.useCache); this.tabData = tabResponse?.data ?? null; if (tabResponse?.data?.filterString) { @@ -59,7 +80,33 @@ export class UiTemplateTabComponent implements OnChanges, OnInit, OnDestroy { } } - this.templateUpdateLink = tabResponse?.links?.find?.(link => link.rel.some(rel => rel === "update") && link.resourceType == "ui-template-tab") ?? null; - this.templateDeleteLink = tabResponse?.links?.find?.(link => link.rel.some(rel => rel === "delete") && link.resourceType == "ui-template-tab") ?? null; + this.tabUpdateLink = tabResponse?.links?.find?.(link => link.rel.some(rel => rel === "update") && link.resourceType == "ui-template-tab") ?? null; + this.tabDeleteLink = tabResponse?.links?.find?.(link => link.rel.some(rel => rel === "delete") && link.resourceType == "ui-template-tab") ?? null; + } + + updateTab() { + if (this.tabUpdateLink == null) { + return; + } + if (!this.isValid || !this.newData) { + return; + } + + this.registry.submitByApiLink(this.tabUpdateLink, this.newData); + } + + async deleteTab() { + if (this.tabDeleteLink == null) { + return; + } + + const dialogRef = this.dialog.open(DeleteDialog, { + data: this.tabLink, + }); + + const doDelete = await dialogRef.afterClosed().toPromise(); + if (doDelete) { + this.registry.submitByApiLink(this.tabDeleteLink); + } } }