diff --git a/package-lock.json b/package-lock.json index 9b1e639e4c..a98326bcd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21005,4 +21005,4 @@ "license": "MIT" } } -} +} \ No newline at end of file diff --git a/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.ts b/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.ts index 5f0caa606f..a47e167387 100644 --- a/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.ts +++ b/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.ts @@ -70,6 +70,7 @@ export class DatasetDetailDynamicComponent implements OnInit, OnDestroy { actionItems: ActionItems = { datasets: [], + instruments: undefined, }; constructor( @@ -100,6 +101,10 @@ export class DatasetDetailDynamicComponent implements OnInit, OnDestroy { this.subscriptions.push( this.store.select(selectCurrentInstrument).subscribe((instrument) => { + if (instrument) { + console.log("Updatding action items"); + this.actionItems.instruments = [instrument]; + } this.instrument = instrument; }), ); 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..2a5c8bc342 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 @@ -6,6 +6,7 @@ import { MatButtonModule } from "@angular/material/button"; import { MatIconModule } from "@angular/material/icon"; import { MatTableModule } from "@angular/material/table"; import { PipesModule } from "shared/pipes/pipes.module"; +import { DatePipe } from "@angular/common"; import { ReactiveFormsModule } from "@angular/forms"; import { MatDialogModule, MatDialogRef } from "@angular/material/dialog"; import { RouterModule } from "@angular/router"; @@ -99,7 +100,7 @@ describe("1000: ConfigurableActionComponent", () => { StoreModule.forRoot({}), ], declarations: [ConfigurableActionComponent], - providers: [provideMockStore()], + providers: [provideMockStore(), DatePipe], }); TestBed.overrideComponent(ConfigurableActionComponent, { set: { @@ -983,4 +984,92 @@ describe("1000: ConfigurableActionComponent", () => { current_action.target, ); }); + + it("1140: should build dependency graph", () => { + const variables = { + files: "#Dataset0FilesPath", + first: "@files[0]", + copy: "@first", + }; + + const graph = component.buildDependenciesGraph(variables); + + expect(graph["files"]).toEqual(new Set()); + expect(graph["first"]).toEqual(new Set(["files"])); + expect(graph["copy"]).toEqual(new Set(["first"])); + }); + + it("1150:should support multiple dependencies", () => { + const variables = { + a: "@b", + b: "@c", + c: "@a", + }; + + const graph = component.buildDependenciesGraph(variables); + + expect(graph["a"]).toEqual(new Set(["b"])); + expect(graph["b"]).toEqual(new Set(["c"])); + expect(graph["c"]).toEqual(new Set(["a"])); + }); + + it("1160: should resolve dependent variables", () => { + component.actionConfig = { + variables: { + files: "#Dataset0FilesPath", + first: "@files[0]", + }, + } as any; + + component.update_status(); + + expect(component.variables["files"]).toEqual([ + "/file/1", + "/file/2", + "/file/3", + ]); + + expect(component.variables["first"]).toBe("/file/1"); + }); + + it("1170: should resolve chained dependencies", () => { + component.actionConfig = { + variables: { + files: "#Dataset0FilesPath", + first: "@files[0]", + copy: "@first", + }, + } as any; + + component.update_status(); + + expect(component.variables["copy"]).toBe("/file/1"); + }); + + it("1180: should detect cyclic dependencies", () => { + spyOn(console, "error"); + + component.actionConfig = { + variables: { + a: "@b", + b: "@a", + }, + } as any; + + component.update_status(); + + expect(console.error).toHaveBeenCalled(); + }); + + it("1190: should resolve selectors after variable substitution", () => { + component.actionConfig = { + variables: { + year: "#date_format(2026-05-12T14:53:45Z, yyyy)", + }, + } as any; + + component.update_status(); + + expect(component.variables["year"]).toBe("2026"); + }); }); 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..509bd15a25 100644 --- a/src/app/shared/modules/configurable-actions/configurable-action.component.ts +++ b/src/app/shared/modules/configurable-actions/configurable-action.component.ts @@ -5,15 +5,14 @@ import { OnInit, SimpleChanges, } from "@angular/core"; +import { DatePipe } from "@angular/common"; import { UsersService } from "@scicatproject/scicat-sdk-ts-angular"; import { ActionConfig, ActionItems } from "./configurable-action.interfaces"; -import { DataFiles_File } from "datasets/datafiles/datafiles.interfaces"; import { AuthService } from "shared/services/auth/auth.service"; import { v4 } 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 { @@ -21,97 +20,6 @@ import { 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; -} @Component({ selector: "configurable-action", @@ -146,6 +54,7 @@ export class ConfigurableActionComponent implements OnInit, OnChanges { private snackBar: MatSnackBar, private store: Store, private router: Router, + private datePipe: DatePipe, ) { this.usersService.usersControllerGetUserJWTV3().subscribe((jwt) => { this.jwt = jwt.jwt; @@ -216,6 +125,156 @@ export class ConfigurableActionComponent implements OnInit, OnChanges { } } + private processSelector( + jsonObject: ActionItems, + selector: string, + ): string | string[] | number | number[] { + if (!jsonObject.datasets?.length) { + console.warn("No datasets available"); + return undefined; + } + // 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) => { + if (jsonObject.datasets?.[Number(m[1])]) { + return 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]]), + "#Instruments\\[(\\d+)\\]Field\\[(\\w+)\\]": (m) => { + if (jsonObject.instruments?.[Number(m[1])]) { + return jsonObject.instruments?.[Number(m[1])][m[2]]; + } + }, + "#date_format\\(([^,]+),\\s*([^)]+)\\)": (m) => { + const dateInput = m[1].trim(); + const format = m[2].trim(); + const date = new Date(dateInput); + + console.log(`Date: ${date} Format: ${format}`); + if (isNaN(date.getTime())) { + console.warn("Invalid date:", dateInput); + return ""; + } + return this.datePipe.transform(date, format); + }, + }; + // 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; + } + + buildDependenciesGraph(variables: Record) { + const graph: Record> = {}; + Object.entries(variables).forEach(([key, value]) => { + const deps: string[] = []; + if (typeof value === "string") { + const matches = value.matchAll(/@(\w+)/g); + for (const match of matches) { + deps.push(match[1]); + } + } + graph[key] = new Set(deps); + }); + return graph; + } + + update_status() { + const depsGraph = this.buildDependenciesGraph(this.actionConfig.variables); + const visited: Set = new Set(); + const resolveVariable = (varKey: string): any => { + const deps = depsGraph[varKey] ?? new Set(); + for (const dep of deps ?? []) { + if (!(dep in this.variables)) { + if (visited.has(dep)) { + console.error(`Cyclic dependency detected in variable ${dep}`); + continue; + } + visited.add(dep); + this.variables[dep] = resolveVariable(dep); + } + } + const varDef = this.actionConfig.variables?.[varKey]; + const resolved = varDef.replace( + /@(\w+)(\[(\d+)\])?/g, + (_, name, _fullIndex, index) => { + let value = this.variables[name]; + if (value === undefined) return undefined; + if (index !== undefined) { + value = value?.[Number(index)]; + } + return value; + }, + ); + return this.processSelector(this.actionItems, resolved); + }; + + for (const key of Object.keys(this.actionConfig.variables ?? {})) { + this.variables[key] = resolveVariable(key); + } + } + ngOnInit() { this.subscriptions.push( this.userProfile$.subscribe((userProfile) => { @@ -251,14 +310,6 @@ export class ConfigurableActionComponent implements OnInit, OnChanges { } } - update_status() { - Object.entries(this.actionConfig.variables ?? {}).forEach( - ([key, selector]) => { - this.variables[key] = processSelector(this.actionItems, selector); - }, - ); - } - get context() { return { variables: this.variables, @@ -351,6 +402,40 @@ export class ConfigurableActionComponent implements OnInit, OnChanges { return headers; } + prepare_url() { + if (this.actionConfig.url) { + return ( + this.actionConfig.url + // eslint-disable-next-line no-useless-escape + .replace( + /\{\{\s*([@#]\w+(?:\[\d+\])?)\s*\}\}/g, + (_, variableName) => { + // Handle array indexing like @files[0] + const arrayMatch = variableName.match(/^([@#]\w+)\[(\d+)\]$/); + if (arrayMatch) { + const baseVariable = arrayMatch[1]; + const index = parseInt(arrayMatch[2], 10); + const value = this.get_value_from_definition(baseVariable); + + if (Array.isArray(value) && index < value.length) { + return value[index]; + } + console.error( + `Could not resolve array ${variableName} at index ${index}`, + ); + return ""; + } + // Handle normal variables + return this.get_value_from_definition(variableName); + }, + ) + ); + } else { + console.error("No URL provided for configurable action"); + return ""; + } + } + type_form() { if (this.form !== null) { document.body.removeChild(this.form); @@ -520,6 +605,11 @@ export class ConfigurableActionComponent implements OnInit, OnChanges { } type_link() { - window.open(this.actionConfig.url, this.actionConfig.target || "_self"); + const url = this.prepare_url(); + window.open(url, this.actionConfig.target || "_self"); + } + + ngOnDestroy() { + this.subscriptions.forEach((s) => s.unsubscribe()); } } 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..b44904c2a3 100644 --- a/src/app/shared/modules/configurable-actions/configurable-action.interfaces.ts +++ b/src/app/shared/modules/configurable-actions/configurable-action.interfaces.ts @@ -1,4 +1,5 @@ import { DataFiles_File } from "datasets/datafiles/datafiles.interfaces"; +import { Instrument } from "@scicatproject/scicat-sdk-ts-angular"; export interface ActionConfig { id: string; @@ -39,4 +40,5 @@ export interface ActionItemDataset { export interface ActionItems { datasets: ActionItemDataset[]; + instruments?: Instrument[]; } 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..a52bb0c647 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 @@ -189,7 +189,7 @@ export const mockActionsConfig: ActionConfig[] = [ enabled: "(#datasetOwner || #userIsAdmin) && !@isPublished", authorization: ["#datasetOwner && !@isPublished"], variables: { - pid: "@Dataset0Pid", + pid: "#Dataset0Pid", isPublished: "#Dataset[0]Field[isPublished]", }, payload: '{"isPublished":"true"}',