From 67246f24bf041f47da0c40dfcc73c0f471dbf267 Mon Sep 17 00:00:00 2001 From: minottic Date: Wed, 22 Apr 2026 13:08:02 +0000 Subject: [PATCH 1/5] chore: extend dialog component for custom fields --- .../modules/dialog/dialog.component.html | 72 ++++++++++++------- .../modules/dialog/dialog.component.spec.ts | 38 ++++++++++ .../shared/modules/dialog/dialog.component.ts | 29 +++++++- 3 files changed, 113 insertions(+), 26 deletions(-) diff --git a/src/app/shared/modules/dialog/dialog.component.html b/src/app/shared/modules/dialog/dialog.component.html index db6926e477..a3a55d33f3 100644 --- a/src/app/shared/modules/dialog/dialog.component.html +++ b/src/app/shared/modules/dialog/dialog.component.html @@ -1,32 +1,54 @@

{{ data.title }}

+
-

{{ data.question }}

- - - -
+

{{ data.question }}

-
- - - {{ choice.option }} - - - - - - {{ choice.location }}  - +
+ + {{ data.label ?? data.title }} + + + {{ opt.option }} + + - + +
+ + {{ field.value.label }} + + + + + + {{ opt.option }} + + + + + + This field is required + +
+
-
- - +
+ +
diff --git a/src/app/shared/modules/dialog/dialog.component.spec.ts b/src/app/shared/modules/dialog/dialog.component.spec.ts index be6963d63e..58df1a7de8 100644 --- a/src/app/shared/modules/dialog/dialog.component.spec.ts +++ b/src/app/shared/modules/dialog/dialog.component.spec.ts @@ -4,9 +4,11 @@ import { DialogComponent } from "./dialog.component"; import { MockMatDialogRef, MockMatDialogData } from "shared/MockStubs"; import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material/dialog"; import { SharedScicatFrontendModule } from "shared/shared.module"; + describe("DialogComponent", () => { let component: DialogComponent; let fixture: ComponentFixture; + let dialogRef: MatDialogRef; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -22,10 +24,46 @@ describe("DialogComponent", () => { beforeEach(() => { fixture = TestBed.createComponent(DialogComponent); component = fixture.componentInstance; + dialogRef = TestBed.inject(MatDialogRef); fixture.detectChanges(); }); it("should create", () => { expect(component).toBeTruthy(); }); + + it("should close the dialog when cancelling", () => { + const closeSpy = spyOn(dialogRef, "close"); + + component.onNoClick(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it("should render dynamic additional fields and disable submit when required data is missing", () => { + component.data = { + title: "Mark for deletion reason", + additionalFields: { + deletiionCode: { + label: "Deletion code", + type: "select", + required: true, + options: [{ option: "MARKED_FOR_DELETION" }], + }, + explanation: { + label: "Explanation for deletion", + type: "textarea", + required: true, + }, + }, + }; + + fixture.detectChanges(); + + const compiled = fixture.debugElement.nativeElement; + + expect(compiled.textContent).toContain("Deletion code"); + expect(compiled.textContent).toContain("Explanation for deletion"); + expect(compiled.querySelector("textarea")).toBeTruthy(); + }); }); diff --git a/src/app/shared/modules/dialog/dialog.component.ts b/src/app/shared/modules/dialog/dialog.component.ts index 935de919ac..c3d2204e32 100644 --- a/src/app/shared/modules/dialog/dialog.component.ts +++ b/src/app/shared/modules/dialog/dialog.component.ts @@ -1,6 +1,30 @@ import { Component, Inject } from "@angular/core"; +import { FormGroup } from "@angular/forms"; import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material/dialog"; +export interface DialogOptionData { + option: string; + tooltip?: string; +} + +export interface DynamicField { + label: string; + type: "text" | "textarea" | "select"; + required?: boolean; + options?: DialogOptionData[]; +} + +export interface DynamicDialogData { + title: string; + label?: string; + question?: string; + choice?: { + options: DialogOptionData[]; + }; + additionalFields?: { [key: string]: DynamicField }; + [key: string]: any; +} + @Component({ selector: "app-dialog", templateUrl: "./dialog.component.html", @@ -8,9 +32,12 @@ import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material/dialog"; standalone: false, }) export class DialogComponent { + form: FormGroup = new FormGroup({}); + fieldKeys: string[] = []; + constructor( public dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: any, + @Inject(MAT_DIALOG_DATA) public data: DynamicDialogData, ) {} onNoClick(): void { From 623c0a400f8150042098bcff4ad2ae7d04239f66 Mon Sep 17 00:00:00 2001 From: minottic Date: Wed, 22 Apr 2026 12:45:07 +0000 Subject: [PATCH 2/5] feat: support dialog in configurable actions New action type dialog that allows defining a dialog in config and emits on close This includes a big refactor (improvement) of configurable action --- src/app/app-config.service.ts | 12 +- .../configurable-action.component.html | 21 +- .../configurable-action.component.spec.ts | 20 +- .../configurable-action.component.ts | 764 ++++++++---------- .../configurable-action.interfaces.ts | 43 +- .../configurable-actions.component.html | 2 + .../configurable-actions.component.ts | 17 +- .../shared/modules/dialog/dialog.component.ts | 4 - 8 files changed, 431 insertions(+), 452 deletions(-) diff --git a/src/app/app-config.service.ts b/src/app/app-config.service.ts index 71bf1b094b..50942e43e7 100644 --- a/src/app/app-config.service.ts +++ b/src/app/app-config.service.ts @@ -3,6 +3,8 @@ import { Injectable } from "@angular/core"; import { mergeWith } from "lodash-es"; import { firstValueFrom, of } from "rxjs"; import { catchError, timeout } from "rxjs/operators"; +import { ActionConfig } from "shared/modules/configurable-actions/configurable-action.interfaces"; +import { DialogOptionData } from "shared/modules/dialog/dialog.component"; import { DatasetDetailComponentConfig, IngestorComponentConfig, @@ -88,13 +90,13 @@ export interface AppConfigInterface { datasetReduceEnabled: boolean; datasetDetailsShowMissingProposalId: boolean; datasetActionsEnabled: boolean; - datasetActions: any[]; + datasetActions: ActionConfig[]; datafilesActionsEnabled: boolean; - datafilesActions: any[]; + datafilesActions: ActionConfig[]; datasetDetailsActionsEnabled: boolean; - datasetDetailsActions: any[]; + datasetDetailsActions: ActionConfig[]; datasetSelectionActionsEnabled: boolean; - datasetSelectionActions: any[]; + datasetSelectionActions: ActionConfig[]; editDatasetEnabled: boolean; editDatasetSampleEnabled: boolean; editMetadataEnabled: boolean; @@ -172,6 +174,8 @@ export interface AppConfigInterface { defaultTab?: DefaultTab; statusBannerMessage?: string; statusBannerCode?: "INFO" | "WARN"; + batchActionsEnabled?: boolean; + batchActions?: ActionConfig[]; } function isMainPageConfiguration(obj: any): obj is MainPageConfiguration { diff --git a/src/app/shared/modules/configurable-actions/configurable-action.component.html b/src/app/shared/modules/configurable-actions/configurable-action.component.html index 7999324896..c2e9cf829e 100644 --- a/src/app/shared/modules/configurable-actions/configurable-action.component.html +++ b/src/app/shared/modules/configurable-actions/configurable-action.component.html @@ -1,14 +1,19 @@ + diff --git a/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts b/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts index e44fa99f6c..dc448b4d0f 100644 --- a/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts +++ b/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts @@ -632,7 +632,7 @@ describe("1000: ConfigurableActionComponent", () => { spyOn(document, "createElement").and.callFake(createFakeElement); - component.perform_action(); + component.performAction(); const formChildren = Array.from(component.form.children).map( (item) => item as unknown as MockHtmlElement, @@ -660,7 +660,7 @@ describe("1000: ConfigurableActionComponent", () => { .url.replace(/\/$/, ""); spyOn(document, "createElement").and.callFake(createFakeElement); - component.perform_action(); + component.performAction(); expect(component.form.action.replace(/\/$/, "")).toEqual(action_url); }); @@ -675,7 +675,7 @@ describe("1000: ConfigurableActionComponent", () => { } as TestCase); spyOn(document, "createElement").and.callFake(createFakeElement); - component.perform_action(); + component.performAction(); const formChildren = Array.from(component.form.children).map( (item) => item as unknown as MockHtmlElement, @@ -700,7 +700,7 @@ describe("1000: ConfigurableActionComponent", () => { } as TestCase); spyOn(document, "createElement").and.callFake(createFakeElement); - component.perform_action(); + component.performAction(); const formChildren = Array.from(component.form.children).map( (item) => item as unknown as MockHtmlElement, @@ -727,7 +727,7 @@ describe("1000: ConfigurableActionComponent", () => { } as TestCase); spyOn(document, "createElement").and.callFake(createFakeElement); - component.perform_action(); + component.performAction(); const formChildren = Array.from(component.form.children).map( (item) => item as unknown as MockHtmlElement, @@ -751,7 +751,7 @@ describe("1000: ConfigurableActionComponent", () => { .url.replace(/\/$/, ""); spyOn(document, "createElement").and.callFake(createFakeElement); - component.perform_action(); + component.performAction(); expect(component.form.action.replace(/\/$/, "")).toEqual(action_url); }); @@ -766,7 +766,7 @@ describe("1000: ConfigurableActionComponent", () => { } as TestCase); spyOn(document, "createElement").and.callFake(createFakeElement); - component.perform_action(); + component.performAction(); const formChildren = Array.from(component.form.children).map( (item) => item as unknown as MockHtmlElement, @@ -876,7 +876,7 @@ describe("1000: ConfigurableActionComponent", () => { ), ); - component.perform_action(); + component.performAction(); const spy = window.fetch as jasmine.Spy; expect(spy.calls.any()).toBeTrue(); @@ -929,7 +929,7 @@ describe("1000: ConfigurableActionComponent", () => { ), ); - component.perform_action(); + component.performAction(); const spy = window.fetch as jasmine.Spy; @@ -973,7 +973,7 @@ describe("1000: ConfigurableActionComponent", () => { result: false, } as TestCase); spyOn(window, "open"); - component.perform_action(); + component.performAction(); const current_action = mockActionsConfig.filter( (a) => a.id == actionSelectorType.link, diff --git a/src/app/shared/modules/configurable-actions/configurable-action.component.ts b/src/app/shared/modules/configurable-actions/configurable-action.component.ts index eaca82a1d4..368fe6f95e 100644 --- a/src/app/shared/modules/configurable-actions/configurable-action.component.ts +++ b/src/app/shared/modules/configurable-actions/configurable-action.component.ts @@ -1,117 +1,37 @@ import { Component, + EventEmitter, Input, OnChanges, OnInit, + Output, SimpleChanges, } from "@angular/core"; - -import { UsersService } from "@scicatproject/scicat-sdk-ts-angular"; -import { ActionConfig, ActionItems } from "./configurable-action.interfaces"; -import { DataFiles_File } from "datasets/datafiles/datafiles.interfaces"; +import { + DatasetClass, + UsersService, +} from "@scicatproject/scicat-sdk-ts-angular"; +import { + ActionButtonStyle, + ActionConfig, + ActionItemDataset, + ActionItems, + ActionType, + DialogField, +} from "./configurable-action.interfaces"; import { AuthService } from "shared/services/auth/auth.service"; -import { v4 } from "uuid"; +import { v4 as uuidv4 } from "uuid"; import { MatSnackBar } from "@angular/material/snack-bar"; import { Store } from "@ngrx/store"; -import { updatePropertyAction } from "state-management/actions/datasets.actions"; -import { Router } from "@angular/router"; import { AppConfigService } from "app-config.service"; import { selectIsAdmin, selectProfile, } from "state-management/selectors/user.selectors"; import { Subscription } from "rxjs"; -import { result } from "lodash-es"; - -type JSONValue = - | string - | number - | boolean - | null - | { [key: string]: JSONValue } - | JSONValue[]; - -function processSelector( - jsonObject: ActionItems, - selector: string, -): string | string[] | number | number[] { - let match: RegExpMatchArray | null; - - // Map of static patterns to processing functions - const keywordMap: { [pattern: string]: (RegExpMatchArray) => any } = { - "#Dataset0Pid": (m) => jsonObject.datasets[0]?.pid, - "#Dataset0FilesPath": (m) => - jsonObject.datasets[0]?.files?.map((i) => i.path), - "#Dataset0FilesTotalSize": (m) => - jsonObject.datasets[0]?.files - ?.map((i) => Number(i.size)) - .reduce((acc, val) => acc + val, 0), - "#Dataset0SourceFolder": (m) => jsonObject.datasets[0]?.sourceFolder, - "#Dataset0SelectedFilesPath": (m) => - jsonObject.datasets[0]?.files - ?.filter((i) => i.selected) - .map((i) => i.path), - "#Dataset0SelectedFilesCount": (m) => - jsonObject.datasets[0]?.files?.filter((i) => i.selected).length, - "#Dataset0SelectedFilesTotalSize": (m) => - jsonObject.datasets[0]?.files - ?.filter((i) => i.selected) - .map((i) => Number(i.size)) - .reduce((acc, val) => acc + val, 0), - // eslint-disable-next-line no-useless-escape - "#Dataset\\[(\\d+)\\]Field\\[(\\w+)\\]": (m) => - jsonObject.datasets[Number(m[1])][m[2]], - "#DatasetsPid": (m) => jsonObject.datasets?.map((i) => i.pid), - "#DatasetsFilesPath": (m) => - jsonObject.datasets - ?.map((i) => i.files) - .flat() - .map((i) => i.path), - "#DatasetsFilesTotalSize": (m) => - jsonObject.datasets - ?.map((i) => i.files) - .flat() - .map((i) => Number(i.size)) - .reduce((acc, val) => acc + val, 0), - "#DatasetsSourceFolder": (m) => - jsonObject.datasets?.map((i) => i.sourceFolder), - "#DatasetsSelectedFilesPath": (m) => - jsonObject.datasets - ?.map((i) => i.files) - .flat() - .filter((i) => i.selected) - .map((i) => i.path), - "#DatasetsSelectedFilesCount": (m) => - jsonObject.datasets - ?.map((i) => i.files) - .flat() - .filter((i) => i.selected).length, - "#DatasetsSelectedFilesTotalSize": (m) => - jsonObject.datasets - ?.map((i) => i.files) - .flat() - .filter((i) => i.selected) - .map((i) => Number(i.size)) - .reduce((acc, val) => acc + val, 0), - // eslint-disable-next-line no-useless-escape - "#DatasetsField\\[(\\w+)\\]": (m) => - jsonObject.datasets.map((i) => i[m[1]]), - }; - - // Check for direct pattern matches - for (const [pattern, fn] of Object.entries(keywordMap)) { - const match = selector.match(new RegExp(pattern)); - - if (match) { - const res = fn(match); - - return res; - } - } - - // No pattern matched, return selector itself - return selector; -} +import { DialogComponent, DynamicDialogData } from "../dialog/dialog.component"; +import { MatDialog } from "@angular/material/dialog"; +import _ from "lodash"; @Component({ selector: "configurable-action", @@ -120,24 +40,38 @@ function processSelector( standalone: false, }) export class ConfigurableActionComponent implements OnInit, OnChanges { + private authorizationTokens = { + "#jwt": () => this.jwt, + "#token": () => this.authService.getToken()?.id, + "#tokenSimple": () => this.authService.getToken()?.id, + "#tokenBearer": () => `Bearer ${this.authService.getToken()?.id}`, + "#uuid": () => uuidv4(), + }; + @Input({ required: true }) actionConfig: ActionConfig; @Input({ required: true }) actionItems: ActionItems; - //@Input() files?: DataFiles_File[]; + @Input({ required: false }) buttonStyle: ActionButtonStyle = { + raised: true, + color: "accent", + }; + @Output() actionFinished = new EventEmitter<{ + success: boolean; + result?: unknown; + error?: Error; + }>(); + userProfile$ = this.store.select(selectProfile); isAdmin$ = this.store.select(selectIsAdmin); jwt = ""; - use_mat_icon = false; - use_icon = false; - disabled_condition = "false"; - variables: Record = {}; - - form: HTMLFormElement = null; - - subscriptions: Subscription[] = []; + useMatIcon = false; + useIcon = false; + variables: Record = {}; userProfile: any = {}; isAdmin = false; + subscriptions: Subscription[] = []; + form: HTMLFormElement | null = null; constructor( private usersService: UsersService, @@ -145,381 +79,395 @@ export class ConfigurableActionComponent implements OnInit, OnChanges { private configService: AppConfigService, private snackBar: MatSnackBar, private store: Store, - private router: Router, + public dialog: MatDialog, ) { this.usersService.usersControllerGetUserJWTV3().subscribe((jwt) => { this.jwt = jwt.jwt; }); } - private evaluate_hidden_condition(condition: string) { - return condition - .replaceAll( - "#isPublished", - String(this.actionItems[0].isPublished === true), - ) - .replaceAll( - "#!isPublished", - String(this.actionItems[0].isPublished === false), - ); - } - - private prepare_action_condition(condition: string) { - // Define replacements for specific functions and variables - return ( - condition - // Handle #Length({{ files }}) - .replace( - // eslint-disable-next-line no-useless-escape - /\#Length\(\s*\@(\w+)\s*\)/g, - (_, variableName) => `(variables.${variableName}?.length ?? 0)`, - ) - // Handle #MaxDownloadableSize({{ totalSize }}) - .replace( - ///#MaxDownloadableSize\(\{\{\s(\w+)\s\}\}\)/g, - // eslint-disable-next-line no-useless-escape - /\#MaxDownloadableSize\(@*(\w+)\)/g, - (_, variableName) => - `variables.${variableName} <= maxDownloadableSize`, - ) - // eslint-disable-next-line no-useless-escape - .replace(/\#datasetOwner/g, (_) => `datasetOwner`) - // eslint-disable-next-line no-useless-escape - .replace(/\#userIsAdmin/g, (_) => `isAdmin`) - // eslint-disable-next-line no-useless-escape - .replace(/\#uuid/g, (_) => v4()) - // eslint-disable-next-line no-useless-escape - .replace(/\@(\w+)/g, (_, variableName) => `variables.${variableName}`) + private variableHandler(selector: string): unknown { + const dynamicKey = Object.keys(this.actionItems).find((key) => + selector.startsWith(`#${key}`), ); - } - private prepare_disabled_condition() { - if (this.actionConfig.enabled) { - this.disabled_condition = - "!(" + this.prepare_action_condition(this.actionConfig.enabled) + ")"; - } else if (this.actionConfig.disabled) { - this.disabled_condition = this.prepare_action_condition( - this.actionConfig.disabled, - ); - } else { - this.disabled_condition = "false"; - } + if (dynamicKey) return this.dynamicVariableHandler(selector, dynamicKey); + + const staticMap = this.buildDatasetStaticMap(); + + if (selector in staticMap) return staticMap[selector](); + + if (selector.includes("Field[")) return this.fieldMatch(selector); + + return undefined; } - private prepare_hidden_condition() { - if (this.actionConfig.hidden) { - return ( - "!(" + this.evaluate_hidden_condition(this.actionConfig.hidden) + ")" - ); - } else { - return "false"; - } + private dynamicVariableHandler( + selector: string, + dynamicKey: string, + ): unknown { + if (selector === `#${dynamicKey}`) return this.actionItems[dynamicKey]; + const path = selector.slice(dynamicKey.length + 2); + return _.get(this.actionItems[dynamicKey], path); } - ngOnInit() { - this.subscriptions.push( - this.userProfile$.subscribe((userProfile) => { - if (userProfile) { - this.userProfile = userProfile; - } - }), - ); - this.subscriptions.push( - this.isAdmin$.subscribe((isAdmin) => { - if (isAdmin) { - this.isAdmin = isAdmin; - } - }), + private fieldMatch(selector: string): unknown { + const datasets = _.get( + this.actionItems, + "datasets", + [], + ) as ActionItemDataset[]; + const allFieldMatch = selector.match(/^#DatasetsField\[(\w+)\]$/); + if (allFieldMatch) return _.map(datasets, allFieldMatch[1]); + const datasetFieldMatch = selector.match( + /^#Dataset\[(\d+)\]Field\[(\w+)\]$/, ); - this.use_mat_icon = !!this.actionConfig.mat_icon; - this.use_icon = this.actionConfig.icon !== undefined; - try { - this.prepare_disabled_condition(); - this.update_status(); - } catch (error) { - console.error("Configurable action error on init", error); + if (datasetFieldMatch) { + const index = Number(datasetFieldMatch[1]); + const field = datasetFieldMatch[2]; + return _.get(datasets, `[${index}].${field}`); } + return undefined; } - ngOnChanges(changes: SimpleChanges) { - if (changes["actionItems"]) { - try { - this.update_status(); - } catch (error) { - console.error("Configurable action error on changes", error); - } - } + private buildDatasetStaticMap() { + const datasets = _.get( + this.actionItems, + "datasets", + [], + ) as ActionItemDataset[]; + const ds0 = _.get(datasets, "[0]"); + + const staticMap: Record unknown> = { + "#Dataset0Pid": () => ds0?.pid, + "#Dataset0SourceFolder": () => ds0?.sourceFolder, + "#Dataset0FilesPath": () => _.map(ds0?.files, "path"), + "#Dataset0FilesTotalSize": () => + _.sumBy(ds0?.files, (f) => Number(f.size || 0)), + "#Dataset0SelectedFilesPath": () => + _(ds0?.files).filter("selected").map("path").value(), + "#Dataset0SelectedFilesCount": () => + _(ds0?.files).filter("selected").size(), + "#Dataset0SelectedFilesTotalSize": () => + _(ds0?.files) + .filter("selected") + .sumBy((f) => Number(f.size || 0)), + "#DatasetsPid": () => _.map(datasets, "pid"), + "#DatasetsSourceFolder": () => _.map(datasets, "sourceFolder"), + "#DatasetsFilesPath": () => + _(datasets).flatMap("files").map("path").value(), + "#DatasetsFilesTotalSize": () => + _(datasets) + .flatMap("files") + .sumBy((f) => Number(f.size || 0)), + "#DatasetsSelectedFilesPath": () => + _(datasets).flatMap("files").filter("selected").map("path").value(), + "#DatasetsSelectedFilesCount": () => + _(datasets).flatMap("files").filter("selected").size(), + "#DatasetsSelectedFilesTotalSize": () => + _(datasets) + .flatMap("files") + .filter("selected") + .sumBy((f) => Number(f.size || 0)), + }; + return staticMap; } - update_status() { - Object.entries(this.actionConfig.variables ?? {}).forEach( - ([key, selector]) => { - this.variables[key] = processSelector(this.actionItems, selector); - }, + private viewHandlers(condition: string): string { + let expr = condition; + const symbols: Record = { + "#datasetOwner": "context.isOwner", + "#userIsAdmin": "context.isAdmin", + "#isPublished": String( + this.actionItems.datasets?.[0]?.isPublished === true, + ), + "#!isPublished": String( + this.actionItems.datasets?.[0]?.isPublished === false, + ), + }; + + Object.entries(symbols).forEach(([k, v]) => (expr = expr.replaceAll(k, v))); + expr = expr.replace(/@([\w.]+)/g, "variables.$1"); + expr = expr.replace(/#Length\((.*?)\)/g, "($1?.length ?? 0)"); + expr = expr.replace( + /#MaxDownloadableSize\((.*?)\)/g, + "$1 <= context.maxSize", ); + + return expr; } - get context() { - return { - variables: this.variables, - maxDownloadableSize: this.configService.getConfig().maxDirectDownloadSize, - datasetOwner: ( - this.actionItems.datasets.map((d): boolean => { - return this.userProfile.accessGroups?.includes(d.ownerGroup) || false; - }) as Array - ).some(Boolean), - isAdmin: this.isAdmin, - }; + private authorizationHandlers(definition: string): string { + return this.authorizationTokens[definition]?.() ?? definition; } - get disabled() { - let res = false; - try { - this.update_status(); - - const expr = this.disabled_condition; - const fn = new Function("ctx", `with (ctx) { return (${expr}); }`); - const { context } = this; - res = fn(context); - } catch (error) { - console.error("Configurable action error on get disabled", error); - } - return res; + private resolve(definition: string): unknown { + if (definition.startsWith("@")) + return _.get(this.variables, definition.slice(1)); + if (definition in this.authorizationTokens) + return this.authorizationHandlers(definition); + return definition; } - get visible() { - if (!this.actionConfig.hidden) { - return true; - } else { - const expr = this.prepare_hidden_condition(); - const fn = new Function("ctx", `with (ctx) { return (${expr}); }`); + private interpolate(template: string): string { + if (!template) return ""; + return template.replace( + /\{\{\s*([@#][\w.]+(\[\])?)\s*\}\}/g, + (fullMatch, match) => { + const isArray = match.endsWith("[]"); + const clean = match.replace("[]", ""); + + const value = this.resolve(clean); + return isArray + ? JSON.stringify(_.castArray(value ?? [])) + : String(value ?? ""); + }, + ); + } - return fn({ + private evaluate(expr: string): boolean { + try { + const context = { variables: this.variables, - maxDownloadableSize: - this.configService.getConfig().maxDirectDownloadSize, - }); + context: { + isAdmin: this.isAdmin, + isOwner: this.isDatasetOwner, + maxSize: this.configService.getConfig().maxDirectDownloadSize, + }, + }; + const fn = new Function("ctx", `with(ctx){ return ${expr}; }`); + return fn(context); + } catch (e) { + console.error("Evaluation error:", expr, e); + return false; } } - add_input(name: string, value: string) { - const input = document.createElement("input"); - input.type = "hidden"; - input.name = name; - input.value = value; - return input; + private get isDatasetOwner(): boolean { + const datasets = _.get(this.actionItems, "datasets", []) as DatasetClass[]; + const userGroups = _.get(this.userProfile, "accessGroups", []) as string[]; + return _.some(datasets, (d) => userGroups.includes(d.ownerGroup)); } - perform_action() { - const action_type = this.actionConfig.type || "form"; - switch (action_type) { - case "json-download": - return this.type_json_to_download(); - case "xhr": - return this.type_xhr(); - case "link": - return this.type_link(); - case "form": - default: - return this.type_form(); - } + private resolveVariableContext() { + Object.entries(this.actionConfig.variables ?? {}).forEach( + ([key, selector]) => { + this.variables[key] = this.variableHandler(selector); + }, + ); } + private typeXhr() { + const url = this.interpolate(this.actionConfig.url); + const headers: Record = { + "Content-Type": "application/json", + }; - get_value_from_definition(definition: string) { - if (definition == "#token" || definition == "#tokenSimple") { - return this.authService.getToken().id; - } else if (definition == "#tokenBearer") { - return `Bearer ${this.authService.getToken().id}`; - } else if (definition == "#jwt") { - return this.jwt; - } else if (definition == "#uuid") { - return v4(); - } else if (definition.startsWith("@")) { - return this.variables[definition.slice(1)]; - } - return definition; - } + Object.entries(this.actionConfig.headers || {}).forEach(([k, v]) => { + headers[k] = String(this.resolve(v)); + }); - get_auth_headers(headers: Record) { - const headerKey = "Authorization"; - if (headerKey in headers) { - const currentValue = headers[headerKey]; - const updatedValue = this.get_value_from_definition(currentValue); + fetch(url, { + method: this.actionConfig.method || "POST", + headers, + body: this.preparePayload(), + }) + .then(async (r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const data = await r.json().catch(() => ({})); - headers[headerKey] = updatedValue; - } - return headers; + this.actionFinishedEmit(true, data); + }) + .catch((err: Error) => { + this.snackBar.open("Action failed", "Close", { duration: 2000 }); + this.actionFinishedEmit(false, err); + }); + return true; } - type_form() { - if (this.form !== null) { - document.body.removeChild(this.form); - } - + private typeForm() { + if (this.form) document.body.removeChild(this.form); this.form = document.createElement("form"); this.form.target = this.actionConfig.target || "_self"; this.form.method = this.actionConfig.method || "POST"; this.form.action = this.actionConfig.url; this.form.style.display = "none"; - // use the configuration under inputs to create the form - Object.entries(this.actionConfig.inputs).forEach(([input, definition]) => { - const value = this.get_value_from_definition(definition); + Object.entries(this.actionConfig.inputs || {}).forEach(([input, def]) => { + const value = this.resolve(def); if (input.endsWith("[]")) { - const itemInput = input.slice(0, -2); - - const iteratable = Array.isArray(value) ? value : [value]; - iteratable.forEach((itemValue, itemIndex) => { - this.form.appendChild( - this.add_input(`${itemInput}[${itemIndex}]`, itemValue), - ); - }); + const name = input.slice(0, -2); + _.castArray(value).forEach((v, i) => + this.form!.appendChild( + this.addInputElement(`${name}[${i}]`, String(v)), + ), + ); } else { - this.form.appendChild(this.add_input(input, value)); + this.form!.appendChild(this.addInputElement(input, String(value))); } }); document.body.appendChild(this.form); this.form.submit(); - return true; } - get_payload() { - let payload = ""; - if (this.actionConfig.payload == "#dump") { - payload = JSON.stringify(this.variables); - } else if ( - this.actionConfig.payload != "#empty" && - this.actionConfig.payload - ) { - payload = this.actionConfig.payload; - } - - const readyPayload = payload.replace( - /\{\{\s*([@#]\w+(\[\])?)\s*\}\}/g, - (_, variableName) => { - if (variableName.endsWith("[]")) { - const variableNameClean = variableName.slice(0, -2); - const value = this.get_value_from_definition(variableNameClean); - - const iteratable = !value - ? [] - : Array.isArray(value) - ? value - : [value]; - return JSON.stringify(iteratable); - } else { - return this.get_value_from_definition(variableName); - } - }, - ); - - return readyPayload; + private preparePayload(): string { + const { payload } = this.actionConfig; + if (payload === "#dump") return JSON.stringify(this.variables); + if (!payload || payload === "#empty") return "{}"; + return this.interpolate(payload); } - type_json_to_download() { - const filename = this.actionConfig.filename.replace( - /\{\{\s*([@#]\w+)\s*\}\}/g, - (_, variableName) => this.get_value_from_definition(variableName), + private typeJsonToDownload() { + const filename = this.interpolate( + this.actionConfig.filename || "download.json", ); + const headers: Record = { + "Content-Type": "application/json", + }; + + Object.entries(this.actionConfig.headers || {}).forEach(([k, v]) => { + headers[k] = String(this.resolve(v)); + }); - const method = this.actionConfig.method || "POST"; - const payload = this.get_payload(); - const headers = this.get_auth_headers(this.actionConfig.headers || {}); fetch(this.actionConfig.url, { - method: method, - headers: { - ...{ - "Content-Type": "application/json", - }, - ...(headers || {}), - }, - body: payload, + method: this.actionConfig.method || "POST", + headers, + body: this.preparePayload(), }) - .then((response) => { - if (response.ok) { - return response.blob(); - } else { - // http error - return Promise.reject( - new Error(`HTTP Error code: ${response.status}`), - ); - } - }) - .then((blob) => URL.createObjectURL(blob)) - .then((url) => { + .then((r) => (r.ok ? r.blob() : Promise.reject())) + .then((blob) => { + const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }) - .catch((error) => { - this.snackBar.open( - "There has been an error performing the action", - "Close", - { - duration: 2000, - }, - ); - }); - + .catch(() => + this.snackBar.open("Download failed", "Close", { duration: 2000 }), + ); return true; } - type_xhr() { - const url = this.actionConfig.url.replace( - /{{\s*(\w+)\s*}}/g, - (_, variableName) => - encodeURIComponent(this.get_value_from_definition(variableName)), - ); - const headers = this.get_auth_headers(this.actionConfig.headers || {}); + private typeLink() { + window.open(this.actionConfig.url, this.actionConfig.target || "_self"); + } - fetch(url, { - method: this.actionConfig.method || "POST", - headers: { - ...{ - "Content-Type": "application/json", - }, - ...(headers || {}), - }, - body: this.get_payload(), - }) - .then((response) => { - if (!response.ok) { - return Promise.reject( - new Error(`HTTP Error code: ${response.status}`), - ); - } - - // specific only for datasets - // cannot be used - // this.store.dispatch( - // updatePropertyAction({ - // method: this.actionConfig.method, - // pid: element.pid, - // property: JSON.parse(this.actionConfig.payload), - // }), - // ); - - return response; - }) - .catch((error) => { - this.snackBar.open( - "There has been an error performing the action", - "Close", - { - duration: 2000, - }, - ); + private typeDialog() { + const dialogRef = this.dialog.open(DialogComponent, { + width: this.actionConfig.dialog?.width || "450px", + data: this.prepareDialogData(), + }); + + dialogRef.afterClosed().subscribe((result: Record) => { + if (!result) return; + const dialogRes: Record = {}; + this.actionConfig.dialog?.fields?.forEach((f) => { + const v = result[f.key]; + dialogRes[f.key] = + v && typeof v === "object" && "option" in v ? v.option : v; }); + this.variables["dialog"] = dialogRes; + if (this.actionConfig.onSuccess) + this.executeNextStep(this.actionConfig.onSuccess); + }); + } - return true; + private executeNextStep(nextStep: ActionType) { + if (nextStep === "xhr") this.typeXhr(); + if (nextStep === "form") this.typeForm(); + if (nextStep === "json-download") this.typeJsonToDownload(); } - type_link() { - window.open(this.actionConfig.url, this.actionConfig.target || "_self"); + private addInputElement(name: string, value: string): HTMLInputElement { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = name; + input.value = value; + return input; + } + + private prepareDialogData(): DynamicDialogData { + const conf = this.actionConfig.dialog!; + const data: DynamicDialogData = { + title: conf.title || "Confirm", + question: conf.description || "", + additionalFields: {}, + }; + + conf.fields?.forEach((f: DialogField) => { + data.additionalFields![f.key] = { + label: f.label, + type: f.type, + required: f.required, + options: f.options?.map((opt) => { + if (typeof opt === "string") return { option: opt }; + return { + option: opt.option, + tooltip: opt.tooltip, + thumbnail: (opt as any).thumbnail || null, + }; + }), + }; + }); + + return data; + } + + private actionFinishedEmit(success: boolean, payload?: unknown) { + this.actionFinished.emit({ + success, + result: success ? payload : undefined, + error: !success ? (payload as Error) : undefined, + }); + } + get visible(): boolean { + this.resolveVariableContext(); + if (!this.actionConfig.hidden) return true; + return !this.evaluate(this.viewHandlers(this.actionConfig.hidden)); + } + + get disabled(): boolean { + this.resolveVariableContext(); + const raw = this.actionConfig.enabled + ? `!(${this.actionConfig.enabled})` + : this.actionConfig.disabled || "false"; + return this.evaluate(this.viewHandlers(raw)); + } + + ngOnInit() { + this.subscriptions.push( + this.userProfile$.subscribe((up) => up && (this.userProfile = up)), + ); + this.subscriptions.push( + this.isAdmin$.subscribe((ia) => (this.isAdmin = ia)), + ); + this.useMatIcon = !!this.actionConfig.mat_icon; + this.useIcon = this.actionConfig.icon !== undefined; + this.resolveVariableContext(); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes["actionItems"]) this.resolveVariableContext(); + } + + performAction() { + this.resolveVariableContext(); + const type = this.actionConfig.type || "form"; + switch (type) { + case "json-download": + return this.typeJsonToDownload(); + case "xhr": + return this.typeXhr(); + case "link": + return this.typeLink(); + case "dialog": + return this.typeDialog(); + case "form": + default: + return this.typeForm(); + } } } diff --git a/src/app/shared/modules/configurable-actions/configurable-action.interfaces.ts b/src/app/shared/modules/configurable-actions/configurable-action.interfaces.ts index 7e81519b05..0acaf4dc92 100644 --- a/src/app/shared/modules/configurable-actions/configurable-action.interfaces.ts +++ b/src/app/shared/modules/configurable-actions/configurable-action.interfaces.ts @@ -1,18 +1,31 @@ import { DataFiles_File } from "datasets/datafiles/datafiles.interfaces"; +import { DynamicField } from "../dialog/dialog.component"; +import { DatasetClass } from "@scicatproject/scicat-sdk-ts-angular"; + +export type DialogField = { key: string } & DynamicField; + +export interface DialogConfig { + title?: string; + description?: string; + width?: string; + fields: DialogField[]; +} + +export type ActionType = "form" | "link" | "json-download" | "xhr" | "dialog"; export interface ActionConfig { id: string; description?: string; order: number; label: string; - files?: string; + files?: "all" | "selected"; mat_icon?: string; icon?: string; - type?: string; + type?: ActionType; url: string; - target: string; + target?: "_blank" | "_self" | "_parent" | "_top"; authorization: string[]; - method?: string; + method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; enabled?: string; disabled?: string; payload?: string; @@ -21,22 +34,20 @@ export interface ActionConfig { variables?: Record; inputs?: Record; headers?: Record; + onSuccess?: ActionType; + dialog?: DialogConfig; } -// export interface ActionItem { -// pid: string; -// sourceFolder?: string; -// isPublished?: boolean; -// } - -export interface ActionItemDataset { - ownerGroup: string; - pid: string; - sourceFolder?: string; - isPublished?: boolean; +export type ActionItemDataset = DatasetClass & { files?: DataFiles_File[]; -} +}; export interface ActionItems { datasets: ActionItemDataset[]; + [key: string]: unknown; +} + +export interface ActionButtonStyle { + raised?: boolean; + color?: "primary" | "accent" | "warn"; } diff --git a/src/app/shared/modules/configurable-actions/configurable-actions.component.html b/src/app/shared/modules/configurable-actions/configurable-actions.component.html index 4f9fbddaa1..583ec515ea 100644 --- a/src/app/shared/modules/configurable-actions/configurable-actions.component.html +++ b/src/app/shared/modules/configurable-actions/configurable-actions.component.html @@ -4,6 +4,8 @@ *ngFor="let actionConfig of sortedActionsConfig" [actionConfig]="actionConfig" [actionItems]="actionItems" + [buttonStyle]="buttonsStyle" + (actionFinished)="actionFinished.emit($event)" >
diff --git a/src/app/shared/modules/configurable-actions/configurable-actions.component.ts b/src/app/shared/modules/configurable-actions/configurable-actions.component.ts index 0f17c7a7b4..e1010209bf 100644 --- a/src/app/shared/modules/configurable-actions/configurable-actions.component.ts +++ b/src/app/shared/modules/configurable-actions/configurable-actions.component.ts @@ -1,5 +1,9 @@ -import { Component, Input } from "@angular/core"; -import { ActionConfig, ActionItems } from "./configurable-action.interfaces"; +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { + ActionButtonStyle, + ActionConfig, + ActionItems, +} from "./configurable-action.interfaces"; import { AppConfigService } from "app-config.service"; @@ -12,6 +16,15 @@ import { AppConfigService } from "app-config.service"; export class ConfigurableActionsComponent { @Input({ required: true }) actionsConfig: ActionConfig[] = []; @Input({ required: true }) actionItems: ActionItems; + @Input({ required: false }) buttonsStyle: ActionButtonStyle = { + raised: true, + color: "accent", + }; + @Output() actionFinished = new EventEmitter<{ + success: boolean; + result?: unknown; + error?: Error; + }>(); constructor(public appConfigService: AppConfigService) {} diff --git a/src/app/shared/modules/dialog/dialog.component.ts b/src/app/shared/modules/dialog/dialog.component.ts index c3d2204e32..df754fa59d 100644 --- a/src/app/shared/modules/dialog/dialog.component.ts +++ b/src/app/shared/modules/dialog/dialog.component.ts @@ -1,5 +1,4 @@ import { Component, Inject } from "@angular/core"; -import { FormGroup } from "@angular/forms"; import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material/dialog"; export interface DialogOptionData { @@ -32,9 +31,6 @@ export interface DynamicDialogData { standalone: false, }) export class DialogComponent { - form: FormGroup = new FormGroup({}); - fieldKeys: string[] = []; - constructor( public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: DynamicDialogData, From 865e00bf705c7bc05fb7de844fcf4a145c44dae9 Mon Sep 17 00:00:00 2001 From: minottic Date: Wed, 22 Apr 2026 14:21:47 +0000 Subject: [PATCH 3/5] Add tests --- .../configurable-action.component.spec.ts | 97 +++++++++++++++---- .../configurable-actions.component.spec.ts | 10 ++ .../configurable-actions.test.data.ts | 41 +++++++- 3 files changed, 128 insertions(+), 20 deletions(-) diff --git a/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts b/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts index dc448b4d0f..e9e6e69e84 100644 --- a/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts +++ b/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts @@ -9,7 +9,7 @@ import { PipesModule } from "shared/pipes/pipes.module"; import { ReactiveFormsModule } from "@angular/forms"; import { MatDialogModule, MatDialogRef } from "@angular/material/dialog"; import { RouterModule } from "@angular/router"; -import { Store, StoreModule } from "@ngrx/store"; +import { StoreModule } from "@ngrx/store"; import { MockAuthService, MockHtmlElement, @@ -36,10 +36,10 @@ import { mockAppConfigService, mockUserProfiles, } from "./configurable-actions.test.data"; -import { Subject } from "rxjs"; +import { of } from "rxjs"; import { MockStore, provideMockStore } from "@ngrx/store/testing"; import { selectProfile } from "state-management/selectors/user.selectors"; -import { ActionItems } from "./configurable-action.interfaces"; +import { ActionConfig, ActionItems } from "./configurable-action.interfaces"; describe("1000: ConfigurableActionComponent", () => { let component: ConfigurableActionComponent; @@ -59,6 +59,8 @@ describe("1000: ConfigurableActionComponent", () => { publish = "9c6a11b6-a526-11f0-8795-6f025b320cc3", unpublish = "94a1d694-a526-11f0-947b-038d53cd837a", link = "c3bcbd40-a526-11f0-915a-93eeff0860ab", + dialog_open = "6a4d5226-1cf8-4dbf-a7db-9a4a16b1f523", + dialog_xhr = "4fcf5658-95f4-4fbd-99fd-8df4bb4bf0d0", } const usersControllerGetUserJWTV3 = () => ({ @@ -71,10 +73,6 @@ describe("1000: ConfigurableActionComponent", () => { // id: "4ac45f3e-4d79-11ef-856c-6339dab93bee", // }); - class MockUserProfile { - userProfile$ = new Subject(); - } - beforeAll(() => { htmlForm = document.createElement("form"); (htmlForm as HTMLFormElement).submit = () => {}; @@ -124,7 +122,10 @@ describe("1000: ConfigurableActionComponent", () => { store = TestBed.inject(MockStore); })); - function createComponent(componentActionConfig, componentsActionItems) { + function createComponent( + componentActionConfig: ActionConfig, + componentsActionItems: ActionItems, + ) { fixture = TestBed.createComponent(ConfigurableActionComponent); component = fixture.componentInstance; component.actionConfig = componentActionConfig; @@ -793,8 +794,9 @@ describe("1000: ConfigurableActionComponent", () => { } as TestCase); const componentElement: HTMLElement = fixture.nativeElement; - const actionButton = componentElement.querySelector(".action-button"); - expect(actionButton.innerHTML).toContain( + const actionButton = componentElement.querySelector("button"); + expect(actionButton).not.toBeNull(); + expect(actionButton?.textContent).toContain( mockActionsConfig.filter( (a) => a.id == actionSelectorType.download_all, )[0].label, @@ -811,8 +813,9 @@ describe("1000: ConfigurableActionComponent", () => { } as TestCase); const componentElement: HTMLElement = fixture.nativeElement; - const actionButton = componentElement.querySelector(".action-button"); - expect(actionButton.innerHTML).toContain( + const actionButton = componentElement.querySelector("button"); + expect(actionButton).not.toBeNull(); + expect(actionButton?.textContent).toContain( mockActionsConfig.filter( (a) => a.id == actionSelectorType.download_selected, )[0].label, @@ -829,8 +832,9 @@ describe("1000: ConfigurableActionComponent", () => { } as TestCase); const componentElement: HTMLElement = fixture.nativeElement; - const actionButton = componentElement.querySelector(".action-button"); - expect(actionButton.innerHTML).toContain( + const actionButton = componentElement.querySelector("button"); + expect(actionButton).not.toBeNull(); + expect(actionButton?.textContent).toContain( mockActionsConfig.filter( (a) => a.id == actionSelectorType.notebook_all_form, )[0].label, @@ -847,8 +851,9 @@ describe("1000: ConfigurableActionComponent", () => { } as TestCase); const componentElement: HTMLElement = fixture.nativeElement; - const actionButton = componentElement.querySelector(".action-button"); - expect(actionButton.innerHTML).toContain( + const actionButton = componentElement.querySelector("button"); + expect(actionButton).not.toBeNull(); + expect(actionButton?.textContent).toContain( mockActionsConfig.filter( (a) => a.id == actionSelectorType.notebook_selected_form, )[0].label, @@ -879,7 +884,7 @@ describe("1000: ConfigurableActionComponent", () => { component.performAction(); const spy = window.fetch as jasmine.Spy; - expect(spy.calls.any()).toBeTrue(); + expect(spy.calls.count()).toBeGreaterThan(0); const call = spy.calls.mostRecent(); expect(call).toBeDefined(); const [url, opts] = call.args; @@ -933,7 +938,7 @@ describe("1000: ConfigurableActionComponent", () => { const spy = window.fetch as jasmine.Spy; - expect(spy.calls.any()).toBeTrue(); + expect(spy.calls.count()).toBeGreaterThan(0); const call = spy.calls.mostRecent(); expect(call).toBeDefined(); @@ -983,4 +988,60 @@ describe("1000: ConfigurableActionComponent", () => { current_action.target, ); }); + + it("1140: dialog action should open dialog with configured data", () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.dialog_open, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + result: false, + } as TestCase); + + spyOn(component.dialog, "open").and.returnValue({ + afterClosed: () => of(undefined), + } as MatDialogRef); + + component.performAction(); + + expect(component.dialog.open).toHaveBeenCalled(); + }); + + it("1150: dialog action should execute xhr on close using dialog variables", () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.dialog_xhr, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + result: false, + } as TestCase); + + spyOn(component.dialog, "open").and.returnValue({ + afterClosed: () => of({ reason: "integration-test" }), + } as MatDialogRef); + + spyOn(window, "fetch").and.returnValue( + Promise.resolve( + new Response(new Blob(), { + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + component.performAction(); + + const fetchSpy = window.fetch as jasmine.Spy; + expect(fetchSpy.calls.count()).toBe(1); + const [url, opts] = fetchSpy.calls.mostRecent().args; + expect(url).toBe("https://example.org/action"); + + const requestOptions = opts as RequestInit; + expect(requestOptions.method).toBe("POST"); + + const body = JSON.parse(String(requestOptions.body)); + expect(body.dataset).toBe(mockActionItemsDatafilesNofiles.datasets[0].pid); + expect(body.reason).toBe("integration-test"); + }); }); diff --git a/src/app/shared/modules/configurable-actions/configurable-actions.component.spec.ts b/src/app/shared/modules/configurable-actions/configurable-actions.component.spec.ts index 184fe336a7..8b8222cb5b 100644 --- a/src/app/shared/modules/configurable-actions/configurable-actions.component.spec.ts +++ b/src/app/shared/modules/configurable-actions/configurable-actions.component.spec.ts @@ -107,6 +107,16 @@ describe("1010: ConfigurableActionsComponent", () => { expect(component.maxFileSize).toEqual(lowerMaxFileSizeLimit); }); + it("0055: max file size should fallback to zero when not configured", () => { + const config = mockAppConfigService.appConfig as { + maxDirectDownloadSize?: number; + datafilesActionsEnabled: boolean; + }; + config.maxDirectDownloadSize = undefined; + + expect(component.maxFileSize).toEqual(0); + }); + it("0060: there should be as many actions as defined in default configuration", async () => { component.actionsConfig = mockActionsConfig; diff --git a/src/app/shared/modules/configurable-actions/configurable-actions.test.data.ts b/src/app/shared/modules/configurable-actions/configurable-actions.test.data.ts index 7e75862b7e..289e8b853e 100644 --- a/src/app/shared/modules/configurable-actions/configurable-actions.test.data.ts +++ b/src/app/shared/modules/configurable-actions/configurable-actions.test.data.ts @@ -223,6 +223,43 @@ export const mockActionsConfig: ActionConfig[] = [ }, ]; +mockActionsConfig.push({ + ...mockActionsConfig[0], + order: 9, + id: "6a4d5226-1cf8-4dbf-a7db-9a4a16b1f523", + label: "Dialog Action", + type: "dialog", + dialog: { + title: "Confirm", + description: "Why?", + width: "500px", + fields: [{ key: "reason", label: "Reason", type: "text" }], + }, +}); + +mockActionsConfig.push({ + ...mockActionsConfig[0], + order: 10, + id: "4fcf5658-95f4-4fbd-99fd-8df4bb4bf0d0", + label: "Dialog XHR", + type: "dialog", + method: "POST", + url: "https://example.org/action", + headers: { + "Content-Type": "application/json", + }, + variables: { + pid: "#Dataset0Pid", + }, + payload: '{"dataset":"{{ @pid }}","reason":"{{ @dialog.reason }}"}', + onSuccess: "xhr", + dialog: { + title: "Confirm", + description: "Why?", + fields: [{ key: "reason", label: "Reason", type: "text" }], + }, +}); + export const mockActionItems: ActionItems = { datasets: [ { @@ -250,7 +287,7 @@ export const mockActionItems: ActionItems = { time: "2019-09-06T13:11:37.102Z", }, ], - }, + } as ActionItemDataset, { pid: "48217db2-bee2-11f0-ace4-b7a1618f0eba", sourceFolder: "/source/folder/2", @@ -264,7 +301,7 @@ export const mockActionItems: ActionItems = { time: "2019-09-06T13:11:37.102Z", }, ], - }, + } as ActionItemDataset, ], }; From cf959c8a923980f3d0190f594a4cf8bd989e0f34 Mon Sep 17 00:00:00 2001 From: minottic Date: Wed, 22 Apr 2026 14:45:40 +0000 Subject: [PATCH 4/5] Add tests on functions --- .../configurable-action.component.spec.ts | 271 +++++++++++++++++- 1 file changed, 270 insertions(+), 1 deletion(-) diff --git a/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts b/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts index e9e6e69e84..7f25b56e95 100644 --- a/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts +++ b/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts @@ -38,7 +38,10 @@ import { } from "./configurable-actions.test.data"; import { of } from "rxjs"; import { MockStore, provideMockStore } from "@ngrx/store/testing"; -import { selectProfile } from "state-management/selectors/user.selectors"; +import { + selectIsAdmin, + selectProfile, +} from "state-management/selectors/user.selectors"; import { ActionConfig, ActionItems } from "./configurable-action.interfaces"; describe("1000: ConfigurableActionComponent", () => { @@ -1044,4 +1047,270 @@ describe("1000: ConfigurableActionComponent", () => { expect(body.dataset).toBe(mockActionItemsDatafilesNofiles.datasets[0].pid); expect(body.reason).toBe("integration-test"); }); + + it("1152: #datasetOwner token should enable action for owner", () => { + store.overrideSelector(selectProfile, mockUserProfiles[1]); + store.overrideSelector(selectIsAdmin, false); + store.refreshState(); + + const ownerConfig: ActionConfig = { + ...mockActionsConfig[0], + id: "owner-enabled-test", + enabled: "#datasetOwner", + variables: {}, + }; + + createComponent(ownerConfig, mockActionItemsDatafilesNofiles); + expect(component.disabled).toBeFalse(); + }); + + it("1153: #userIsAdmin token should enable action for admin", () => { + store.overrideSelector(selectProfile, mockUserProfiles[2]); + store.overrideSelector(selectIsAdmin, true); + store.refreshState(); + + const adminConfig: ActionConfig = { + ...mockActionsConfig[0], + id: "admin-enabled-test", + enabled: "#userIsAdmin", + variables: {}, + }; + + createComponent(adminConfig, mockActionItemsDatafilesNofiles); + expect(component.disabled).toBeFalse(); + }); + + it("1154: #isPublished token should follow dataset publish status", () => { + const publishedItems: ActionItems = { + datasets: structuredClone(mockActionItemsDatafilesNofiles.datasets), + }; + publishedItems.datasets[0].isPublished = true; + + const publishedConfig: ActionConfig = { + ...mockActionsConfig[0], + id: "published-enabled-test", + enabled: "#isPublished", + variables: {}, + }; + + createComponent(publishedConfig, publishedItems); + expect(component.disabled).toBeFalse(); + }); + + it("1155: #!isPublished token should follow dataset publish status", () => { + const unpublishedItems: ActionItems = { + datasets: structuredClone(mockActionItemsDatafilesNofiles.datasets), + }; + unpublishedItems.datasets[0].isPublished = false; + + const unpublishedConfig: ActionConfig = { + ...mockActionsConfig[0], + id: "unpublished-enabled-test", + enabled: "#!isPublished", + variables: {}, + }; + + createComponent(unpublishedConfig, unpublishedItems); + expect(component.disabled).toBeFalse(); + }); + + it("1156: #Length should evaluate selected file list length", () => { + const lengthConfig: ActionConfig = { + ...mockActionsConfig[0], + id: "length-enabled-test", + enabled: "#Length(@files) > 0", + variables: { + files: "#Dataset0SelectedFilesPath", + }, + }; + + createComponent(lengthConfig, mockActionItemsDatafilesNofiles); + expect(component.disabled).toBeTrue(); + + createComponent(lengthConfig, mockActionItemsDatafilesFile1); + expect(component.disabled).toBeFalse(); + }); + + it("1157: #MaxDownloadableSize should compare against configured max size", () => { + mockAppConfigService.appConfig.maxDirectDownloadSize = + lowerMaxFileSizeLimit; + + const sizeConfig: ActionConfig = { + ...mockActionsConfig[0], + id: "max-download-enabled-test", + enabled: "#MaxDownloadableSize(@totalSize)", + variables: { + totalSize: "#Dataset0FilesTotalSize", + }, + }; + + createComponent(sizeConfig, mockActionItemsDatafilesNofiles); + expect(component.disabled).toBeTrue(); + + mockAppConfigService.appConfig.maxDirectDownloadSize = + higherMaxFileSizeLimit; + createComponent(sizeConfig, mockActionItemsDatafilesNofiles); + expect(component.disabled).toBeFalse(); + }); + + it("1158: hidden expression should hide action when condition is true", () => { + const hiddenConfig: ActionConfig = { + ...mockActionsConfig[0], + id: "hidden-test", + hidden: "#Length(@files) === 0", + variables: { + files: "#Dataset0SelectedFilesPath", + }, + }; + + createComponent(hiddenConfig, mockActionItemsDatafilesNofiles); + expect(component.visible).toBeFalse(); + + createComponent(hiddenConfig, mockActionItemsDatafilesFile1); + expect(component.visible).toBeTrue(); + }); + + interface SelectorCoverageCase { + name: string; + selector: string; + expected: unknown; + } + + const allKeywordMapSelectors: SelectorCoverageCase[] = [ + { + name: "Dataset0Pid", + selector: "#Dataset0Pid", + expected: mockActionItems.datasets[0].pid, + }, + { + name: "Dataset0FilesPath", + selector: "#Dataset0FilesPath", + expected: (mockActionItems.datasets[0].files || []).map((f) => f.path), + }, + { + name: "Dataset0FilesTotalSize", + selector: "#Dataset0FilesTotalSize", + expected: (mockActionItems.datasets[0].files || []).reduce( + (sum, f) => sum + Number(f.size || 0), + 0, + ), + }, + { + name: "Dataset0SourceFolder", + selector: "#Dataset0SourceFolder", + expected: mockActionItems.datasets[0].sourceFolder, + }, + { + name: "Dataset0SelectedFilesPath", + selector: "#Dataset0SelectedFilesPath", + expected: (mockActionItems.datasets[0].files || []) + .filter((f) => f.selected) + .map((f) => f.path), + }, + { + name: "Dataset0SelectedFilesCount", + selector: "#Dataset0SelectedFilesCount", + expected: (mockActionItems.datasets[0].files || []).filter( + (f) => f.selected, + ).length, + }, + { + name: "Dataset0SelectedFilesTotalSize", + selector: "#Dataset0SelectedFilesTotalSize", + expected: (mockActionItems.datasets[0].files || []) + .filter((f) => f.selected) + .reduce((sum, f) => sum + Number(f.size || 0), 0), + }, + { + name: "DatasetIndexedField", + selector: "#Dataset[0]Field[isPublished]", + expected: mockActionItems.datasets[0].isPublished, + }, + { + name: "DatasetsPid", + selector: "#DatasetsPid", + expected: mockActionItems.datasets.map((d) => d.pid), + }, + { + name: "DatasetsSourceFolder", + selector: "#DatasetsSourceFolder", + expected: mockActionItems.datasets.map((d) => d.sourceFolder), + }, + { + name: "DatasetsFilesPath", + selector: "#DatasetsFilesPath", + expected: mockActionItems.datasets.flatMap((d) => + (d.files || []).map((f) => f.path), + ), + }, + { + name: "DatasetsFilesTotalSize", + selector: "#DatasetsFilesTotalSize", + expected: mockActionItems.datasets + .flatMap((d) => d.files || []) + .reduce((sum, f) => sum + Number(f.size || 0), 0), + }, + { + name: "DatasetsSelectedFilesPath", + selector: "#DatasetsSelectedFilesPath", + expected: mockActionItems.datasets + .flatMap((d) => d.files || []) + .filter((f) => f.selected) + .map((f) => f.path), + }, + { + name: "DatasetsSelectedFilesCount", + selector: "#DatasetsSelectedFilesCount", + expected: mockActionItems.datasets + .flatMap((d) => d.files || []) + .filter((f) => f.selected).length, + }, + { + name: "DatasetsSelectedFilesTotalSize", + selector: "#DatasetsSelectedFilesTotalSize", + expected: mockActionItems.datasets + .flatMap((d) => d.files || []) + .filter((f) => f.selected) + .reduce((sum, f) => sum + Number(f.size || 0), 0), + }, + { + name: "DatasetsField", + selector: "#DatasetsField[sourceFolder]", + expected: mockActionItems.datasets.map((d) => d.sourceFolder), + }, + { + name: "Dataset[0]Field[size]", + selector: "#Dataset[0]Field[size]", + expected: mockActionItems.datasets[0].size, + }, + { + name: "#user.username", + selector: "#user.username", + expected: "abc", + }, + { + name: "#prop1", + selector: "#prop1", + expected: "prop1Value", + }, + ]; + + allKeywordMapSelectors.forEach((testCase) => { + it(`1160: ${testCase.name} selector should resolve`, () => { + const selectorConfig: ActionConfig = { + ...mockActionsConfig[0], + id: `selector-${testCase.name}`, + type: "link", + variables: { + value: testCase.selector, + }, + }; + createComponent(selectorConfig, { + ...mockActionItems, + user: { username: "abc" }, + prop1: "prop1Value", + }); + expect(component.variables["value"]).toEqual(testCase.expected); + }); + }); }); From ee22024243272fa7ee96efdfb6c0d44ed2a0ba93 Mon Sep 17 00:00:00 2001 From: minottic Date: Wed, 22 Apr 2026 17:50:37 +0000 Subject: [PATCH 5/5] Update documentation --- ...e-actions-configuration-reference-table.md | 46 ++++++++++--------- .../doc/configurable-actions-non-technical.md | 2 + .../doc/configurable-actions-technical.md | 26 ++++++++++- 3 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/app/shared/modules/configurable-actions/doc/configurable-actions-configuration-reference-table.md b/src/app/shared/modules/configurable-actions/doc/configurable-actions-configuration-reference-table.md index 9efa90e9f3..38e9bacc20 100644 --- a/src/app/shared/modules/configurable-actions/doc/configurable-actions-configuration-reference-table.md +++ b/src/app/shared/modules/configurable-actions/doc/configurable-actions-configuration-reference-table.md @@ -1,25 +1,27 @@ -| Option | Type | Required | Description | -| ------------- | --------- | -------- | ------------------------------------------------------ | -| id | string | Yes | Unique identifier for action | -| order | number | Yes | UI display order | -| label | string | Yes | Button label text | -| description | string | No | Tooltip/extra info | -| type | string | Yes | "form", "link", "json-download", or "xhr" | -| method | string | No | HTTP method for requests | -| url | string | Yes | Target URL (templated) | -| target | string | No | Browser tab/window target | -| icon | string | No | Display icon (path to image asset) | -| mat_icon | string | No | Material icon name | -| files | string | No | "all" or "selected" (file context) | -| enabled | string | No | Expression for enabling action | -| disabled | string | No | Expression for disabling action | -| hidden | string | No | Expression for hiding action | -| authorization | string[] | No | Expressions for user/group authorization | -| variables | object | No | Variable definitions and selectors | -| inputs | object | No | Form input mappings (for `"form"` type) | -| headers | object | No | HTTP headers (for `"xhr"`, `"json-download"`) | -| payload | string | No | Request body (`"xhr"`/`"json-download"` only) | -| filename | string | No | Download filename (`"json-download"` only) | +| Option | Type | Required | Description | +| ------------- | --------- | -------- | ----------------------------------------------------------------------------------- | +| id | string | Yes | Unique identifier for action | +| order | number | Yes | UI display order | +| label | string | Yes | Button label text | +| description | string | No | Tooltip/extra info | +| type | string | Yes | "form", "link", "json-download", "xhr", or "dialog" | +| method | string | No | HTTP method for requests | +| url | string | Yes | Target URL (templated) | +| target | string | No | Browser tab/window target | +| icon | string | No | Display icon (path to image asset) | +| mat_icon | string | No | Material icon name | +| files | string | No | "all" or "selected" (file context) | +| enabled | string | No | Expression for enabling action | +| disabled | string | No | Expression for disabling action | +| hidden | string | No | Expression for hiding action | +| authorization | string[] | No | Expressions for user/group authorization | +| variables | object | No | Variable definitions and selectors | +| inputs | object | No | Form input mappings (for `"form"` type) | +| headers | object | No | HTTP headers (for `"xhr"`, `"json-download"`) | +| payload | string | No | Request body (`"xhr"`/`"json-download"` only) | +| filename | string | No | Download filename (`"json-download"` only) | +| onSuccess | string | No | Next action type after dialog close (`"xhr"`, `"form"`, `"json-download"`) | +| dialog | object | No | Dialog definition (`title`, `description`, `width`, `fields[]`) for `"dialog"` type | --- > **Note:** This document has been generated with the help of AI using Neutro, the ESS dedicated AI app. \ No newline at end of file diff --git a/src/app/shared/modules/configurable-actions/doc/configurable-actions-non-technical.md b/src/app/shared/modules/configurable-actions/doc/configurable-actions-non-technical.md index bf59d6901f..b923226282 100644 --- a/src/app/shared/modules/configurable-actions/doc/configurable-actions-non-technical.md +++ b/src/app/shared/modules/configurable-actions/doc/configurable-actions-non-technical.md @@ -14,6 +14,7 @@ Configurable actions are special buttons in the ESS user interface that help you - **Generate notebooks** (for Jupyter or SciWyrm) - **Create download links** - **Change dataset status** (publish/unpublish) +- **Ask for extra input in a dialog** before running an action - And more, as new options are added ## Common Button Types @@ -25,6 +26,7 @@ Configurable actions are special buttons in the ESS user interface that help you | "Publish" | Make your dataset publicly available | | "Unpublish" | Make your dataset private again | | "ESS" | Link to an ESS webpage | +| "Dialog"-based actions | Ask for confirmation or user input first | *(Note: The actual icon may look different in your UI.)* diff --git a/src/app/shared/modules/configurable-actions/doc/configurable-actions-technical.md b/src/app/shared/modules/configurable-actions/doc/configurable-actions-technical.md index f50e1dd8e7..7b2782fc19 100644 --- a/src/app/shared/modules/configurable-actions/doc/configurable-actions-technical.md +++ b/src/app/shared/modules/configurable-actions/doc/configurable-actions-technical.md @@ -32,12 +32,13 @@ Below are supported configuration properties, their types, and descriptions. ### 5. `type` - **Type:** `string` -- **Accepted values:** `form`, `link`, `json-download`, `xhr` +- **Accepted values:** `form`, `link`, `json-download`, `xhr`, `dialog` - **Description:** Action execution mode: - `form`: Submits a hidden HTML form to `url`. - `link`: Opens URL in new tab/window. - `json-download`: Fetches data and starts file download. - `xhr`: Makes an XHR/fetch API call, optionally updating local state. + - `dialog`: Opens a configurable dialog before running a follow-up action. ### 6. `method` - **Type:** `string` @@ -114,6 +115,21 @@ Below are supported configuration properties, their types, and descriptions. - **Type:** `string` - **Description:** *(json-download only)*. Name for downloaded file (can use template, eg: `{{ #uuid }}.ipynb`). +### 21. `onSuccess` +- **Type:** `string` +- **Accepted values:** `xhr`, `form`, `json-download` +- **Description:** *(dialog only)*. Action to execute after the dialog closes with valid user input. + +### 22. `dialog` +- **Type:** `object` +- **Description:** *(dialog only)* Defines dialog UI. + + Typical shape: + - `title`: dialog title + - `description`: helper text/question + - `width`: dialog width (e.g. `"450px"`) + - `fields`: array of field definitions (`key`, `label`, `type`, `required`, `options`) + --- ## Supported Selectors in `variables` @@ -140,6 +156,14 @@ Below are supported configuration properties, their types, and descriptions. - `#token`, `#tokenBearer`, `#jwt`, `#uuid`: Various tokens and a random UUID. - `@`: Variable defined in `variables` mapping. +**Expression helper keywords (enabled/disabled/hidden):** +- `#datasetOwner`: True if user belongs to owner group of at least one dataset +- `#userIsAdmin`: True if user has admin role +- `#isPublished`: True if first dataset is published +- `#!isPublished`: True if first dataset is not published +- `#Length()`: Resolves to expression length (`0` if null/undefined) +- `#MaxDownloadableSize()`: Compares value against configured max direct download size + --- ## Expression and Templating