diff --git a/.vscode/launch.json b/.vscode/launch.json index 16f234ab58..9b7eb145b2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,11 +2,13 @@ "version": "0.2.0", "configurations": [ { - "name": "ng serve", + "name": "Debug SciCat frontend in Chrome", "type": "chrome", "request": "launch", - "url": "http://localhost:4200/#", - "webRoot": "${workspaceFolder}" + "url": "http://localhost:4200", + "webRoot": "${workspaceFolder}", + "sourceMaps": true, + "trace": true }, { "name": "ng test", diff --git a/CI/e2e/frontend.config.e2e.json b/CI/e2e/frontend.config.e2e.json index 245232597b..920d42129d 100644 --- a/CI/e2e/frontend.config.e2e.json +++ b/CI/e2e/frontend.config.e2e.json @@ -1,6 +1,7 @@ { "accessTokenPrefix": "Bearer ", "addDatasetEnabled": false, + "allowConfigOverrides": true, "archiveWorkflowEnabled": false, "datasetReduceEnabled": true, "datasetJsonScientificMetadata": true, @@ -10,8 +11,10 @@ "editPublishedData": true, "addSampleEnabled": false, "externalAuthEndpoint": "/api/v3/auth/msad", - "facility": "SciCat Vanilla", + "facility": "SciCat Vanilla CI Test", "siteIcon": "site-header-logo.png", + "sitetitle": "", + "siteSciCatLogo": "", "loginFacilityLabel": "SciCat Vanilla", "loginLdapLabel": "Ldap", "loginLocalLabel": "Local", @@ -24,12 +27,20 @@ "ingestManual": null, "jobsEnabled": true, "jsonMetadataEnabled": true, - "jupyterHubUrl": "", "landingPage": "doi.ess.eu/detail/", "lbBaseURL": "http://localhost:3000", + "ingestorComponent": { + "ingestorEnabled": false, + "ingestorAutodiscoveryOptions": [ + { + "mailDomain": "university.ch", + "description": "University/facility of Choice", + "facilityBackend": "http://localhost:8888" + } + ] + }, "logbookEnabled": true, "loginFormEnabled": true, - "maxDirectDownloadSize": 1047521824, "thumbnailFetchLimitPerPage": 100, "metadataPreviewEnabled": true, "metadataStructure": "", @@ -38,7 +49,7 @@ "oAuth2Endpoints": [ { "authURL": "api/v3/auth/oidc", - "displayText": "ESS One Identity" + "displayText": "CI Test Identity" } ], "policiesEnabled": true, @@ -50,6 +61,9 @@ "searchSamples": true, "sftpHost": "", "shareEnabled": true, + "sourceFolder": "/data/scicat/vanilla", + "maxDirectDownloadSize": 5000000000, + "maxFileSizeWarning": "Some files are above and cannot be downloaded directly. These file can be downloaded via sftp host: in directory: ", "shoppingCartEnabled": true, "shoppingCartOnHeader": true, "tableSciDataEnabled": true, @@ -60,52 +74,216 @@ "datafilesActions": [ { "id": "eed8efec-4354-11ef-a3b5-d75573a5d37f", - "order": 4, + "description": "This action let users download all files using the zip service", + "order": 1, "label": "Download All", "files": "all", "mat_icon": "download", "type": "form", "url": "http://localhost:4200/download/all", "target": "_blank", - "enabled": "#SizeLimit", + "variables" : { + "pid": "#Dataset0Pid", + "files": "#Dataset0FilesPath", + "totalSize": "#Dataset0FilesTotalSize", + "folder": "#Dataset0SourceFolder" + }, + "enabled": "#MaxDownloadableSize(@totalSize)", + "inputs" : { + "item[]" : "@pid", + "directory[]" : "@folder", + "files[]": "@files" + }, "authorization": ["#datasetAccess", "#datasetPublic"] }, { "id": "3072fafc-4363-11ef-b9f9-ebf568222d26", - "order": 3, + "description": "This action let users download selected files using the zip service", + "order": 2, "label": "Download Selected", "files": "selected", "mat_icon": "download", "type": "form", "url": "http://localhost:4200/download/selected", "target": "_blank", - "enabled": "#Selected && #SizeLimit", + "variables" : { + "pid": "#Dataset0Pid", + "files": "#Dataset0SelectedFilesPath", + "selected": "#Dataset0SelectedFilesCount", + "totalSize": "#Dataset0SelectedFilesTotalSize", + "folder": "#Dataset0SourceFolder" + }, + "inputs" : { + "auth_token" : "#tokenBearer", + "jwt" : "#jwt", + "item[]" : "@pid", + "directory[]" : "@folder", + "files[]": "@files" + }, + "enabled": "#Length(@files) && #MaxDownloadableSize(@totalSize)", "authorization": ["#datasetAccess", "#datasetPublic"] }, { "id": "4f974f0e-4364-11ef-9c63-03d19f813f4e", - "order": 2, - "label": "Notebook All", + "description": "This action let users download jupyter notebook properly populated with dataset pid and all files using an instance of sciwyrm", + "order": 3, + "label": "Notebook All (Form)", "files": "all", "icon": "/assets/icons/jupyter_logo.png", "type": "form", - "url": "http://localhost:4200/notebook/all", + "url": "http://localhost:4200/notebook/all/form", "target": "_blank", + "variables" : { + "pid": "#Dataset0Pid", + "files": "#Dataset0FilesPath", + "totalSize": "#Dataset0FilesTotalSize", + "folder": "#Dataset0SourceFolder" + }, + "enabled": "", + "inputs" : { + "auth_token" : "#token", + "jwt" : "#jwt", + "item[]" : "@pid", + "directory[]" : "@folder", + "files[]": "@files" + }, "authorization": ["#datasetAccess", "#datasetPublic"] }, { "id": "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", - "order": 1, - "label": "Notebook Selected", + "order": 4, + "label": "Notebook Selected (Form)", "files": "selected", "icon": "/assets/icons/jupyter_logo.png", "type": "form", - "url": "http://localhost:4200/notebook/selected", + "url": "http://localhost:4200/notebook/selected/form", "target": "_blank", - "enabled": "#Selected", + "variables" : { + "pid": "#Dataset0Pid", + "files": "#Dataset0SelectedFilesPath", + "selected": "#Dataset0SelectedFilesCount", + "totalSize": "#Dataset0SelectedFilesTotalSize", + "folder": "#Dataset0SourceFolder" + }, + "inputs" : { + "auth_token" : "#token", + "jwt" : "#jwt", + "item[]" : "@pid", + "directory[]" : "@folder", + "files[]": "@files" + }, + "enabled": "#Length(@files) > 0", "authorization": ["#datasetAccess", "#datasetPublic"] + }, + { + "id": "0cd5b592-0b1a-11f0-a42c-23e177127ee7", + "description": "This action let users download jupyter notebook properly populated with dataset pid and all files using an instance of sciwyrm", + "order": 5, + "label": "Notebook All (Download JSON)", + "files": "all", + "type": "json-download", + "icon": "/assets/icons/jupyter_logo.png", + "url": "http://localhost:4200/notebook/all/json", + "target": "_blank", + "authorization": ["#datasetAccess", "#datasetPublic"], + "variables" : { + "pid": "#Dataset0Pid", + "files": "#Dataset0FilesPath", + "folder": "#Dataset0SourceFolder" + }, + "payload": "{\"template_id\":\"c975455e-ede3-11ef-94fb-138c9cd51fc0\",\"parameters\":{\"dataset\":\"{{ @pid }}\",\"directory\":\"{{ @folder }}\",\"files\": {{ @files[] }},\"jwt\":\"{{ #jwt }}\",\"scicat_url\":\"https://staging.scicat.ess.url\",\"file_server_url\":\"sftserver2.esss.dk\",\"file_server_port\":\"22\"}}", + "filename": "{{ #uuid }}.ipynb" + }, + { + "id": "a414773a-a526-11f0-a7f2-ff1026e5dba9", + "description": "This action let users download jupyter notebook properly populated with dataset pid and selected files using an instance of sciwyrm", + "order": 6, + "label": "Notebook Selected (Download JSON)", + "type": "json-to-download", + "icon": "/assets/icons/jupyter_logo.png", + "url": "http://localhost:4200/notebook/selected/json", + "target": "_blank", + "enabled": "#Length(@files) > 0", + "authorization": ["#datasetAccess", "#datasetPublic"], + "variables" : { + "pid": "#Dataset0Pid", + "files": "#Dataset0SelectedFilesPath", + "selected": "#Dataset0SelectedFilesCount", + "folder": "#Dataset0SourceFolder" + }, + "payload": "{\"template_id\":\"c975455e-ede3-11ef-94fb-138c9cd51fc0\",\"parameters\":{\"dataset\":\"{{ @pid }}\",\"directory\":\"{{ @folder }}\",\"files\": {{ @files[] }},\"jwt\":\"{{ #jwt }}\",\"scicat_url\":\"https://staging.scicat.ess.url\",\"file_server_url\":\"sftserver2.esss.dk\",\"file_server_port\":\"22\"}}", + "filename": "{{ #uuid }}.ipynb" + }, + { + "id": "9c6a11b6-a526-11f0-8795-6f025b320cc3", + "description": "This action let users make a call an arbitrary URL and store the reply in the store", + "order": 7, + "label": "Publish", + "type": "xhr", + "mat_icon": "action", + "method" : "PATCH", + "url": "http://localhost:4200/dataset/{{ @pid }}/", + "target": "_blank", + "enabled": "(#datasetOwner || #userIsAdmin) && !@isPublished", + "authorization": "#datasetOwner && !@isPublished", + "variables" : { + "pid": "@Dataset0Pid", + "isPublished" : "#Dataset[0]Field[isPublished]" + }, + "payload": "{\"isPublished\":\"true\"}", + "headers": { + "Content-Type": "application/json", + "Authorization": "#tokenBearer" + } + }, + { + "id": "94a1d694-a526-11f0-947b-038d53cd837a", + "description": "This action let users make a call an arbitrary URL and store the reply in the store", + "order": 8, + "label": "Unpublish", + "type": "xhr", + "mat_icon": "action", + "method" : "PATCH", + "url": "http://localhost:4200/dataset/{{ @pid }}/", + "target": "_blank", + "enabled": "(#datasetOwner || #userIsAdmin) && @isPublished", + "authorization": "#datasetOwner && @isPublished", + "variables" : { + "pid": "#Dataset0Pid", + "isPublished" : "#Dataset[0]Field[isPublished]" + }, + "payload": "{\"isPublished\":\"false\"}", + "headers": { + "Content-Type": "application/json", + "Authorization": "#tokenBearer" + } + }, + { + "id": "c3bcbd40-a526-11f0-915a-93eeff0860ab", + "description": "This action let users jump to another URL entirely", + "order": 9, + "label": "ESS", + "type": "link", + "icon": "/assets/icons/button_ess.png", + "url": "http://localhost:4200/external", + "target": "_blank" } ], + "datasetDetailsActionsEnabled": false, + "datasetDetailsActions": [], + "selectionActionsEnabled": true, + "selectionActions": [], + "labelMaps": { + "filters": { + "LocationFilter": "Location", + "PidFilter": "Pid", + "GroupFilter": "Group", + "TypeFilter": "Type", + "KeywordFilter": "Keyword", + "DateRangeFilter": "Start Date - End Date", + "TextFilter": "Text" + } + }, "defaultDatasetsListSettings": { "columns": [ { diff --git a/angular.json b/angular.json index faf13bc3a5..181b25ab0e 100644 --- a/angular.json +++ b/angular.json @@ -56,7 +56,7 @@ { "type": "initial", "maximumWarning": "500kb", - "maximumError": "4mb" + "maximumError": "4.5mb" }, { "type": "anyComponentStyle", diff --git a/cypress/e2e/datasets/datasets-datafiles.cy.js b/cypress/e2e/datasets/datasets-datafiles.cy.js index c81cbaaaff..fae038386f 100644 --- a/cypress/e2e/datasets/datasets-datafiles.cy.js +++ b/cypress/e2e/datasets/datasets-datafiles.cy.js @@ -21,10 +21,18 @@ describe("Dataset datafiles", () => { const actionUrl = { downloadSelected: "http://localhost:4200/download/selected", downloadAll: "http://localhost:4200/download/all", - notebookSelected: "http://localhost:4200/notebook/selected", - notebookAll: "http://localhost:4200/notebook/all", + notebookFormSelected: "http://localhost:4200/notebook/selected/form", + notebookFormAll: "http://localhost:4200/notebook/all/form", + notebookJsonSelected: "/notebook/selected/json", + notebookJsonAll: "/notebook/all/json", }; - it("Should be able to download/notebook with selected/all", () => { + it("Should be able to download or notebook (form) with selected or all files", () => { + // Intercept the expected network request + // cy.intercept('POST', 'https://zip.scicatproject.org/download/all').as('DownloadFormAll'); + // cy.intercept('POST', 'https://zip.scicatproject.org/download/selected').as('DownloadFormSelected'); + // cy.intercept('POST', 'https://zip.scicatproject.org/notebook/all').as('DownloadNotebookAll'); + // cy.intercept('POST', 'https://zip.scicatproject.org/notebook/selected').as('DownloadNotebookSelected'); + cy.createDataset({ type: "raw", dataFileSize: "small" }); cy.visit("/datasets"); @@ -63,19 +71,87 @@ describe("Dataset datafiles", () => { cy.get("form").eq(1).should("have.attr", "action", actionUrl.downloadAll); // Test notebook selected - cy.get('button:contains("Notebook Selected")').click(); + cy.get('button:contains("Notebook Selected (Form)")').click(); cy.get("@formSubmit").should("have.been.called", 3); cy.get("form") .eq(2) - .should("have.attr", "action", actionUrl.notebookSelected); + .should("have.attr", "action", actionUrl.notebookFormSelected); // Test notebook all - cy.get('button:contains("Notebook All")').click(); + cy.get('button:contains("Notebook All (Form)")').click(); cy.get("@formSubmit").should("have.been.called", 4); - cy.get("form").eq(3).should("have.attr", "action", actionUrl.notebookAll); + cy.get("form").eq(3).should("have.attr", "action", actionUrl.notebookFormAll); + }); + + it("Should be able to download the notebook from sciwyrm with selected or all files", () => { + // // Intercept the expected network request + // cy.intercept('POST', actionUrl.notebookJsonAll, { + // statusCode: 200, + // body: { name: "Notebook Json All" } + // }).as('DownloadNotebookAll'); + // cy.intercept('POST', actionUrl.notebookJsonSelected, { + // statusCode: 200, + // body: { name: "Notebook Json Select" } + // }).as('DownloadNotebookSelected'); + + cy.window().then((win) => { + cy.stub(win.document, 'createElement').callsFake((tag) => { + if (tag === 'a') { + // Return a spy-able anchor element + const a = document.createElement('a'); + cy.spy(a, 'click').as('aClick'); + return a; + } + return document.createElement(tag); + }); + cy.stub(win.URL, 'createObjectURL').callsFake(() => 'blob:fake-url'); + }); + + cy.createDataset({ type: "raw", dataFileSize: "small" }); + + cy.visit("/datasets"); + + cy.get(".dataset-table mat-table mat-header-row").should("exist"); + + cy.finishedLoading(); + + cy.get('[data-cy="text-search"]').clear().type("Cypress"); + cy.get('[data-cy="search-button"]').click(); + + cy.isLoading(); + + cy.get("mat-row").contains("Cypress Dataset").first().click(); + + cy.wait("@fetch"); + + cy.get(".mat-mdc-tab-link").contains("Datafiles").click(); + + cy.get(".mdc-checkbox__native-control").eq(1).check(); + + + //cy.intercept('POST', '/your/download/url').as('downloadRequest'); + + // Test notebook selected + cy.get('button:contains("Notebook Selected (Download JSON)")').click(); + // Wait for the intercepted call and assert the response + // cy.wait('@DownloadNotebookSelected').then((interception) => { + // expect(interception.request.headers['Content-Type']).to.eq('application/json'); + // expect(interception.request.body.template_id).to.eq("c975455e-ede3-11ef-94fb-138c9cd51fc0"); + // }); + // Assert anchor was created and clicked + cy.get('@aClick').should('have.been.called'); + + // Test notebook all + cy.get('button:contains("Notebook All (Download JSON)")').click(); + // cy.wait('@DownloadNotebookAll').then((interception) => { + // expect(interception.request.headers['Content-Type']).to.eq('application/json'); + // expect(interception.request.body.template_id).to.eq("c975455e-ede3-11ef-94fb-138c9cd51fc0"); + // }); + // Assert anchor was created and clicked + cy.get('@aClick').should('have.been.called'); }); - it("Should not be able to download selected/all file that is exceeding size limit", () => { + it("Should not be able to download selected/all files that is exceeding size limit", () => { cy.createDataset({ type: "raw", dataFileSize: "large" }); cy.visit("/datasets"); @@ -103,8 +179,10 @@ describe("Dataset datafiles", () => { cy.get('button:contains("Download Selected")').should("be.disabled"); cy.get('button:contains("Download All")').should("be.disabled"); - cy.get('button:contains("Notebook Selected")').should("not.be.disabled"); - cy.get('button:contains("Notebook Selected")').should("not.be.disabled"); + cy.get('button:contains("Notebook Selected (Form)")').should("not.be.disabled"); + cy.get('button:contains("Notebook All (Form)")').should("not.be.disabled"); + cy.get('button:contains("Notebook Selected (Download JSON)")').should("not.be.disabled"); + cy.get('button:contains("Notebook All (Download JSON)")').should("not.be.disabled"); cy.get(".mdc-checkbox__native-control").eq(1).uncheck(); cy.get(".mdc-checkbox__native-control").eq(2).check(); diff --git a/src/app/app-config.service.spec.ts b/src/app/app-config.service.spec.ts index fb08f1d8ad..e74b77dcfd 100644 --- a/src/app/app-config.service.spec.ts +++ b/src/app/app-config.service.spec.ts @@ -77,52 +77,14 @@ const appConfig: AppConfigInterface = { datasetDetailsShowMissingProposalId: true, helpMessages: new HelpMessages(), notificationInterceptorEnabled: true, - datafilesActionsEnabled: true, - datafilesActions: [ - { - id: "eed8efec-4354-11ef-a3b5-d75573a5d37f", - order: 4, - label: "Download All", - files: "all", - mat_icon: "download", - url: "", - target: "_blank", - enabled: "#SizeLimit", - authorization: ["#datasetAccess", "#datasetPublic"], - }, - { - id: "3072fafc-4363-11ef-b9f9-ebf568222d26", - order: 3, - label: "Download Selected", - files: "selected", - mat_icon: "download", - url: "", - target: "_blank", - enabled: "#Selected && #SizeLimit", - authorization: ["#datasetAccess", "#datasetPublic"], - }, - { - id: "4f974f0e-4364-11ef-9c63-03d19f813f4e", - order: 2, - label: "Notebook All", - files: "all", - icon: "/assets/icons/jupyter_logo.png", - url: "", - target: "_blank", - authorization: ["#datasetAccess", "#datasetPublic"], - }, - { - id: "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", - order: 1, - label: "Notebook Selected", - files: "selected", - icon: "/assets/icons/jupyter_logo.png", - url: "", - target: "_blank", - enabled: "#Selected", - authorization: ["#datasetAccess", "#datasetPublic"], - }, - ], + datasetActionsEnabled: false, + datasetActions: [], + datasetDetailsActionsEnabled: false, + datasetDetailsActions: [], + datafilesActionsEnabled: false, + datafilesActions: [], + datasetSelectionActionsEnabled: false, + datasetSelectionActions: [], defaultDatasetsListSettings: { columns: [ { diff --git a/src/app/app-config.service.ts b/src/app/app-config.service.ts index 65ca265659..34645eba49 100644 --- a/src/app/app-config.service.ts +++ b/src/app/app-config.service.ts @@ -75,8 +75,14 @@ export interface AppConfigInterface { datasetJsonScientificMetadata: boolean; datasetReduceEnabled: boolean; datasetDetailsShowMissingProposalId: boolean; + datasetActionsEnabled: boolean; + datasetActions: any[]; datafilesActionsEnabled: boolean; datafilesActions: any[]; + datasetDetailsActionsEnabled: boolean; + datasetDetailsActions: any[]; + datasetSelectionActionsEnabled: boolean; + datasetSelectionActions: any[]; editDatasetEnabled: boolean; editDatasetSampleEnabled: boolean; editMetadataEnabled: boolean; diff --git a/src/app/app-routing/redirecting.component.ts b/src/app/app-routing/redirecting.component.ts index 936d85b116..ba47f21e37 100644 --- a/src/app/app-routing/redirecting.component.ts +++ b/src/app/app-routing/redirecting.component.ts @@ -42,5 +42,6 @@ import { Component } from "@angular/core"; } `, ], + standalone: false, }) export class RedirectingComponent {} diff --git a/src/app/datasets/batch-view/batch-view.component.spec.ts b/src/app/datasets/batch-view/batch-view.component.spec.ts index 92c88544ee..77cd2cd67b 100644 --- a/src/app/datasets/batch-view/batch-view.component.spec.ts +++ b/src/app/datasets/batch-view/batch-view.component.spec.ts @@ -4,8 +4,9 @@ import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; import { BatchViewComponent } from "./batch-view.component"; import { NO_ERRORS_SCHEMA } from "@angular/core"; -import { Router } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { + MockActivatedRoute, MockArchivingService, MockDatasetApi, mockDataset as dataset, @@ -70,6 +71,7 @@ describe("BatchViewComponent", () => { { provide: Router, useValue: router }, { provide: DatasetsService, useClass: MockDatasetApi }, { provide: AppConfigService, useValue: { getConfig } }, + { provide: ActivatedRoute, useClass: MockActivatedRoute }, ], }, }); diff --git a/src/app/datasets/batch-view/batch-view.component.ts b/src/app/datasets/batch-view/batch-view.component.ts index 97a3836d32..fa6936ba45 100644 --- a/src/app/datasets/batch-view/batch-view.component.ts +++ b/src/app/datasets/batch-view/batch-view.component.ts @@ -14,7 +14,7 @@ import { Message, MessageType } from "state-management/models"; import { showMessageAction } from "state-management/actions/user.actions"; import { DialogComponent } from "shared/modules/dialog/dialog.component"; -import { Router } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { ArchivingService } from "../archiving.service"; import { Observable, Subscription, combineLatest } from "rxjs"; import { MatDialog } from "@angular/material/dialog"; @@ -39,6 +39,7 @@ export class BatchViewComponent implements OnInit, OnDestroy { ); userProfile$ = this.store.select(selectProfile); isAdmin$ = this.store.select(selectIsAdmin); + params$ = this.route.queryParams; isAdmin = false; userProfile: any = {}; subscriptions: Subscription[] = []; @@ -57,6 +58,7 @@ export class BatchViewComponent implements OnInit, OnDestroy { private store: Store, private archivingSrv: ArchivingService, private router: Router, + private route: ActivatedRoute, ) {} private clearBatch() { @@ -83,6 +85,13 @@ export class BatchViewComponent implements OnInit, OnDestroy { this.router.navigate(["datasets", "selection", "publish"]); } + onShareClick() { + this.router.navigate([], { + queryParams: { share: "true" }, + queryParamsHandling: "merge", + }); + } + onShare() { const shouldHaveInfoMessage = !this.isAdmin && @@ -157,6 +166,11 @@ export class BatchViewComponent implements OnInit, OnDestroy { ); this.store.dispatch(showMessageAction({ message })); } + + this.router.navigate([], { + queryParams: { share: null }, + queryParamsHandling: "merge", + }); }); } @@ -208,6 +222,11 @@ export class BatchViewComponent implements OnInit, OnDestroy { ), ); } + + this.router.navigate([], { + queryParams: { retrieve: null }, + queryParamsHandling: "merge", + }); }); } @@ -267,6 +286,17 @@ export class BatchViewComponent implements OnInit, OnDestroy { } }), ); + + this.subscriptions.push( + combineLatest([this.params$]).subscribe(([queryParams]) => { + if (queryParams["share"] === "true") { + this.onShare(); + } + if (queryParams["retrieve"] === "true") { + this.onRetrieve(); + } + }), + ); } ngOnDestroy() { diff --git a/src/app/datasets/datafiles-actions/datafiles-action.component.spec.ts b/src/app/datasets/datafiles-actions/datafiles-action.component.spec.ts deleted file mode 100644 index 8de1a48c1c..0000000000 --- a/src/app/datasets/datafiles-actions/datafiles-action.component.spec.ts +++ /dev/null @@ -1,842 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; - -import { DatafilesActionComponent } from "./datafiles-action.component"; -import { NO_ERRORS_SCHEMA } from "@angular/core"; -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 { ReactiveFormsModule } from "@angular/forms"; -import { MatDialogModule, MatDialogRef } from "@angular/material/dialog"; -import { RouterModule } from "@angular/router"; -import { StoreModule } from "@ngrx/store"; -import { - MockHtmlElement, - MockMatDialogRef, - MockUserApi, -} from "shared/MockStubs"; -import { ActionDataset } from "./datafiles-action.interfaces"; -import { UsersService } from "@scicatproject/scicat-sdk-ts-angular"; -import { AuthService } from "shared/services/auth/auth.service"; -import { MatSnackBarModule } from "@angular/material/snack-bar"; - -describe("1000: DatafilesActionComponent", () => { - let component: DatafilesActionComponent; - let fixture: ComponentFixture; - let htmlForm: HTMLFormElement; - let htmlInput: HTMLInputElement; - - const actionsConfig = [ - { - id: "eed8efec-4354-11ef-a3b5-d75573a5d37f", - order: 0, - label: "Download All", - files: "all", - mat_icon: "download", - url: "https://download.scicat.org", - target: "_blank", - enabled: "#SizeLimit", - authorization: ["#datasetAccess", "#datasetPublic"], - }, - { - id: "3072fafc-4363-11ef-b9f9-ebf568222d26", - order: 1, - label: "Download Selected", - files: "selected", - mat_icon: "download", - url: "https://download.scicat.org", - target: "_blank", - enabled: "#Selected && #SizeLimit", - authorization: ["#datasetAccess", "#datasetPublic"], - }, - { - id: "4f974f0e-4364-11ef-9c63-03d19f813f4e", - order: 2, - label: "Notebook All", - files: "all", - icon: "/assets/icons/jupyter_logo.png", - url: "https://notebook.scicat.org", - target: "_blank", - authorization: ["#datasetAccess", "#datasetPublic"], - }, - { - id: "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", - order: 3, - label: "Notebook Selected", - files: "selected", - icon: "/assets/icons/jupyter_logo.png", - url: "https://notebook.scicat.org", - target: "_blank", - enabled: "#Selected", - authorization: ["#datasetAccess", "#datasetPublic"], - }, - { - id: "fa3ce6ee-482d-11ef-95e9-ff2c80ddnews", - order: 4, - label: "Notebook Selected", - files: "selected", - type: "json-download", - icon: "/assets/icons/jupyter_logo.png", - url: "https://notebook.scicat.org", - target: "_blank", - enabled: "#Selected", - authorization: ["#datasetAccess", "#datasetPublic"], - filename: "{{ uuid }}.ipynb", - }, - { - id: "test-test-test-test-1", - order: 5, - label: "Notebook Selected", - files: "selected", - type: "json-download", - icon: "/assets/icons/jupyter_logo.png", - url: "https://notebook.scicat.org", - target: "_blank", - enabled: "#Selected", - authorization: ["#datasetAccess", "#datasetPublic"], - payload: "", - filename: "{{ uuid }}.ipynb", - }, - { - id: "test-test-test-test-2", - order: 6, - label: "Notebook Selected", - files: "selected", - type: "json-download", - icon: "/assets/icons/jupyter_logo.png", - url: "https://notebook.scicat.org", - target: "_blank", - enabled: "#Selected", - authorization: ["#datasetAccess", "#datasetPublic"], - filename: "{{ uuid }}.ipynb", - }, - ]; - - const actionDataset: ActionDataset = { - pid: "1c7298da-4a7c-11ef-a2ce-2fdb7a34e7eb", - sourceFolder: "/folder_1/folder_2/folder_3", - }; - - const actionFiles = [ - { - path: "file1", - size: 5000, - time: "2019-09-06T13:11:37.102Z", - chk: "string", - uid: "string", - gid: "string", - perm: "string", - selected: false, - hash: "", - }, - { - path: "file2", - size: 10000, - time: "2019-09-06T13:11:37.102Z", - chk: "string", - uid: "string", - gid: "string", - perm: "string", - selected: false, - hash: "", - }, - ]; - - const lowerMaxFileSizeLimit = 9999; - const higherMaxFileSizeLimit = 20000; - enum maxSizeType { - lower = "lower", - higher = "higher", - } - - enum selectedFilesType { - none = "none", - file1 = "file1", - file2 = "file2", - all = "all", - } - - enum actionSelectorType { - download_all = 0, - download_selected = 1, - notebook_all = 2, - notebook_selected = 3, - json_download_with_payload = 4, - json_download_without_payload = 5, - } - - const usersControllerGetUserJWTV3 = () => ({ - subscribe: () => ({ - jwt: "9a2322a8-4a7d-11ef-a0f5-d7c40fcf1693", - }), - }); - - const getCurrentToken = () => ({ - id: "4ac45f3e-4d79-11ef-856c-6339dab93bee", - }); - - // const browserWindowMock = { - // document: { - // write() {}, - // body: { - // setAttribute() {}, - // }, - // }, - // } as unknown as Window; - - beforeAll(() => { - htmlForm = document.createElement("form"); - (htmlForm as HTMLFormElement).submit = () => {}; - htmlInput = document.createElement("input"); - }); - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - schemas: [NO_ERRORS_SCHEMA], - imports: [ - MatButtonModule, - MatIconModule, - MatTableModule, - PipesModule, - ReactiveFormsModule, - MatDialogModule, - MatSnackBarModule, - RouterModule, - RouterModule.forRoot([]), - StoreModule.forRoot({}), - ], - declarations: [DatafilesActionComponent], - }); - TestBed.overrideComponent(DatafilesActionComponent, { - set: { - providers: [ - { provide: UsersService, useClass: MockUserApi }, - { provide: MatDialogRef, useClass: MockMatDialogRef }, - { - provide: UsersService, - useValue: { usersControllerGetUserJWTV3 }, - }, - { - provide: AuthService, - useValue: { getToken: getCurrentToken }, - }, - ], - }, - }); - TestBed.compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(DatafilesActionComponent); - component = fixture.componentInstance; - component.files = structuredClone(actionFiles); - component.actionConfig = actionsConfig[0]; - component.actionDataset = actionDataset; - component.maxFileSize = lowerMaxFileSizeLimit; - fixture.detectChanges(); - }); - - afterEach(() => { - fixture.destroy(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); - - /* - * Unit tests for enabled/disabled cases performed - * ------------------------ - * Test # , Action , Max Size , Selected , Status - * ------------------------------------------------------------------------------- - * 0010 , Download All , low max file size , no selected files , disabled - * 0020 , Download All , low max file size , file 1 selected , disabled - * 0030 , Download All , low max file size , file 2 selected , disabled - * 0040 , Download All , low max file size , all files selected , disabled - * 0050 , Download All , high max file size , no selected files , enabled - * 0060 , Download All , high max file size , file 1 selected , enabled - * 0070 , Download All , high max file size , file 2 selected , enabled - * 0080 , Download All , high max file size , all files selected , enabled - * - * 0090 , Download Selected , low max file size , no selected files , disabled - * 0100 , Download Selected , low max file size , file 1 selected , enabled - * 0110 , Download Selected , low max file size , file 2 selected , disabled - * 0120 , Download Selected , low max file size , all files selected , disabled - * 0130 , Download Selected , high max file size , no selected files , disabled - * 0140 , Download Selected , high max file size , file 1 selected , enabled - * 0150 , Download Selected , high max file size , file 2 selected , enabled - * 0160 , Download Selected , high max file size , all files selected , enabled - * - * 0170 , Notebook All , low max file size , no selected files , enabled - * 0180 , Notebook All , low max file size , file 1 selected , enabled - * 0190 , Notebook All , low max file size , file 2 selected , enabled - * 0200 , Notebook All , low max file size , all files selected , enabled - * 0210 , Notebook All , high max file size , no selected files , enabled - * 0220 , Notebook All , high max file size , file 1 selected , enabled - * 0230 , Notebook All , high max file size , file 2 selected , enabled - * 0240 , Notebook All , high max file size , all files selected , enabled - * - * 0250 , Notebook Selected , low max file size , no selected files , disbaled - * 0260 , Notebook Selected , low max file size , file 1 selected , enabled - * 0270 , Notebook Selected , low max file size , file 2 selected , enabled - * 0280 , Notebook Selected , low max file size , all files selected , enabled - * 0290 , Notebook Selected , high max file size , no selected files , disabled - * 0300 , Notebook Selected , high max file size , file 1 selected , enabled - * 0310 , Notebook Selected , high max file size , file 2 selected , enabled - * 0320 , Notebook Selected , high max file size , all files selected , enabled - */ - - const testEnabledDisabledCases = [ - { - test: "0010: Download All should be disabled with lowest max size limit and no files selected", - action: actionSelectorType.download_all, - limit: maxSizeType.lower, - selection: selectedFilesType.none, - result: true, - }, - { - test: "0020: Download All should be disabled with lowest max size limit and file 1 selected", - action: actionSelectorType.download_all, - limit: maxSizeType.lower, - selection: selectedFilesType.file1, - result: true, - }, - { - test: "0030: Download All should be disabled with lowest max size limit and file 2 selected", - action: actionSelectorType.download_all, - limit: maxSizeType.lower, - selection: selectedFilesType.file2, - result: true, - }, - { - test: "0040: Download All should be disabled with lowest max size limit and all files selected", - action: actionSelectorType.download_all, - limit: maxSizeType.lower, - selection: selectedFilesType.all, - result: true, - }, - { - test: "0050: Download All should be enabled with highest max size limit and no files selected", - action: actionSelectorType.download_all, - limit: maxSizeType.higher, - selection: selectedFilesType.none, - result: false, - }, - { - test: "0060: Download All should be enabled with highest max size limit and file 1 selected", - action: actionSelectorType.download_all, - limit: maxSizeType.higher, - selection: selectedFilesType.file1, - result: false, - }, - { - test: "0070: Download All should be enabled with highest max size limit and file 2 selected", - action: actionSelectorType.download_all, - limit: maxSizeType.higher, - selection: selectedFilesType.file2, - result: false, - }, - { - test: "0080: Download All should be enabled with highest max size limit and all files selected", - action: actionSelectorType.download_all, - limit: maxSizeType.higher, - selection: selectedFilesType.all, - result: false, - }, - { - test: "0090: Download Selected should be disabled with lowest max size limit and no files selected", - action: actionSelectorType.download_selected, - limit: maxSizeType.lower, - selection: selectedFilesType.none, - result: true, - }, - { - test: "0100: Download Selected should be enabled with lowest max size limit and file 1 selected", - action: actionSelectorType.download_selected, - limit: maxSizeType.lower, - selection: selectedFilesType.file1, - result: false, - }, - { - test: "0110: Download Selected should be disabled with lowest max size limit and file 2 selected", - action: actionSelectorType.download_selected, - limit: maxSizeType.lower, - selection: selectedFilesType.file2, - result: true, - }, - { - test: "0120: Download Selected should be disabled with lowest max size limit and all files selected", - action: actionSelectorType.download_selected, - limit: maxSizeType.lower, - selection: selectedFilesType.all, - result: true, - }, - { - test: "0130: Download Selected should be disabled with highest max size limit and no files selected", - action: actionSelectorType.download_selected, - limit: maxSizeType.higher, - selection: selectedFilesType.none, - result: true, - }, - { - test: "0140: Download Selected should be enabled with highest max size limit and file 1 selected", - action: actionSelectorType.download_selected, - limit: maxSizeType.higher, - selection: selectedFilesType.file1, - result: false, - }, - { - test: "0150: Download Selected should be enabled with highest max size limit and file 2 selected", - action: actionSelectorType.download_selected, - limit: maxSizeType.higher, - selection: selectedFilesType.file2, - result: false, - }, - { - test: "0160: Download Selected should be enabled with highest max size limit and all files selected", - action: actionSelectorType.download_selected, - limit: maxSizeType.higher, - selection: selectedFilesType.all, - result: false, - }, - { - test: "0170: Notebook All should be enabled with lowest max size limit and no files selected", - action: actionSelectorType.notebook_all, - limit: maxSizeType.lower, - selection: selectedFilesType.none, - result: false, - }, - { - test: "0180: Notebook All should be enabled with lowest max size limit and file 1 selected", - action: actionSelectorType.notebook_all, - limit: maxSizeType.lower, - selection: selectedFilesType.file1, - result: false, - }, - { - test: "0190: Notebook All should be enabled with lowest max size limit and file 2 selected", - action: actionSelectorType.notebook_all, - limit: maxSizeType.lower, - selection: selectedFilesType.file2, - result: false, - }, - { - test: "0200: Notebook All should be enabled with lowest max size limit and all files selected", - action: actionSelectorType.notebook_all, - limit: maxSizeType.lower, - selection: selectedFilesType.all, - result: false, - }, - { - test: "0210: Notebook All should be enabled with highest max size limit and no files selected", - action: actionSelectorType.notebook_all, - limit: maxSizeType.higher, - selection: selectedFilesType.none, - result: false, - }, - { - test: "0220: Notebook All should be enabled with highest max size limit and file 1 selected", - action: actionSelectorType.notebook_all, - limit: maxSizeType.higher, - selection: selectedFilesType.file1, - result: false, - }, - { - test: "0230: Notebook All should be enabled with highest max size limit and file 2 selected", - action: actionSelectorType.notebook_all, - limit: maxSizeType.higher, - selection: selectedFilesType.file2, - result: false, - }, - { - test: "0240: Notebook All should be enabled with highest max size limit and all files selected", - action: actionSelectorType.notebook_all, - limit: maxSizeType.higher, - selection: selectedFilesType.all, - result: false, - }, - { - test: "0250: Notebook Selected should be disabled with lowest max size limit and no files selected", - action: actionSelectorType.notebook_selected, - limit: maxSizeType.lower, - selection: selectedFilesType.none, - result: true, - }, - { - test: "0260: Notebook Selected should be enabled with lowest max size limit and file 1 selected", - action: actionSelectorType.notebook_selected, - limit: maxSizeType.lower, - selection: selectedFilesType.file1, - result: false, - }, - { - test: "0270: Notebook Selected should be enabled with lowest max size limit and file 2 selected", - action: actionSelectorType.notebook_selected, - limit: maxSizeType.lower, - selection: selectedFilesType.file2, - result: false, - }, - { - test: "0280: Notebook Selected should be enabled with lowest max size limit and all files selected", - action: actionSelectorType.notebook_selected, - limit: maxSizeType.lower, - selection: selectedFilesType.all, - result: false, - }, - { - test: "0290: Notebook Selected should be disabled with highest max size limit and no files selected", - action: actionSelectorType.notebook_selected, - limit: maxSizeType.higher, - selection: selectedFilesType.none, - result: true, - }, - { - test: "0300: Notebook Selected should be enabled with highest max size limit and file 1 selected", - action: actionSelectorType.notebook_selected, - limit: maxSizeType.higher, - selection: selectedFilesType.file1, - result: false, - }, - { - test: "0310: Notebook Selected should be enabled with highest max size limit and file 2 selected", - action: actionSelectorType.notebook_selected, - limit: maxSizeType.higher, - selection: selectedFilesType.file2, - result: false, - }, - { - test: "0320: Notebook Selected should be enabled with highest max size limit and all files selected", - action: actionSelectorType.notebook_selected, - limit: maxSizeType.higher, - selection: selectedFilesType.all, - result: false, - }, - ]; - - function selectTestCase( - action: actionSelectorType, - maxSize: maxSizeType, - selectedFiles: selectedFilesType, - ) { - component.actionConfig = actionsConfig[action]; - switch (maxSize) { - case maxSizeType.higher: - component.maxFileSize = higherMaxFileSizeLimit; - break; - case maxSizeType.lower: - default: - component.maxFileSize = lowerMaxFileSizeLimit; - break; - } - component.files = structuredClone(actionFiles); - switch (selectedFiles) { - case selectedFilesType.file1: - component.files[0].selected = true; - //component.files[1].selected = false; - break; - case selectedFilesType.file2: - //component.files[0].selected = false; - component.files[1].selected = true; - break; - case selectedFilesType.all: - component.files[0].selected = true; - component.files[1].selected = true; - break; - } - fixture.detectChanges(); - } - - testEnabledDisabledCases.forEach((testCase) => { - it(testCase.test, () => { - selectTestCase(testCase.action, testCase.limit, testCase.selection); - - expect(component.disabled).toEqual(testCase.result); - }); - }); - - function createFakeElement(elementType: string): HTMLElement { - //const element = new MockHtmlElement(elementType); - //return element as unknown as HTMLElement; - let element: HTMLElement = null; - - switch (elementType) { - case "form": - element = htmlForm.cloneNode(true) as HTMLElement; - break; - case "input": - element = htmlInput.cloneNode(true) as HTMLElement; - break; - default: - element = null; - } - return element; - } - - it("0400: Form submission should have all files when Download All is clicked", async () => { - selectTestCase( - actionSelectorType.download_all, - maxSizeType.higher, - selectedFilesType.none, - ); - - spyOn(document, "createElement").and.callFake(createFakeElement); - //spyOn(window, "open").and.returnValue(browserWindowMock); - - component.perform_action(); - - const formChildren = Array.from(component.form.children).map( - (item) => item as unknown as MockHtmlElement, - ); - const formFiles = formChildren.filter((item) => - item.name.includes("files"), - ); - expect(formFiles.length).toEqual(2); - }); - - it("0410: Form submission should have correct url when Download All is clicked", async () => { - selectTestCase( - actionSelectorType.download_all, - maxSizeType.higher, - selectedFilesType.none, - ); - spyOn(document, "createElement").and.callFake(createFakeElement); - //spyOn(window, "open").and.returnValue(browserWindowMock); - - component.perform_action(); - - expect(component.form.action.replace(/\/$/, "")).toEqual( - actionsConfig[actionSelectorType.download_all].url.replace(/\/$/, ""), - ); - }); - - it("0420: Form submission should have correct dataset when Download All is clicked", async () => { - selectTestCase( - actionSelectorType.download_all, - maxSizeType.higher, - selectedFilesType.none, - ); - spyOn(document, "createElement").and.callFake(createFakeElement); - //spyOn(window, "open").and.returnValue(browserWindowMock); - - component.perform_action(); - - const formChildren = Array.from(component.form.children).map( - (item) => item as unknown as MockHtmlElement, - ); - const formDataset = formChildren.filter((item) => - item.name.includes("dataset"), - ); - expect(formDataset.length).toEqual(1); - const datasetPid = formDataset[0].value; - expect(datasetPid).toEqual(actionDataset.pid); - }); - - it("0430: Form submission should have correct file when Download Selected is clicked", async () => { - const selectedFile = selectedFilesType.file1; - selectTestCase( - actionSelectorType.download_selected, - maxSizeType.higher, - selectedFile, - ); - spyOn(document, "createElement").and.callFake(createFakeElement); - //spyOn(window, "open").and.returnValue(browserWindowMock); - - component.perform_action(); - - const formChildren = Array.from(component.form.children).map( - (item) => item as unknown as MockHtmlElement, - ); - const formFiles = formChildren.filter((item) => - item.name.includes("files"), - ); - expect(formFiles.length).toEqual(1); - const formFilePath = formFiles[0].value; - const selectedFilePath = actionFiles.filter( - (item) => item.path == selectedFile, - )[0].path; - expect(formFilePath).toEqual(selectedFilePath); - }); - - it("0440: Form submission should have all files when Notebook All is clicked", async () => { - selectTestCase( - actionSelectorType.notebook_all, - maxSizeType.higher, - selectedFilesType.none, - ); - spyOn(document, "createElement").and.callFake(createFakeElement); - //spyOn(window, "open").and.returnValue(browserWindowMock); - - component.perform_action(); - - const formChildren = Array.from(component.form.children).map( - (item) => item as unknown as MockHtmlElement, - ); - const formFiles = formChildren.filter((item) => - item.name.includes("files"), - ); - expect(formFiles.length).toEqual(2); - }); - - it("0450: Form submission should have correct url when Notebook All is clicked", async () => { - selectTestCase( - actionSelectorType.notebook_all, - maxSizeType.higher, - selectedFilesType.none, - ); - spyOn(document, "createElement").and.callFake(createFakeElement); - //spyOn(window, "open").and.returnValue(browserWindowMock); - - component.perform_action(); - - expect(component.form.action.replace(/\/$/, "")).toEqual( - actionsConfig[actionSelectorType.notebook_all].url.replace(/\/$/, ""), - ); - }); - - it("0460: Form submission should have correct file when Notebook Selected is clicked", async () => { - const selectedFile = selectedFilesType.file2; - selectTestCase( - actionSelectorType.notebook_selected, - maxSizeType.higher, - selectedFile, - ); - spyOn(document, "createElement").and.callFake(createFakeElement); - //spyOn(window, "open").and.returnValue(browserWindowMock); - - component.perform_action(); - - const formChildren = Array.from(component.form.children).map( - (item) => item as unknown as MockHtmlElement, - ); - const formFiles = formChildren.filter((item) => - item.name.includes("files"), - ); - expect(formFiles.length).toEqual(1); - const formFilePath = formFiles[0].value; - const selectedFilePath = actionFiles.filter( - (item) => item.path == selectedFile, - )[0].path; - expect(formFilePath).toEqual(selectedFilePath); - }); - - it("0500: Download All action button should contain the correct label", () => { - selectTestCase( - actionSelectorType.download_all, - maxSizeType.higher, - selectedFilesType.none, - ); - - const componentElement: HTMLElement = fixture.nativeElement; - const actionButton = componentElement.querySelector(".action-button"); - expect(actionButton.innerHTML).toContain( - actionsConfig[actionSelectorType.download_all].label, - ); - }); - - it("0510: Download Selected action button should contain the correct label", () => { - selectTestCase( - actionSelectorType.download_selected, - maxSizeType.higher, - selectedFilesType.none, - ); - - const componentElement: HTMLElement = fixture.nativeElement; - const actionButton = componentElement.querySelector(".action-button"); - expect(actionButton.innerHTML).toContain( - actionsConfig[actionSelectorType.download_selected].label, - ); - }); - - it("0520: Notebook All action button should contain the correct label", () => { - selectTestCase( - actionSelectorType.notebook_all, - maxSizeType.higher, - selectedFilesType.none, - ); - - const componentElement: HTMLElement = fixture.nativeElement; - const actionButton = componentElement.querySelector(".action-button"); - expect(actionButton.innerHTML).toContain( - actionsConfig[actionSelectorType.notebook_all].label, - ); - }); - - it("0530: Notebook Selected action button should contain the correct label", () => { - selectTestCase( - actionSelectorType.notebook_selected, - maxSizeType.higher, - selectedFilesType.none, - ); - - const componentElement: HTMLElement = fixture.nativeElement; - const actionButton = componentElement.querySelector(".action-button"); - expect(actionButton.innerHTML).toContain( - actionsConfig[actionSelectorType.notebook_selected].label, - ); - }); - - it("0600: JSON-download action should fetch with customized payload fields when provided", async () => { - selectTestCase( - actionSelectorType.json_download_with_payload, - maxSizeType.higher, - selectedFilesType.none, - ); - actionsConfig[actionSelectorType.json_download_with_payload].payload = - '{"test_id":"test-id","parameters":{"testField1":"{{ datasetPid }}","testField2":"{{ sourceFolder }}","files": {{ filesPath }},"jwt":"{{ jwt }}","scicat_url":"https://staging.scicat.ess.url","file_server_url":"sftserver2.esss.dk","file_server_port":"22"}}'; - component.jwt = "TEST_JWT"; - spyOn(window, "fetch").and.returnValue( - Promise.resolve( - new Response(new Blob(), { - status: 200, - statusText: "OK", - headers: { "Content-Type": "application/json" }, - }), - ), - ); - component.perform_action(); - - const [url, opts] = (window.fetch as jasmine.Spy).calls.mostRecent().args; - expect(url).toBe( - actionsConfig[actionSelectorType.json_download_with_payload].url, - ); - expect(opts.method).toBe("POST"); - expect(opts.headers["Content-Type"]).toBe("application/json"); - - const body = JSON.parse(opts.body); - expect(body.test_id).toBe("test-id"); - expect(body.parameters.jwt).toBe("TEST_JWT"); - expect(body.parameters.testField1).toBe(actionDataset.pid); - expect(body.parameters.testField2).toBe(actionDataset.sourceFolder); - }); - - it("0610: JSON-download action should fetch with default fields if payload is not provided", async () => { - selectTestCase( - actionSelectorType.json_download_without_payload, - maxSizeType.higher, - selectedFilesType.none, - ); - component.jwt = "TEST_JWT2"; - spyOn(window, "fetch").and.returnValue( - Promise.resolve( - new Response(new Blob(), { - status: 200, - statusText: "OK", - headers: { "Content-Type": "application/json" }, - }), - ), - ); - component.perform_action(); - - const [url, opts] = (window.fetch as jasmine.Spy).calls.mostRecent().args; - expect(url).toBe( - actionsConfig[actionSelectorType.json_download_without_payload].url, - ); - expect(opts.method).toBe("POST"); - expect(opts.headers["Content-Type"]).toBe("application/json"); - - const body = JSON.parse(opts.body); - expect(body.jwt).toBe("TEST_JWT2"); - expect(body.dataset).toBe(actionDataset.pid); - expect(body.directory).toBe(actionDataset.sourceFolder); - }); -}); diff --git a/src/app/datasets/datafiles-actions/datafiles-action.component.ts b/src/app/datasets/datafiles-actions/datafiles-action.component.ts deleted file mode 100644 index e710dc544b..0000000000 --- a/src/app/datasets/datafiles-actions/datafiles-action.component.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { - Component, - Input, - OnChanges, - OnInit, - SimpleChanges, -} from "@angular/core"; - -import { UsersService } from "@scicatproject/scicat-sdk-ts-angular"; -import { ActionConfig, ActionDataset } from "./datafiles-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"; - -@Component({ - selector: "datafiles-action", - templateUrl: "./datafiles-action.component.html", - styleUrls: ["./datafiles-action.component.scss"], - standalone: false, -}) -export class DatafilesActionComponent implements OnInit, OnChanges { - @Input({ required: true }) actionConfig: ActionConfig; - @Input({ required: true }) actionDataset: ActionDataset; - @Input({ required: true }) files: DataFiles_File[]; - @Input({ required: true }) maxFileSize: number; - - jwt = ""; - visible = true; - use_mat_icon = false; - use_icon = false; - disabled_condition = "false"; - selectedTotalFileSize = 0; - numberOfFileSelected = 0; - - form: HTMLFormElement = null; - - constructor( - private usersService: UsersService, - private authService: AuthService, - private snackBar: MatSnackBar, - ) { - this.usersService.usersControllerGetUserJWTV3().subscribe((jwt) => { - this.jwt = jwt.jwt; - }); - } - - private evaluate_disabled_condition(condition: string) { - return condition - .replaceAll( - "#SizeLimit", - String( - this.maxFileSize > 0 && - this.selectedTotalFileSize <= this.maxFileSize, - ), - ) - .replaceAll("#Selected", String(this.numberOfFileSelected > 0)); - } - - private prepare_disabled_condition() { - if (this.actionConfig.enabled) { - this.disabled_condition = - "!(" + - this.evaluate_disabled_condition(this.actionConfig.enabled) + - ")"; - } else if (this.actionConfig.disabled) { - this.disabled_condition = this.evaluate_disabled_condition( - this.actionConfig.disabled, - ); - } else { - this.disabled_condition = "false"; - } - } - - ngOnInit() { - this.use_mat_icon = this.actionConfig.mat_icon !== undefined; - this.use_icon = this.actionConfig.icon !== undefined; - this.prepare_disabled_condition(); - this.update_status(); - //this.compute_disabled(); - } - - ngOnChanges(changes: SimpleChanges) { - if (changes["files"]) { - this.update_status(); - //this.compute_disabled(); - } - } - - update_status() { - this.selectedTotalFileSize = this.files - .filter((item) => item.selected || this.actionConfig.files === "all") - .reduce((sum, item) => sum + item.size, 0); - this.numberOfFileSelected = this.files.filter( - (item) => item.selected, - ).length; - } - - get disabled() { - this.update_status(); - this.prepare_disabled_condition(); - - const expr = this.disabled_condition; - const fn = new Function("ctx", `with (ctx) { return (${expr}); }`); - - return fn({ - maxFileSize: this.maxFileSize, - selectedTotalFileSize: this.selectedTotalFileSize, - numberOfFileSelected: this.numberOfFileSelected, - }); - } - - add_input(name, value) { - const input = document.createElement("input"); - input.type = "hidden"; - input.name = name; - input.value = value; - return input; - } - - perform_action() { - const action_type = this.actionConfig.type || "form"; - switch (action_type) { - case "json-download": - return this.type_json_download(); - case "form": - default: - return this.type_form(); - } - } - - type_form() { - if (this.form !== null) { - 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"; - - this.form.appendChild( - this.add_input("auth_token", `Bearer ${this.authService.getToken().id}`), - ); - - this.form.appendChild(this.add_input("jwt", this.jwt)); - - this.form.appendChild(this.add_input("dataset", this.actionDataset.pid)); - - this.form.appendChild( - this.add_input("directory", this.actionDataset.sourceFolder), - ); - - let index = 0; - for (const item of this.files) { - if ( - this.actionConfig.files === "all" || - (this.actionConfig.files === "selected" && item.selected) - ) { - this.form.appendChild( - this.add_input("files[" + index + "]", item.path), - ); - index = index + 1; - } - } - - document.body.appendChild(this.form); - this.form.submit(); - - return true; - } - - type_json_download() { - let payload = ""; - if (this.actionConfig.payload) { - payload = this.actionConfig.payload - .replace(/{{ auth_token }}/, `Bearer ${this.authService.getToken().id}`) - .replace(/{{ jwt }}/, this.jwt) - .replace(/{{ datasetPid }}/, this.actionDataset.pid) - .replace(/{{ sourceFolder }}/, this.actionDataset.sourceFolder) - .replace( - /{{ filesPath }}/, - JSON.stringify( - this.files - .filter( - (item) => - this.actionConfig.files === "all" || - (this.actionConfig.files === "selected" && item.selected), - ) - .map((item) => item.path), - ), - ); - } else { - const data = { - auth_token: `Bearer ${this.authService.getToken().id}`, - jwt: this.jwt, - dataset: this.actionDataset.pid, - directory: this.actionDataset.sourceFolder, - files: this.files - .filter( - (item) => - this.actionConfig.files === "all" || - (this.actionConfig.files === "selected" && item.selected), - ) - .map((item) => item.path), - }; - payload = JSON.stringify(data); - } - - const filename = this.actionConfig.filename.replace(/{{ uuid }}/, v4()); - - fetch(this.actionConfig.url, { - method: this.actionConfig.method || "POST", - headers: { - "Content-Type": "application/json", - }, - body: payload, - }) - .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) => { - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - }) - .catch((error) => { - console.log("Datafile action error : ", error); - this.snackBar.open( - "There has been an error performing the action", - "Close", - { - duration: 2000, - }, - ); - }); - - return true; - } -} diff --git a/src/app/datasets/datafiles-actions/datafiles-action.interfaces.ts b/src/app/datasets/datafiles-actions/datafiles-action.interfaces.ts deleted file mode 100644 index 1218e829a1..0000000000 --- a/src/app/datasets/datafiles-actions/datafiles-action.interfaces.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface ActionConfig { - id: string; - description?: string; - order: number; - label: string; - files: string; - mat_icon?: string; - icon?: string; - type?: string; - url: string; - target: string; - authorization: string[]; - method?: string; - enabled?: string; - disabled?: string; - payload?: string; - filename?: string; -} - -export interface ActionDataset { - pid: string; - sourceFolder: string; -} diff --git a/src/app/datasets/datafiles-actions/datafiles-actions.component.html b/src/app/datasets/datafiles-actions/datafiles-actions.component.html deleted file mode 100644 index b35f645ea2..0000000000 --- a/src/app/datasets/datafiles-actions/datafiles-actions.component.html +++ /dev/null @@ -1,12 +0,0 @@ - -
- - -
-
diff --git a/src/app/datasets/datafiles-actions/datafiles-actions.component.scss b/src/app/datasets/datafiles-actions/datafiles-actions.component.scss deleted file mode 100644 index 2a13354b9b..0000000000 --- a/src/app/datasets/datafiles-actions/datafiles-actions.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -.dataset-datafiles-actions { - float: right; -} diff --git a/src/app/datasets/datafiles-actions/datafiles-actions.component.spec.ts b/src/app/datasets/datafiles-actions/datafiles-actions.component.spec.ts deleted file mode 100644 index 5c80ebec87..0000000000 --- a/src/app/datasets/datafiles-actions/datafiles-actions.component.spec.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; - -import { DatafilesActionsComponent } from "./datafiles-actions.component"; -import { NO_ERRORS_SCHEMA } from "@angular/core"; -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 { ReactiveFormsModule } from "@angular/forms"; -import { MatDialogModule, MatDialogRef } from "@angular/material/dialog"; -import { RouterModule } from "@angular/router"; -import { StoreModule } from "@ngrx/store"; -import { MockMatDialogRef, MockUserApi } from "shared/MockStubs"; -import { AppConfigService } from "app-config.service"; -import { UsersService } from "@scicatproject/scicat-sdk-ts-angular"; - -describe("DatafilesActionsComponent", () => { - let component: DatafilesActionsComponent; - let fixture: ComponentFixture; - const mockAppConfigService = { - getConfig: () => { - return { - maxDirectDownloadSize: 10000, - datafilesActionsEnabled: true, - }; - }, - }; - - const actionsConfig = [ - { - id: "eed8efec-4354-11ef-a3b5-d75573a5d37f", - order: 4, - label: "Download All", - files: "all", - mat_icon: "download", - url: "", - target: "_blank", - enabled: "#SizeLimit", - authorization: ["#datasetAccess", "#datasetPublic"], - }, - { - id: "3072fafc-4363-11ef-b9f9-ebf568222d26", - order: 3, - label: "Download Selected", - files: "selected", - mat_icon: "download", - url: "", - target: "_blank", - enabled: "#Selected && #SizeLimit", - authorization: ["#datasetAccess", "#datasetPublic"], - }, - { - id: "4f974f0e-4364-11ef-9c63-03d19f813f4e", - order: 2, - label: "Notebook All", - files: "all", - icon: "/assets/icons/jupyter_logo.png", - url: "", - target: "_blank", - authorization: ["#datasetAccess", "#datasetPublic"], - }, - { - id: "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", - order: 1, - label: "Notebook Selected", - files: "selected", - icon: "/assets/icons/jupyter_logo.png", - url: "", - target: "_blank", - enabled: "#Selected", - authorization: ["#datasetAccess", "#datasetPublic"], - }, - ]; - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - schemas: [NO_ERRORS_SCHEMA], - imports: [ - MatButtonModule, - MatIconModule, - MatTableModule, - PipesModule, - ReactiveFormsModule, - MatDialogModule, - RouterModule, - RouterModule.forRoot([]), - StoreModule.forRoot({}), - ], - declarations: [DatafilesActionsComponent], - }); - TestBed.overrideComponent(DatafilesActionsComponent, { - set: { - providers: [ - { provide: UsersService, useClass: MockUserApi }, - { provide: MatDialogRef, useClass: MockMatDialogRef }, - { provide: AppConfigService, useValue: mockAppConfigService }, - { provide: UsersService, useClass: MockUserApi }, - ], - }, - }); - TestBed.compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(DatafilesActionsComponent); - component = fixture.componentInstance; - component.files = [ - { - path: "test1", - size: 5000, - time: "2019-09-06T13:11:37.102Z", - chk: "string", - uid: "string", - gid: "string", - perm: "string", - selected: false, - hash: "", - }, - { - path: "test2", - size: 10000, - time: "2019-09-06T13:11:37.102Z", - chk: "string", - uid: "string", - gid: "string", - perm: "string", - selected: false, - hash: "", - }, - ]; - component.actionsConfig = actionsConfig; - component.actionDataset = { - pid: "57eb0ad6-48d4-11ef-814b-df221a8e3571", - sourceFolder: "/level_1/level_2/level3", - }; - fixture.detectChanges(); - }); - - afterEach(() => { - fixture.destroy(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); - - it("sorted actions should be sorted", () => { - const sortedActionsConfig = component.sortedActionsConfig; - - for (let i = 1; i < sortedActionsConfig.length; i++) { - expect( - sortedActionsConfig[i].order >= sortedActionsConfig[i - 1].order, - ).toEqual(true); - } - }); - - it("actions should be visible when enabled in configuration", () => { - expect(component.visible).toEqual(true); - }); - - it("actions should be visible when disabled in configuration", () => { - spyOn(mockAppConfigService, "getConfig").and.returnValue({ - maxDirectDownloadSize: 10000, - datafilesActionsEnabled: false, - }); - expect(component.visible).toEqual(false); - }); - - it("max file size should be the same as set in configuration, aka 10000", () => { - expect(component.maxFileSize).toEqual(10000); - }); - - it("max file size should be the same as set in configuration, aka 5000", () => { - spyOn(mockAppConfigService, "getConfig").and.returnValue({ - maxDirectDownloadSize: 5000, - datafilesActionsEnabled: true, - }); - expect(component.maxFileSize).toEqual(5000); - }); - - it("actions should be visible with default configuration", () => { - spyOn(mockAppConfigService, "getConfig").and.returnValue({ - maxDirectDownloadSize: 10000, - datafilesActionsEnabled: true, - }); - expect(component.visible).toEqual(true); - }); - - it("there should be 4 actions as defined in default configuration", async () => { - expect(component.sortedActionsConfig.length).toEqual(actionsConfig.length); - const htmlElement: HTMLElement = fixture.nativeElement; - const htmlActions = htmlElement.querySelectorAll("datafiles-action"); - expect(htmlActions.length).toEqual(actionsConfig.length); - }); - - it("there should be 0 actions with no actions configured", async () => { - component.actionsConfig = []; - fixture.detectChanges(); - expect(component.sortedActionsConfig.length).toEqual(0); - const htmlElement: HTMLElement = fixture.nativeElement; - const htmlActions = htmlElement.querySelectorAll("datafiles-action"); - expect(htmlActions.length).toEqual(0); - }); -}); diff --git a/src/app/datasets/datafiles-actions/datafiles-actions.component.ts b/src/app/datasets/datafiles-actions/datafiles-actions.component.ts deleted file mode 100644 index a6698cc915..0000000000 --- a/src/app/datasets/datafiles-actions/datafiles-actions.component.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Component, Input } from "@angular/core"; -import { ActionConfig, ActionDataset } from "./datafiles-action.interfaces"; -import { DataFiles_File } from "datasets/datafiles/datafiles.interfaces"; -import { AppConfigService } from "app-config.service"; -//import { DatafilesActionComponent } from "./datafiles-action.component"; - -@Component({ - selector: "datafiles-actions", - //standalone: true, - //imports: [DatafilesActionComponent], - templateUrl: "./datafiles-actions.component.html", - styleUrls: ["./datafiles-actions.component.scss"], - standalone: false, -}) -export class DatafilesActionsComponent { - private _sortedActionsConfig: ActionConfig[]; - - @Input({ required: true }) actionsConfig: ActionConfig[]; - @Input({ required: true }) actionDataset: ActionDataset; - @Input({ required: true }) files: DataFiles_File[]; - - constructor(public appConfigService: AppConfigService) {} - - // ngOnInit() { - // this.sortedActionsConfig = this.actionsConfig; - // this.sortedActionsConfig.sort((a: ActionConfig, b: ActionConfig) => - // a.order && b.order ? a.order - b.order : 0, - // ); - // } - - get visible(): boolean { - return ( - this.appConfigService.getConfig().datafilesActionsEnabled && - this.files.length > 0 - ); - } - - get maxFileSize(): number { - return this.appConfigService.getConfig().maxDirectDownloadSize || 0; - } - - get sortedActionsConfig(): ActionConfig[] { - this._sortedActionsConfig = this.actionsConfig; - this._sortedActionsConfig.sort((a: ActionConfig, b: ActionConfig) => - a.order && b.order ? a.order - b.order : 0, - ); - return this._sortedActionsConfig; - } -} diff --git a/src/app/datasets/datafiles/datafiles.component.html b/src/app/datasets/datafiles/datafiles.component.html index 001ca7d386..50ec74edd9 100644 --- a/src/app/datasets/datafiles/datafiles.component.html +++ b/src/app/datasets/datafiles/datafiles.component.html @@ -32,105 +32,10 @@

No files associated to this dataset

- - + [actionItems]="actionItems" + > { { provide: AppConfigService, useValue: { getConfig } }, { provide: AuthService, useValue: MockAuthService }, { - provide: DatafilesActionsComponent, + provide: ConfigurableActionsComponent, useClass: MockDatafilesActionsComponent, }, { provide: FileSizePipe }, diff --git a/src/app/datasets/datafiles/datafiles.component.ts b/src/app/datasets/datafiles/datafiles.component.ts index e57a10bf6a..8766755f17 100644 --- a/src/app/datasets/datafiles/datafiles.component.ts +++ b/src/app/datasets/datafiles/datafiles.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectorRef, OnDestroy, AfterViewInit, + OnInit, AfterViewChecked, ViewChild, ElementRef, @@ -35,7 +36,10 @@ import { submitJobAction } from "state-management/actions/jobs.actions"; import { AppConfigService } from "app-config.service"; import { NgForm } from "@angular/forms"; import { DataFiles_File } from "./datafiles.interfaces"; -import { ActionDataset } from "datasets/datafiles-actions/datafiles-action.interfaces"; +import { + ActionItemDataset, + ActionItems, +} from "shared/modules/configurable-actions/configurable-action.interfaces"; import { AuthService } from "shared/services/auth/auth.service"; @Component({ @@ -44,9 +48,7 @@ import { AuthService } from "shared/services/auth/auth.service"; styleUrls: ["./datafiles.component.scss"], standalone: false, }) -export class DatafilesComponent - implements OnDestroy, AfterViewInit, AfterViewChecked -{ +export class DatafilesComponent implements OnDestroy, OnInit, AfterViewChecked { @ViewChild("downloadAllForm") downloadAllFormElement: ElementRef; @ViewChild("downloadSelectedForm") downloadSelectedFormElement; datablocks$ = this.store.select(selectCurrentOrigDatablocks); @@ -69,7 +71,9 @@ export class DatafilesComponent files: Array = []; datasetPid = ""; - actionDataset: ActionDataset; + actionItems: ActionItems = { + datasets: [], + }; count = 0; pageSize = 25; @@ -96,7 +100,6 @@ export class DatafilesComponent icon: "save", sort: false, inList: true, - // pipe: FilePathTruncate, }, { name: "size", @@ -244,13 +247,12 @@ export class DatafilesComponent return warning; } - ngAfterViewInit() { + //ngAfterViewInit() { + ngOnInit() { this.subscriptions.push( this.dataset$.subscribe((dataset) => { if (dataset) { - this.sourceFolder = dataset.sourceFolder; - this.datasetPid = dataset.pid; - this.actionDataset = dataset; + this.actionItems.datasets = [dataset]; } }), ); @@ -269,6 +271,7 @@ export class DatafilesComponent this.tableData = files.slice(0, this.pageSize); this.files = files; this.tooLargeFile = this.hasTooLargeFiles(this.files); + this.actionItems.datasets[0].files = files; } }), ); diff --git a/src/app/datasets/datafiles/datafiles.interfaces.ts b/src/app/datasets/datafiles/datafiles.interfaces.ts index a781da1eeb..e096db50ed 100644 --- a/src/app/datasets/datafiles/datafiles.interfaces.ts +++ b/src/app/datasets/datafiles/datafiles.interfaces.ts @@ -2,10 +2,10 @@ export interface DataFiles_File { path: string; size: number; time: string; - chk: string; - uid: string; - gid: string; - perm: string; + chk?: string; + uid?: string; + gid?: string; + perm?: string; selected: boolean; - hash: string; + hash?: string; } diff --git a/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.html b/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.html index 933a0cbd41..7b4d2d671c 100644 --- a/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.html +++ b/src/app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component.html @@ -13,6 +13,10 @@ Jupyter Hub +
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 8e8ac7f112..f20e02c3c2 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 @@ -30,6 +30,10 @@ import { OutputDatasetObsoleteDto } from "@scicatproject/scicat-sdk-ts-angular/m import { Instrument } from "@scicatproject/scicat-sdk-ts-angular"; import { Router } from "@angular/router"; import { MatSnackBar } from "@angular/material/snack-bar"; +import { + ActionItemDataset, + ActionItems, +} from "shared/modules/configurable-actions/configurable-action.interfaces"; /** * Component to show customizable details for a dataset, using the @@ -64,6 +68,10 @@ export class DatasetDetailDynamicComponent implements OnInit, OnDestroy { instrument: Instrument | undefined; dataset: OutputDatasetObsoleteDto | undefined; + actionItems: ActionItems = { + datasets: [], + }; + constructor( public appConfigService: AppConfigService, public dialog: MatDialog, @@ -97,6 +105,10 @@ export class DatasetDetailDynamicComponent implements OnInit, OnDestroy { this.subscriptions.push( this.dataset$.subscribe((dataset) => { + if (dataset) { + console.log("Updatding action items"); + this.actionItems.datasets = [dataset]; + } this.dataset = dataset; }), ); diff --git a/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.scss b/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.scss index 013be16c0a..07cf2cb468 100644 --- a/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.scss +++ b/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.scss @@ -68,13 +68,6 @@ mat-card { } } } -.jupyter-button { - margin: 1em 0 0 1em; -} -.public-toggle { - margin: 1em 1em 0 0; - float: right; -} .attachment-card { display: flex; align-items: center; diff --git a/src/app/datasets/datasets.module.ts b/src/app/datasets/datasets.module.ts index 6d86cb9b28..b7ef9668e4 100644 --- a/src/app/datasets/datasets.module.ts +++ b/src/app/datasets/datasets.module.ts @@ -73,8 +73,6 @@ import { AdminTabComponent } from "./admin-tab/admin-tab.component"; import { instrumentsReducer } from "state-management/reducers/instruments.reducer"; import { InstrumentEffects } from "state-management/effects/instruments.effects"; import { RelatedDatasetsComponent } from "./related-datasets/related-datasets.component"; -import { DatafilesActionsComponent } from "./datafiles-actions/datafiles-actions.component"; -import { DatafilesActionComponent } from "./datafiles-actions/datafiles-action.component"; import { MatMenuModule } from "@angular/material/menu"; import { DatasetsFilterSettingsComponent } from "./datasets-filter/settings/datasets-filter-settings.component"; import { CdkDrag, CdkDragHandle, CdkDropList } from "@angular/cdk/drag-drop"; @@ -90,6 +88,7 @@ import { IngestorModule } from "../ingestor/ingestor.module"; import { MatExpansionModule } from "@angular/material/expansion"; import { MatBadgeModule } from "@angular/material/badge"; import { TitleCasePipe } from "shared/pipes/title-case.pipe"; +import { ConfigurableActionsModule } from "shared/modules/configurable-actions/configurable-actions.module"; @NgModule({ imports: [ @@ -156,7 +155,7 @@ import { TitleCasePipe } from "shared/pipes/title-case.pipe"; IngestorModule, MatExpansionModule, MatBadgeModule, - IngestorModule, + ConfigurableActionsModule, ], declarations: [ BatchViewComponent, @@ -181,8 +180,6 @@ import { TitleCasePipe } from "shared/pipes/title-case.pipe"; DatasetFileUploaderComponent, AdminTabComponent, RelatedDatasetsComponent, - DatafilesActionsComponent, - DatafilesActionComponent, DatasetsFilterSettingsComponent, ], providers: [ diff --git a/src/app/shared/MockStubs.ts b/src/app/shared/MockStubs.ts index 64d46b4c49..151c3e7b3b 100644 --- a/src/app/shared/MockStubs.ts +++ b/src/app/shared/MockStubs.ts @@ -6,11 +6,10 @@ import { AppConfig } from "app-config.module"; import { SciCatDataSource } from "./services/scicat.datasource"; import { ActionConfig, - ActionDataset, -} from "datasets/datafiles-actions/datafiles-action.interfaces"; + ActionItems, +} from "shared/modules/configurable-actions/configurable-action.interfaces"; import { DataFiles_File } from "datasets/datafiles/datafiles.interfaces"; import { - Attachment, Instrument, OutputJobV3Dto, OutputDatasetObsoleteDto, @@ -266,8 +265,9 @@ export class MockScicatDataSource extends SciCatDataSource { export class MockDatafilesActionsComponent { actionsConfig: ActionConfig[]; - dataset: ActionDataset; + actionItems: ActionItems; files: DataFiles_File[]; + visible: boolean; } export class MockHtmlElement { diff --git a/src/app/datasets/datafiles-actions/datafiles-action.component.html b/src/app/shared/modules/configurable-actions/configurable-action.component.html similarity index 100% rename from src/app/datasets/datafiles-actions/datafiles-action.component.html rename to src/app/shared/modules/configurable-actions/configurable-action.component.html diff --git a/src/app/datasets/datafiles-actions/datafiles-action.component.scss b/src/app/shared/modules/configurable-actions/configurable-action.component.scss similarity index 100% rename from src/app/datasets/datafiles-actions/datafiles-action.component.scss rename to src/app/shared/modules/configurable-actions/configurable-action.component.scss 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 new file mode 100644 index 0000000000..e44fa99f6c --- /dev/null +++ b/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts @@ -0,0 +1,986 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; + +import { ConfigurableActionComponent } from "./configurable-action.component"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; +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 { ReactiveFormsModule } from "@angular/forms"; +import { MatDialogModule, MatDialogRef } from "@angular/material/dialog"; +import { RouterModule } from "@angular/router"; +import { Store, StoreModule } from "@ngrx/store"; +import { + MockAuthService, + MockHtmlElement, + MockMatDialogRef, + MockUserApi, +} from "shared/MockStubs"; +//import { ActionConfig, ActionItems } from "./configurable-action.interfaces"; +import { UsersService } from "@scicatproject/scicat-sdk-ts-angular"; +import { AuthService } from "shared/services/auth/auth.service"; +import { MatSnackBarModule } from "@angular/material/snack-bar"; +//import { DataFiles_File } from "datasets/datafiles/datafiles.interfaces"; +import { AppConfigService } from "app-config.service"; +//import { boolean } from "mathjs"; +import { + higherMaxFileSizeLimit, + lowerMaxFileSizeLimit, + maxSizeType, + mockActionItems, + mockActionItemsDatafilesAllfiles, + mockActionItemsDatafilesFile1, + mockActionItemsDatafilesFile2, + mockActionItemsDatafilesNofiles, + mockActionsConfig, + mockAppConfigService, + mockUserProfiles, +} from "./configurable-actions.test.data"; +import { Subject } from "rxjs"; +import { MockStore, provideMockStore } from "@ngrx/store/testing"; +import { selectProfile } from "state-management/selectors/user.selectors"; +import { ActionItems } from "./configurable-action.interfaces"; + +describe("1000: ConfigurableActionComponent", () => { + let component: ConfigurableActionComponent; + let fixture: ComponentFixture; + let htmlForm: HTMLFormElement; + let htmlInput: HTMLInputElement; + + let store: MockStore; + + enum actionSelectorType { + download_all = "eed8efec-4354-11ef-a3b5-d75573a5d37f", + download_selected = "3072fafc-4363-11ef-b9f9-ebf568222d26", + notebook_all_form = "4f974f0e-4364-11ef-9c63-03d19f813f4e", + notebook_selected_form = "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", + notebook_all_json = "0cd5b592-0b1a-11f0-a42c-23e177127ee7", + notebook_selected_json = "a414773a-a526-11f0-a7f2-ff1026e5dba9", + publish = "9c6a11b6-a526-11f0-8795-6f025b320cc3", + unpublish = "94a1d694-a526-11f0-947b-038d53cd837a", + link = "c3bcbd40-a526-11f0-915a-93eeff0860ab", + } + + const usersControllerGetUserJWTV3 = () => ({ + subscribe: () => ({ + jwt: "9a2322a8-4a7d-11ef-a0f5-d7c40fcf1693", + }), + }); + + // const getCurrentToken = () => ({ + // id: "4ac45f3e-4d79-11ef-856c-6339dab93bee", + // }); + + class MockUserProfile { + userProfile$ = new Subject(); + } + + beforeAll(() => { + htmlForm = document.createElement("form"); + (htmlForm as HTMLFormElement).submit = () => {}; + htmlInput = document.createElement("input"); + }); + + beforeEach(waitForAsync(() => { + const mockAuthService = new MockAuthService(); + + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + MatButtonModule, + MatIconModule, + MatTableModule, + PipesModule, + ReactiveFormsModule, + MatDialogModule, + MatSnackBarModule, + RouterModule, + RouterModule.forRoot([]), + StoreModule.forRoot({}), + ], + declarations: [ConfigurableActionComponent], + providers: [provideMockStore()], + }); + TestBed.overrideComponent(ConfigurableActionComponent, { + set: { + providers: [ + { provide: UsersService, useClass: MockUserApi }, + { provide: MatDialogRef, useClass: MockMatDialogRef }, + { + provide: UsersService, + useValue: { usersControllerGetUserJWTV3 }, + }, + { + provide: AuthService, + useValue: mockAuthService, + }, + { provide: AppConfigService, useValue: mockAppConfigService }, + //{ provide: Store, useClass: MockStore } + ], + }, + }); + TestBed.compileComponents(); + + store = TestBed.inject(MockStore); + })); + + function createComponent(componentActionConfig, componentsActionItems) { + fixture = TestBed.createComponent(ConfigurableActionComponent); + component = fixture.componentInstance; + component.actionConfig = componentActionConfig; + component.actionItems = componentsActionItems; + fixture.detectChanges(); + return component; + } + + beforeEach(() => { + fixture = TestBed.createComponent(ConfigurableActionComponent); + component = fixture.componentInstance; + component.actionConfig = mockActionsConfig[0]; + component.actionItems = mockActionItems; + fixture.detectChanges(); + }); + // beforeEach(inject([Store], (mockStore: MockStore) => { + // store = mockStore; + // })); + + afterEach(() => { + fixture.destroy(); + }); + + // it("0000: should create", () => { + // expect(component).toBeTruthy(); + // }); + + /* + * Unit tests for enabled/disabled cases performed + * ------------------------ + * -------- Action , uuid + * Test # , Max Size , Selected , Is Published , Status , Result(Enabled) + * ----------------------------------------------------------------------------------------- + * -------- Download All , eed8efec-4354-11ef-a3b5-d75573a5d37f + * 0010 , low max file size , no selected files , n/a , disabled , false + * 0020 , low max file size , file 1 selected , n/a , disabled , false + * 0030 , low max file size , file 2 selected , n/a , disabled , false + * 0040 , low max file size , all files selected , n/a , disabled , false + * 0050 , high max file size , no selected files , n/a , enabled , true + * 0060 , high max file size , file 1 selected , n/a , enabled , true + * 0070 , high max file size , file 2 selected , n/a , enabled , true + * 0080 , high max file size , all files selected , n/a , enabled , true + * + * -------- Download Selected , 3072fafc-4363-11ef-b9f9-ebf568222d26 + * 0110 , low max file size , no selected files , n/a , disabled , false + * 0120 , low max file size , file 1 selected , n/a , enabled , true + * 0130 , low max file size , file 2 selected , n/a , enabled , true + * 0140 , low max file size , all files selected , n/a , disabled , false + * 0150 , high max file size , no selected files , n/a , disabled , false + * 0160 , high max file size , file 1 selected , n/a , enabled , true + * 0170 , high max file size , file 2 selected , n/a , enabled , true + * 0180 , high max file size , all files selected , n/a , enabled , true + * + * -------- Notebook All (Form) , 4f974f0e-4364-11ef-9c63-03d19f813f4e + * -------- Notebook All (JSON) , a414773a-a526-11f0-a7f2-ff1026e5dba9 + * 0210 , low max file size , no selected files , n/a , enabled , true + * 0220 , low max file size , file 1 selected , n/a , enabled , true + * 0230 , low max file size , file 2 selected , n/a , enabled , true + * 0240 , low max file size , all files selected , n/a , enabled , true + * 0250 , high max file size , no selected files , n/a , enabled , true + * 0260 , high max file size , file 1 selected , n/a , enabled , true + * 0270 , high max file size , file 2 selected , n/a , enabled , true + * 0280 , high max file size , all files selected , n/a , enabled , true + * + * -------- Notebook Selected (Form) , fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd + * -------- Notebook Selected (JSON) , a414773a-a526-11f0-a7f2-ff1026e5dba9 + * 0310 , low max file size , no selected files , n/a , disabled , false + * 0320 , low max file size , file 1 selected , n/a , enabled , true + * 0330 , low max file size , file 2 selected , n/a , enabled , true + * 0340 , low max file size , all files selected , n/a , enabled , true + * 0350 , high max file size , no selected files , n/a , disabled , false + * 0360 , high max file size , file 1 selected , n/a , enabled , true + * 0370 , high max file size , file 2 selected , n/a , enabled , true + * 0380 , high max file size , all files selected , n/a , enabled , true + * + * -------- Publish , 9c6a11b6-a526-11f0-8795-6f025b320cc3 + * 0410 , n/a , n/a , false , enabled , true + * 0420 , n/a , n/a , true , disabled , false + * + * -------- Unpublish , 94a1d694-a526-11f0-947b-038d53cd837a + * 0510 , n/a , n/a , false , disabled , false + * 0520 , n/a , n/a , true , enabled , true + * + * -------- ESS (link) , c3bcbd40-a526-11f0-915a-93eeff0860ab + * 0610 , n/a , n/a , n/a , enabled , true + * + */ + + interface TestCase { + test: string; + action: string; + limit: maxSizeType; + actionItems: ActionItems; + published?: boolean; + result: boolean; + user?: number; + } + + const testEnabledDisabledCases: TestCase[] = [ + // -------- Download All + { + test: "0010: Download All should be disabled with lowest max size limit and no files are selected", + action: "eed8efec-4354-11ef-a3b5-d75573a5d37f", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesNofiles, + result: false, + }, + { + test: "0020: Download All should be disabled with lowest max size limit and file 1 selected", + action: "eed8efec-4354-11ef-a3b5-d75573a5d37f", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesFile1, + result: false, + }, + { + test: "0030: Download All should be disabled with lowest max size limit and file 2 selected", + action: "eed8efec-4354-11ef-a3b5-d75573a5d37f", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesFile2, + result: false, + }, + { + test: "0040: Download All should be disabled with lowest max size limit and all files selected", + action: "eed8efec-4354-11ef-a3b5-d75573a5d37f", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesAllfiles, + result: false, + }, + { + test: "0050: Download All should be enabled with highest max size limit and no files selected", + action: "eed8efec-4354-11ef-a3b5-d75573a5d37f", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + result: true, + }, + { + test: "0060: Download All should be enabled with highest max size limit and file 1 selected", + action: "eed8efec-4354-11ef-a3b5-d75573a5d37f", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesFile1, + result: true, + }, + { + test: "0070: Download All should be enabled with highest max size limit and file 2 selected", + action: "eed8efec-4354-11ef-a3b5-d75573a5d37f", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesFile2, + result: true, + }, + { + test: "0080: Download All should be enabled with highest max size limit and all files selected", + action: "eed8efec-4354-11ef-a3b5-d75573a5d37f", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesAllfiles, + result: true, + }, + // -------- Download Selected + { + test: "0110: Download Selected should be disabled with lowest max size and no files selected", + action: "3072fafc-4363-11ef-b9f9-ebf568222d26", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesNofiles, + result: false, + }, + { + test: "0120: Download Selected should be enabled with lowest max size and file 1 selected", + action: "3072fafc-4363-11ef-b9f9-ebf568222d26", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesFile1, + result: true, + }, + { + test: "0130: Download Selected should be enabled with lowest max size and file 2 selected", + action: "3072fafc-4363-11ef-b9f9-ebf568222d26", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesFile2, + result: true, + }, + { + test: "0140: Download Selected should be disabled with lowest max size and all files selected", + action: "3072fafc-4363-11ef-b9f9-ebf568222d26", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesAllfiles, + result: false, + }, + { + test: "0150: Download Selected should be disabled with highest max size and no files selected", + action: "3072fafc-4363-11ef-b9f9-ebf568222d26", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + result: false, + }, + { + test: "0160: Download Selected should be enabled with highest max size and file 1 selected", + action: "3072fafc-4363-11ef-b9f9-ebf568222d26", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesFile1, + result: true, + }, + { + test: "0170: Download Selected should be enabled with highest max size and file 2 selected", + action: "3072fafc-4363-11ef-b9f9-ebf568222d26", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesFile2, + result: true, + }, + { + test: "0180: Download Selected should be enabled with highest max size and all files selected", + action: "3072fafc-4363-11ef-b9f9-ebf568222d26", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesAllfiles, + result: true, + }, + // -------- Notebook All (Form and JSON) + { + test: "0210: Notebook All should be enabled with lowest max size and no files selected", + action: "4f974f0e-4364-11ef-9c63-03d19f813f4e", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesNofiles, + result: true, + }, + { + test: "0220: Notebook All should be enabled with lowest max size and file 1 selected", + action: "4f974f0e-4364-11ef-9c63-03d19f813f4e", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesFile1, + result: true, + }, + { + test: "0230: Notebook All should be enabled with lowest max size and file 2 selected", + action: "4f974f0e-4364-11ef-9c63-03d19f813f4e", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesFile2, + result: true, + }, + { + test: "0240: Notebook All should be enabled with lowest max size and all files selected", + action: "4f974f0e-4364-11ef-9c63-03d19f813f4e", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesAllfiles, + result: true, + }, + { + test: "0250: Notebook All should be enabled with highest max size and no files selected", + action: "4f974f0e-4364-11ef-9c63-03d19f813f4e", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + result: true, + }, + { + test: "0260: Notebook All should be enabled with highest max size and file 1 selected", + action: "4f974f0e-4364-11ef-9c63-03d19f813f4e", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesFile1, + result: true, + }, + { + test: "0270: Notebook All should be enabled with highest max size and file 2 selected", + action: "4f974f0e-4364-11ef-9c63-03d19f813f4e", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesFile2, + result: true, + }, + { + test: "0280: Notebook All should be enabled with highest max size and all files selected", + action: "4f974f0e-4364-11ef-9c63-03d19f813f4e", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesAllfiles, + result: true, + }, + // -------- Notebook Selected (Form and JSON) + { + test: "0310: Notebook Selected should be disabled with lowest max size and no files selected", + action: "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesNofiles, + result: false, + }, + { + test: "0320: Notebook Selected should be enabled with lowest max size and file 1 selected", + action: "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesFile1, + result: true, + }, + { + test: "0330: Notebook Selected should be enabled with lowest max size and file 2 selected", + action: "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesFile2, + result: true, + }, + { + test: "0340: Notebook Selected should be enabled with lowest max size and all files selected", + action: "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesAllfiles, + result: true, + }, + { + test: "0350: Notebook Selected should be disabled with highest max size and no files selected", + action: "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + result: false, + }, + { + test: "0360: Notebook Selected should be enabled with highest max size and file 1 selected", + action: "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesFile1, + result: true, + }, + { + test: "0370: Notebook Selected should be enabled with highest max size and file 2 selected", + action: "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesFile2, + result: true, + }, + { + test: "0380: Notebook Selected should be enabled with highest max size and all files selected", + action: "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesAllfiles, + result: true, + }, + // -------- Publish + { + test: "0410: Publish should be enabled when a single dataset is not already published and user is owner", + action: "9c6a11b6-a526-11f0-8795-6f025b320cc3", + limit: undefined, + actionItems: mockActionItemsDatafilesNofiles, + published: false, + result: true, + user: 1, + }, + { + test: "0420: Publish should be disabled when a single dataset is already published and user is an owner", + action: "9c6a11b6-a526-11f0-8795-6f025b320cc3", + limit: undefined, + actionItems: mockActionItemsDatafilesNofiles, + published: true, + result: false, + user: 1, + }, + { + test: "0430: Publish should be disabled when a single dataset is not already published and user is not owner", + action: "9c6a11b6-a526-11f0-8795-6f025b320cc3", + limit: undefined, + actionItems: mockActionItemsDatafilesNofiles, + published: false, + result: false, + user: 2, + }, + { + test: "0440: Publish should be disabled when a single dataset is already published and user is not owner", + action: "9c6a11b6-a526-11f0-8795-6f025b320cc3", + limit: undefined, + actionItems: mockActionItemsDatafilesNofiles, + published: true, + result: false, + user: 2, + }, + + // -------- Unpublish + { + test: "0510: Unpublish should be disabled when a single dataset is not already published and user is an owner", + action: "94a1d694-a526-11f0-947b-038d53cd837a", + limit: undefined, + actionItems: mockActionItemsDatafilesNofiles, + published: false, + result: false, + user: 1, + }, + { + test: "0520: Unpublish should be enabled when a single dataset is already published and user is an owner", + action: "94a1d694-a526-11f0-947b-038d53cd837a", + limit: undefined, + actionItems: mockActionItemsDatafilesNofiles, + published: true, + result: true, + user: 1, + }, + { + test: "0530: Unpublish should be disabled when a single dataset is not already published and users is not an owner", + action: "94a1d694-a526-11f0-947b-038d53cd837a", + limit: undefined, + actionItems: mockActionItemsDatafilesNofiles, + published: false, + result: false, + user: 2, + }, + { + test: "0540: Unpublish should be disabled when a single dataset is already published and users is not an owner", + action: "94a1d694-a526-11f0-947b-038d53cd837a", + limit: undefined, + actionItems: mockActionItemsDatafilesNofiles, + published: true, + result: false, + user: 2, + }, + + // -------- ESS (link) + { + test: "0610: ESS link should always be enabled, with lower download limit and anonymous user", + action: "c3bcbd40-a526-11f0-915a-93eeff0860ab", + limit: maxSizeType.lower, + actionItems: mockActionItemsDatafilesNofiles, + published: undefined, + result: true, + }, + { + test: "0620: ESS link should always be enabled, with higher download limit and anonymous user", + action: "c3bcbd40-a526-11f0-915a-93eeff0860ab", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + published: undefined, + result: true, + }, + { + test: "0630: ESS link should always be enabled, with user who is an owner", + action: "c3bcbd40-a526-11f0-915a-93eeff0860ab", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + published: undefined, + result: true, + user: 1, + }, + { + test: "0610: ESS link should always be enabled, with user who is not an owner", + action: "c3bcbd40-a526-11f0-915a-93eeff0860ab", + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + published: undefined, + result: true, + user: 2, + }, + ]; + + function selectTestCase(testCase: TestCase) { + const userProfile = mockUserProfiles[testCase.user] || {}; + + store.overrideSelector(selectProfile, userProfile); + store.refreshState(); + + const currentActionConfig = mockActionsConfig.filter( + (a) => a.id == testCase.action, + )[0]; + + switch (testCase.limit) { + case maxSizeType.higher: + mockAppConfigService.appConfig.maxDirectDownloadSize = + higherMaxFileSizeLimit; + break; + case maxSizeType.lower: + default: + mockAppConfigService.appConfig.maxDirectDownloadSize = + lowerMaxFileSizeLimit; + break; + } + + const published: boolean | string = testCase.published || false; + if (typeof published === "boolean") { + testCase.actionItems.datasets.forEach((dataset) => { + dataset.isPublished = published; + }); + } + + createComponent(currentActionConfig, testCase.actionItems); + } + + testEnabledDisabledCases.forEach((testCase) => { + it(testCase.test, () => { + selectTestCase(testCase); + + expect(component.disabled).toEqual(!testCase.result); + }); + }); + + function createFakeElement(elementType: string): HTMLElement { + let element: HTMLElement = null; + + switch (elementType) { + case "form": + element = htmlForm.cloneNode(true) as HTMLElement; + break; + case "input": + element = htmlInput.cloneNode(true) as HTMLElement; + break; + default: + element = null; + } + return element; + } + + it("1000: Form submission should have all files when Download All is clicked", async () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.download_all, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + result: false, + } as TestCase); + + spyOn(document, "createElement").and.callFake(createFakeElement); + + component.perform_action(); + + const formChildren = Array.from(component.form.children).map( + (item) => item as unknown as MockHtmlElement, + ); + + const formFiles = formChildren.filter((item) => + item.name.includes("files"), + ); + + expect(formFiles.length).toEqual( + mockActionItemsDatafilesNofiles.datasets[0].files?.length, + ); + }); + + it("1010: Form submission should have correct url when Download All is clicked", async () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.download_all, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + result: false, + } as TestCase); + const action_url = mockActionsConfig + .filter((a) => a.id == actionSelectorType.download_all)[0] + .url.replace(/\/$/, ""); + spyOn(document, "createElement").and.callFake(createFakeElement); + + component.perform_action(); + + expect(component.form.action.replace(/\/$/, "")).toEqual(action_url); + }); + + it("1020: Form submission should have correct dataset when Download All is clicked", async () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.download_all, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + result: false, + } as TestCase); + spyOn(document, "createElement").and.callFake(createFakeElement); + + component.perform_action(); + + const formChildren = Array.from(component.form.children).map( + (item) => item as unknown as MockHtmlElement, + ); + + const formDataset = formChildren.filter((item) => + item.name.includes("dataset"), + ); + expect(formDataset.length).toEqual(1); + + const datasetPid = formDataset[0].value; + expect(datasetPid).toEqual(mockActionItems.datasets[0].pid); + }); + + it("1030: Form submission should have correct file when Download Selected is clicked", async () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.download_selected, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesFile1, + result: false, + } as TestCase); + spyOn(document, "createElement").and.callFake(createFakeElement); + + component.perform_action(); + + const formChildren = Array.from(component.form.children).map( + (item) => item as unknown as MockHtmlElement, + ); + const formFiles = formChildren.filter((item) => + item.name.includes("files"), + ); + expect(formFiles.length).toEqual(1); + const formFilePath = formFiles[0].value; + const selectedFiles = + mockActionItemsDatafilesFile1.datasets[0].files.filter((f) => f.selected); + expect(selectedFiles.length).toEqual(1); + const selectedFilePath = selectedFiles[0].path; + expect(formFilePath).toEqual(selectedFilePath); + }); + + it("1040: Form submission should have all files when Notebook All (Form) is clicked", async () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.notebook_all_form, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + result: false, + } as TestCase); + spyOn(document, "createElement").and.callFake(createFakeElement); + + component.perform_action(); + + const formChildren = Array.from(component.form.children).map( + (item) => item as unknown as MockHtmlElement, + ); + const formFiles = formChildren.filter((item) => + item.name.includes("files"), + ); + expect(formFiles.length).toEqual(mockActionItems.datasets[0].files.length); + }); + + it("1050: Form submission should have correct url when Notebook All (Form) is clicked", async () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.notebook_all_form, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + result: false, + } as TestCase); + const action_url = mockActionsConfig + .filter((a) => a.id == actionSelectorType.notebook_all_form)[0] + .url.replace(/\/$/, ""); + spyOn(document, "createElement").and.callFake(createFakeElement); + + component.perform_action(); + + expect(component.form.action.replace(/\/$/, "")).toEqual(action_url); + }); + + it("1060: Form submission should have correct file when Notebook Selected (Form) is clicked", async () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.notebook_selected_form, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesFile2, + result: false, + } as TestCase); + spyOn(document, "createElement").and.callFake(createFakeElement); + + component.perform_action(); + + const formChildren = Array.from(component.form.children).map( + (item) => item as unknown as MockHtmlElement, + ); + const formFiles = formChildren.filter((item) => + item.name.includes("files"), + ); + expect(formFiles.length).toEqual(1); + const formFilePath = formFiles[0].value; + const selectedFiles = + mockActionItemsDatafilesFile2.datasets[0].files.filter((f) => f.selected); + expect(selectedFiles.length).toEqual(1); + const selectedFilePath = selectedFiles[0].path; + expect(formFilePath).toEqual(selectedFilePath); + }); + + it("1070: Download All action button should contain the correct label", () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.download_all, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + result: false, + } as TestCase); + + const componentElement: HTMLElement = fixture.nativeElement; + const actionButton = componentElement.querySelector(".action-button"); + expect(actionButton.innerHTML).toContain( + mockActionsConfig.filter( + (a) => a.id == actionSelectorType.download_all, + )[0].label, + ); + }); + + it("1080: Download Selected action button should contain the correct label", () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.download_selected, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesAllfiles, + result: false, + } as TestCase); + + const componentElement: HTMLElement = fixture.nativeElement; + const actionButton = componentElement.querySelector(".action-button"); + expect(actionButton.innerHTML).toContain( + mockActionsConfig.filter( + (a) => a.id == actionSelectorType.download_selected, + )[0].label, + ); + }); + + it("1090: Notebook All (Form) action button should contain the correct label", () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.notebook_all_form, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesAllfiles, + result: false, + } as TestCase); + + const componentElement: HTMLElement = fixture.nativeElement; + const actionButton = componentElement.querySelector(".action-button"); + expect(actionButton.innerHTML).toContain( + mockActionsConfig.filter( + (a) => a.id == actionSelectorType.notebook_all_form, + )[0].label, + ); + }); + + it("1100: Notebook Selected (Form) action button should contain the correct label", () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.notebook_selected_form, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesAllfiles, + result: false, + } as TestCase); + + const componentElement: HTMLElement = fixture.nativeElement; + const actionButton = componentElement.querySelector(".action-button"); + expect(actionButton.innerHTML).toContain( + mockActionsConfig.filter( + (a) => a.id == actionSelectorType.notebook_selected_form, + )[0].label, + ); + }); + + it("1110: Notebook All (Json) action should fetch with correct payload when clicked", async () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.notebook_all_json, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + result: false, + } as TestCase); + + component.jwt = "TEST_JWT"; + + spyOn(window, "fetch").and.returnValue( + Promise.resolve( + new Response(new Blob(), { + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + component.perform_action(); + + const spy = window.fetch as jasmine.Spy; + expect(spy.calls.any()).toBeTrue(); + const call = spy.calls.mostRecent(); + expect(call).toBeDefined(); + const [url, opts] = call.args; + //const [url, opts] = (window.fetch as jasmine.Spy).calls.mostRecent().args; + + const currentAction = mockActionsConfig.filter( + (a) => a.id == actionSelectorType.notebook_all_json, + )[0]; + expect(url).toBe(currentAction.url); + expect(opts.method).toBe("POST"); + expect(opts.headers["Content-Type"]).toBe("application/json"); + + //{\"template_id\":\"c975455e-ede3-11ef-94fb-138c9cd51fc0\",\"parameters\":{\"dataset\":\"{{ @pid }}\",\"directory\":\"{{ @folder }}\",\"files\": {{ @files[] }},\"jwt\":\"{{ #jwt }}\",\"scicat_url\":\"https://staging.scicat.ess.url\",\"file_server_url\":\"sftserver2.esss.dk\",\"file_server_port\":\"22\"}} + + const body = JSON.parse(opts.body); + + expect(body.template_id).toBe("c975455e-ede3-11ef-94fb-138c9cd51fc0"); + expect(body.parameters.jwt).toBe("TEST_JWT"); + expect(body.parameters.dataset).toBe( + mockActionItemsDatafilesNofiles.datasets[0].pid, + ); + expect(body.parameters.directory).toBe( + mockActionItemsDatafilesNofiles.datasets[0].sourceFolder, + ); + expect(body.parameters.files.length).toBe( + mockActionItemsDatafilesNofiles.datasets[0].files.length, + ); + }); + + it("1120: Notebook Selected (Json) action should fetch with correct payload when clicked", async () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.notebook_selected_json, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesFile2, + result: false, + } as TestCase); + + component.jwt = "TEST_JWT"; + spyOn(window, "fetch").and.returnValue( + Promise.resolve( + new Response(new Blob(), { + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + component.perform_action(); + + const spy = window.fetch as jasmine.Spy; + + expect(spy.calls.any()).toBeTrue(); + const call = spy.calls.mostRecent(); + + expect(call).toBeDefined(); + const [url, opts] = call.args; + const currentAction = mockActionsConfig.filter( + (a) => a.id == actionSelectorType.notebook_selected_json, + )[0]; + + expect(url).toBe(currentAction.url); + expect(opts.method).toBe("POST"); + expect(opts.headers["Content-Type"]).toBe("application/json"); + + //{\"template_id\":\"c975455e-ede3-11ef-94fb-138c9cd51fc0\",\"parameters\":{\"dataset\":\"{{ @pid }}\",\"directory\":\"{{ @folder }}\",\"files\": {{ @files[] }},\"jwt\":\"{{ #jwt }}\",\"scicat_url\":\"https://staging.scicat.ess.url\",\"file_server_url\":\"sftserver2.esss.dk\",\"file_server_port\":\"22\"}} + const body = JSON.parse(opts.body); + expect(body.template_id).toBe("c975455e-ede3-11ef-94fb-138c9cd51fc0"); + expect(body.parameters.jwt).toBe("TEST_JWT"); + expect(body.parameters.dataset).toBe( + mockActionItemsDatafilesFile2.datasets[0].pid, + ); + expect(body.parameters.directory).toBe( + mockActionItemsDatafilesFile2.datasets[0].sourceFolder, + ); + expect(body.parameters.files.length).toBe(1); + expect(body.parameters.files[0]).toBe( + mockActionItemsDatafilesFile2.datasets[0].files.filter( + (f) => f.selected, + )[0].path, + ); + }); + + it("1130: link action should open a new tab and redirect to the specified url", async () => { + selectTestCase({ + test: "n/a", + action: actionSelectorType.link, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + result: false, + } as TestCase); + spyOn(window, "open"); + component.perform_action(); + + const current_action = mockActionsConfig.filter( + (a) => a.id == actionSelectorType.link, + )[0]; + expect(window.open).toHaveBeenCalledWith( + current_action.url, + current_action.target, + ); + }); +}); diff --git a/src/app/shared/modules/configurable-actions/configurable-action.component.ts b/src/app/shared/modules/configurable-actions/configurable-action.component.ts new file mode 100644 index 0000000000..86d91f8df5 --- /dev/null +++ b/src/app/shared/modules/configurable-actions/configurable-action.component.ts @@ -0,0 +1,495 @@ +import { + Component, + Input, + OnChanges, + OnInit, + 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 { 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 { + 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; +} + +@Component({ + selector: "configurable-action", + templateUrl: "./configurable-action.component.html", + styleUrls: ["./configurable-action.component.scss"], + standalone: false, +}) +export class ConfigurableActionComponent implements OnInit, OnChanges { + @Input({ required: true }) actionConfig: ActionConfig; + @Input({ required: true }) actionItems: ActionItems; + //@Input() files?: DataFiles_File[]; + 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[] = []; + + userProfile: any = {}; + isAdmin = false; + + constructor( + private usersService: UsersService, + private authService: AuthService, + private configService: AppConfigService, + private snackBar: MatSnackBar, + private store: Store, + private router: Router, + ) { + 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`, + ) + // 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(/\@(\w+)/g, (_, variableName) => `variables.${variableName}`) + ); + } + + 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"; + } + } + + private prepare_hidden_condition() { + if (this.actionConfig.hidden) { + return ( + "!(" + this.evaluate_hidden_condition(this.actionConfig.hidden) + ")" + ); + } else { + return "false"; + } + } + + 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; + } + }), + ); + this.use_mat_icon = !!this.actionConfig.mat_icon; + this.use_icon = this.actionConfig.icon !== undefined; + this.prepare_disabled_condition(); + this.update_status(); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes["actionItems"]) { + this.update_status(); + } + } + + update_status() { + Object.entries(this.actionConfig.variables ?? {}).forEach( + ([key, selector]) => { + this.variables[key] = processSelector(this.actionItems, selector); + }, + ); + } + + 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, + }; + } + + get disabled() { + this.update_status(); + + const expr = this.disabled_condition; + const fn = new Function("ctx", `with (ctx) { return (${expr}); }`); + const context = this.context; + const res = fn(context); + return res; + } + + get visible() { + if (!this.actionConfig.hidden) { + return true; + } else { + const expr = this.prepare_hidden_condition(); + const fn = new Function("ctx", `with (ctx) { return (${expr}); }`); + + return fn({ + variables: this.variables, + maxDownloadableSize: + this.configService.getConfig().maxDirectDownloadSize, + }); + } + } + + add_input(name: string, value: string) { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = name; + input.value = value; + return input; + } + + 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(); + } + } + + 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; + } + + type_form() { + if (this.form !== null) { + 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); + + 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), + ); + }); + } else { + this.form.appendChild(this.add_input(input, 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; + } + + type_json_to_download() { + const filename = this.actionConfig.filename.replace( + /{{\s*(\w+)\s*}}/g, + (_, variableName) => this.get_value_from_definition(variableName), + ); + + fetch(this.actionConfig.url, { + method: this.actionConfig.method || "POST", + headers: { + ...{ + "Content-Type": "application/json", + }, + ...(this.actionConfig.headers || {}), + }, + body: this.get_payload(), + }) + .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) => { + 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, + }, + ); + }); + + return true; + } + + type_xhr() { + const url = this.actionConfig.url.replace( + /{{\s*(\w+)\s*}}/g, + (_, variableName) => + encodeURIComponent(this.get_value_from_definition(variableName)), + ); + + fetch(url, { + method: this.actionConfig.method || "POST", + headers: { + ...{ + "Content-Type": "application/json", + }, + ...(this.actionConfig.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, + }, + ); + }); + + return true; + } + + type_link() { + window.open(this.actionConfig.url, this.actionConfig.target || "_self"); + } +} diff --git a/src/app/shared/modules/configurable-actions/configurable-action.interfaces.ts b/src/app/shared/modules/configurable-actions/configurable-action.interfaces.ts new file mode 100644 index 0000000000..7e81519b05 --- /dev/null +++ b/src/app/shared/modules/configurable-actions/configurable-action.interfaces.ts @@ -0,0 +1,42 @@ +import { DataFiles_File } from "datasets/datafiles/datafiles.interfaces"; + +export interface ActionConfig { + id: string; + description?: string; + order: number; + label: string; + files?: string; + mat_icon?: string; + icon?: string; + type?: string; + url: string; + target: string; + authorization: string[]; + method?: string; + enabled?: string; + disabled?: string; + payload?: string; + filename?: string; + hidden?: string; + variables?: Record; + inputs?: Record; + headers?: Record; +} + +// export interface ActionItem { +// pid: string; +// sourceFolder?: string; +// isPublished?: boolean; +// } + +export interface ActionItemDataset { + ownerGroup: string; + pid: string; + sourceFolder?: string; + isPublished?: boolean; + files?: DataFiles_File[]; +} + +export interface ActionItems { + datasets: ActionItemDataset[]; +} diff --git a/src/app/shared/modules/configurable-actions/configurable-actions.component.html b/src/app/shared/modules/configurable-actions/configurable-actions.component.html new file mode 100644 index 0000000000..4f9fbddaa1 --- /dev/null +++ b/src/app/shared/modules/configurable-actions/configurable-actions.component.html @@ -0,0 +1,10 @@ + +
+ + +
+
diff --git a/src/app/shared/modules/configurable-actions/configurable-actions.component.scss b/src/app/shared/modules/configurable-actions/configurable-actions.component.scss new file mode 100644 index 0000000000..a0d3feb528 --- /dev/null +++ b/src/app/shared/modules/configurable-actions/configurable-actions.component.scss @@ -0,0 +1,4 @@ +.configurable-actions { + float: right; + //margin: 1em; +} 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 new file mode 100644 index 0000000000..941b1719cc --- /dev/null +++ b/src/app/shared/modules/configurable-actions/configurable-actions.component.spec.ts @@ -0,0 +1,129 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; + +import { ConfigurableActionsComponent } from "./configurable-actions.component"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; +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 { ReactiveFormsModule } from "@angular/forms"; +import { MatDialogModule, MatDialogRef } from "@angular/material/dialog"; +import { RouterModule } from "@angular/router"; +import { StoreModule } from "@ngrx/store"; +import { MockMatDialogRef, MockUserApi } from "shared/MockStubs"; +import { AppConfigService } from "app-config.service"; +import { UsersService } from "@scicatproject/scicat-sdk-ts-angular"; +import { + higherMaxFileSizeLimit, + lowerMaxFileSizeLimit, + mockActionItems, + mockActionsConfig, + mockAppConfigService, +} from "./configurable-actions.test.data"; + +describe("1010: ConfigurableActionsComponent", () => { + let component: ConfigurableActionsComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + MatButtonModule, + MatIconModule, + MatTableModule, + PipesModule, + ReactiveFormsModule, + MatDialogModule, + RouterModule, + RouterModule.forRoot([]), + StoreModule.forRoot({}), + ], + declarations: [ConfigurableActionsComponent], + }); + TestBed.overrideComponent(ConfigurableActionsComponent, { + set: { + providers: [ + { provide: UsersService, useClass: MockUserApi }, + { provide: MatDialogRef, useClass: MockMatDialogRef }, + { provide: AppConfigService, useValue: mockAppConfigService }, + { provide: UsersService, useClass: MockUserApi }, + ], + }, + }); + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfigurableActionsComponent); + component = fixture.componentInstance; + component.actionsConfig = mockActionsConfig; + component.actionItems = mockActionItems; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it("0000: should create", () => { + expect(component).toBeTruthy(); + }); + + it("0010: sorted actions should be sorted", () => { + const sortedActionsConfig = component.sortedActionsConfig; + + for (let i = 1; i < sortedActionsConfig.length; i++) { + expect( + sortedActionsConfig[i].order >= sortedActionsConfig[i - 1].order, + ).toEqual(true); + } + }); + + it("0020: actions should be visible when enabled in configuration", () => { + mockAppConfigService.appConfig.datafilesActionsEnabled = true; + expect(component.visible).toEqual(true); + }); + + it("0030: actions should be not visible when disabled in configuration", () => { + mockAppConfigService.appConfig.datafilesActionsEnabled = false; + //spyOn(mockAppConfigService, "getConfig").and.returnValue(localConfig); + expect(component.visible).toEqual(false); + }); + + it("0040: max file size should be the same as set in configuration, aka higher limit", () => { + mockAppConfigService.appConfig.maxDirectDownloadSize = + higherMaxFileSizeLimit; + + expect(component.maxFileSize).toEqual(higherMaxFileSizeLimit); + }); + + it("0050: max file size should be the same as set in configuration, aka lower limit", () => { + // const localConfig = mockAppConfigService.getConfig(); + // localConfig.maxDirectDownloadSize = lowerMaxFileSizeLimit; + // spyOn(mockAppConfigService, "getConfig").and.returnValue(localConfig); + mockAppConfigService.appConfig.maxDirectDownloadSize = + lowerMaxFileSizeLimit; + + expect(component.maxFileSize).toEqual(lowerMaxFileSizeLimit); + }); + + it("0060: there should be as many actions as defined in default configuration", async () => { + component.actionsConfig = mockActionsConfig; + expect(component.sortedActionsConfig.length).toEqual( + mockActionsConfig.length, + ); + const htmlElement: HTMLElement = fixture.nativeElement; + const htmlActions = htmlElement.querySelectorAll("configurable-action"); + expect(htmlActions.length).toEqual(mockActionsConfig.length); + }); + + it("0070: there should be 0 actions with no actions configured", async () => { + component.actionsConfig = []; + fixture.detectChanges(); + expect(component.sortedActionsConfig.length).toEqual(0); + const htmlElement: HTMLElement = fixture.nativeElement; + const htmlActions = htmlElement.querySelectorAll("configurable-action"); + expect(htmlActions.length).toEqual(0); + }); +}); diff --git a/src/app/shared/modules/configurable-actions/configurable-actions.component.ts b/src/app/shared/modules/configurable-actions/configurable-actions.component.ts new file mode 100644 index 0000000000..12d543b3f7 --- /dev/null +++ b/src/app/shared/modules/configurable-actions/configurable-actions.component.ts @@ -0,0 +1,35 @@ +import { Component, Input } from "@angular/core"; +import { ActionConfig, ActionItems } from "./configurable-action.interfaces"; + +import { AppConfigService } from "app-config.service"; + +@Component({ + selector: "configurable-actions", + templateUrl: "./configurable-actions.component.html", + styleUrls: ["./configurable-actions.component.scss"], + standalone: false, +}) +export class ConfigurableActionsComponent { + private _sortedActionsConfig: ActionConfig[]; + + @Input({ required: true }) actionsConfig: ActionConfig[] = []; + @Input({ required: true }) actionItems: ActionItems; + + constructor(public appConfigService: AppConfigService) {} + + get visible(): boolean { + return this.appConfigService.getConfig().datafilesActionsEnabled; + } + + get maxFileSize(): number { + return this.appConfigService.getConfig().maxDirectDownloadSize || 0; + } + + get sortedActionsConfig(): ActionConfig[] { + this._sortedActionsConfig = this.actionsConfig; + this.actionsConfig.sort((a: ActionConfig, b: ActionConfig) => + a.order && b.order ? a.order - b.order : 0, + ); + return this._sortedActionsConfig; + } +} diff --git a/src/app/datasets/datafiles-actions/datafiles-actions.documentation.md b/src/app/shared/modules/configurable-actions/configurable-actions.documentation.md similarity index 100% rename from src/app/datasets/datafiles-actions/datafiles-actions.documentation.md rename to src/app/shared/modules/configurable-actions/configurable-actions.documentation.md diff --git a/src/app/shared/modules/configurable-actions/configurable-actions.module.ts b/src/app/shared/modules/configurable-actions/configurable-actions.module.ts new file mode 100644 index 0000000000..6241ffe260 --- /dev/null +++ b/src/app/shared/modules/configurable-actions/configurable-actions.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from "@angular/core"; +import { CommonModule } from "@angular/common"; + +import { MatButtonModule } from "@angular/material/button"; +import { ConfigurableActionsComponent } from "./configurable-actions.component"; +import { ConfigurableActionComponent } from "./configurable-action.component"; +import { MatIconModule } from "@angular/material/icon"; + +@NgModule({ + imports: [CommonModule, MatIconModule, MatButtonModule], + declarations: [ConfigurableActionsComponent, ConfigurableActionComponent], + exports: [ConfigurableActionsComponent, ConfigurableActionComponent], +}) +export class ConfigurableActionsModule {} 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 new file mode 100644 index 0000000000..c04e0faff5 --- /dev/null +++ b/src/app/shared/modules/configurable-actions/configurable-actions.test.data.ts @@ -0,0 +1,325 @@ +import { + ActionConfig, + ActionItemDataset, + ActionItems, +} from "./configurable-action.interfaces"; + +export const mockAppConfigService = { + appConfig: { + maxDirectDownloadSize: 0, + datafilesActionsEnabled: true, + }, + getConfig() { + return this.appConfig; + }, +}; + +export const mockActionsConfig: ActionConfig[] = [ + { + id: "c3bcbd40-a526-11f0-915a-93eeff0860ab", + description: "This action let users jump to another URL entirely", + order: 9, + label: "ESS", + files: "all", + type: "link", + mat_icon: "action", + url: "https://ess.eu", + target: "_blank", + authorization: ["true"], + }, + { + id: "eed8efec-4354-11ef-a3b5-d75573a5d37f", + description: + "This action let users download all files using the zip service", + order: 1, + label: "Download All", + files: "all", + mat_icon: "download", + type: "form", + url: "https://zip.scicatproject.org/download/all", + target: "_blank", + variables: { + pid: "#Dataset0Pid", + files: "#Dataset0FilesPath", + totalSize: "#Dataset0FilesTotalSize", + folder: "#Dataset0SourceFolder", + }, + enabled: "#MaxDownloadableSize(@totalSize)", + inputs: { + "dataset[]": "@pid", + "directory[]": "@folder", + "files[]": "@files", + }, + authorization: ["#datasetAccess", "#datasetPublic"], + }, + { + id: "3072fafc-4363-11ef-b9f9-ebf568222d26", + description: + "This action let users download selected files using the zip service", + order: 2, + label: "Download Selected", + files: "selected", + mat_icon: "download", + type: "form", + url: "https://zip.scicatproject.org/download/selected", + target: "_blank", + variables: { + pid: "#Dataset0Pid", + files: "#Dataset0SelectedFilesPath", + selected: "#Dataset0SelectedFilesCount", + totalSize: "#Dataset0SelectedFilesTotalSize", + folder: "#Dataset0SourceFolder", + }, + inputs: { + auth_token: "#tokenBearer", + jwt: "#jwt", + "dataset[]": "@pid", + "directory[]": "@folder", + "files[]": "@files", + }, + enabled: "#Length(@files) && #MaxDownloadableSize(@totalSize)", + authorization: ["#datasetAccess", "#datasetPublic"], + }, + { + id: "4f974f0e-4364-11ef-9c63-03d19f813f4e", + description: + "This action let users download jupyter notebook properly populated with dataset pid and all files using an instance of sciwyrm", + order: 3, + label: "Notebook All (Form)", + files: "all", + icon: "/assets/icons/jupyter_logo.png", + type: "form", + url: "https://www.scicat.info/notebook/all", + target: "_blank", + variables: { + pid: "#Dataset0Pid", + files: "#Dataset0FilesPath", + totalSize: "#Dataset0FilesTotalSize", + folder: "#Dataset0SourceFolder", + }, + enabled: "", + inputs: { + auth_token: "#token", + jwt: "#jwt", + "dataset[]": "@pid", + "directory[]": "@folder", + "files[]": "@files", + }, + authorization: ["#datasetAccess", "#datasetPublic"], + }, + { + id: "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", + order: 4, + label: "Notebook Selected (Form)", + files: "selected", + icon: "/assets/icons/jupyter_logo.png", + type: "form", + url: "https://www.scicat.info/notebook/selected", + target: "_blank", + variables: { + pid: "#Dataset0Pid", + files: "#Dataset0SelectedFilesPath", + totalSize: "#Dataset0SelectedFilesTotalSize", + folder: "#Dataset0SourceFolder", + }, + inputs: { + auth_token: "#token", + jwt: "#jwt", + "dataset[]": "@pid", + "directory[]": "@folder", + "files[]": "@files", + }, + enabled: "#Length(@files) > 0", + authorization: ["#datasetAccess", "#datasetPublic"], + }, + { + id: "0cd5b592-0b1a-11f0-a42c-23e177127ee7", + description: + "This action let users download jupyter notebook properly populated with dataset pid and all files using an instance of sciwyrm", + order: 5, + label: "Notebook All (Download JSON)", + files: "all", + type: "json-download", + icon: "/assets/icons/jupyter_logo.png", + url: "https://www.sciwyrm.info/notebook/all", + target: "_blank", + authorization: ["#datasetAccess", "#datasetPublic"], + variables: { + pid: "#Dataset0Pid", + files: "#Dataset0FilesPath", + folder: "#Dataset0SourceFolder", + }, + payload: + '{"template_id":"c975455e-ede3-11ef-94fb-138c9cd51fc0","parameters":{"dataset":"{{ @pid }}","directory":"{{ @folder }}","files": {{ @files[] }},"jwt":"{{ #jwt }}","scicat_url":"https://staging.scicat.ess.url","file_server_url":"sftserver2.esss.dk","file_server_port":"22"}}', + filename: "{{ #uuid }}.ipynb", + }, + { + id: "a414773a-a526-11f0-a7f2-ff1026e5dba9", + description: + "This action let users download jupyter notebook properly populated with dataset pid and selected files using an instance of sciwyrm", + order: 6, + label: "Notebook Selected (Download JSON)", + files: "selected", + type: "json-download", + icon: "/assets/icons/jupyter_logo.png", + url: "https://www.sciwyrm.info/notebook/selected", + target: "_blank", + enabled: "#Length(@files)", + authorization: ["#datasetAccess", "#datasetPublic"], + variables: { + pid: "#Dataset0Pid", + files: "#Dataset0SelectedFilesPath", + folder: "#Dataset0SourceFolder", + }, + payload: + '{"template_id":"c975455e-ede3-11ef-94fb-138c9cd51fc0","parameters":{"dataset":"{{ @pid }}","directory":"{{ @folder }}","files": {{ @files[] }},"jwt":"{{ #jwt }}","scicat_url":"https://staging.scicat.ess.url","file_server_url":"sftserver2.esss.dk","file_server_port":"22"}}', + filename: "{{ #uuid }}.ipynb", + }, + { + id: "9c6a11b6-a526-11f0-8795-6f025b320cc3", + description: + "This action let user, who owns the dataset, to make it public", + order: 7, + label: "Publish", + type: "xhr", + mat_icon: "action", + method: "PATCH", + url: "http://localhost:3000/dataset/{{ @pid }}/", + target: "_blank", + enabled: "#datasetOwner && !@isPublished", + authorization: ["#datasetOwner && !@isPublished"], + variables: { + pid: "@Dataset0Pid", + isPublished: "#Dataset[0]Field[isPublished]", + }, + payload: '{"isPublished":"true"}', + headers: { + "Content-Type": "application/json", + Authorization: "#tokenBearer", + }, + }, + { + id: "94a1d694-a526-11f0-947b-038d53cd837a", + description: + "This action let user, who owns the dataset, to make it private", + order: 8, + label: "Unpublish", + type: "xhr", + mat_icon: "action", + method: "PATCH", + url: "http://localhost:3000/dataset/{{ @pid }}/", + target: "_blank", + enabled: "#datasetOwner && @isPublished", + authorization: ["#datasetOwner && @isPublished"], + variables: { + pid: "#Dataset0Pid", + isPublished: "#Dataset[0]Field[isPublished]", + }, + payload: '{"isPublished":"false"}', + headers: { + "Content-Type": "application/json", + Authorization: "#tokenBearer", + }, + }, +]; + +export const mockActionItems: ActionItems = { + datasets: [ + { + pid: "40f3beec-bee2-11f0-8c47-4b68a24470e0", + sourceFolder: "/source/folder/1", + ownerGroup: "group1", + isPublished: false, + files: [ + { + path: "/file/1", + size: 1000, + selected: true, + time: "2019-09-06T13:11:37.102Z", + }, + { + path: "/file/2", + size: 2000, + selected: false, + time: "2019-09-06T13:11:37.102Z", + }, + { + path: "/file/3", + size: 3000, + selected: true, + time: "2019-09-06T13:11:37.102Z", + }, + ], + }, + { + pid: "48217db2-bee2-11f0-ace4-b7a1618f0eba", + sourceFolder: "/source/folder/2", + ownerGroup: "group2", + isPublished: false, + files: [ + { + path: "/file/4", + size: 4000, + selected: true, + time: "2019-09-06T13:11:37.102Z", + }, + ], + }, + ], +}; + +/* + * selection should be the number which the file path ends with. It can be expressed as string or integer + */ +function filesSelection( + inDatasets: ActionItemDataset[], + selection: number[], +): ActionItemDataset[] { + const outDatasets: ActionItemDataset[] = structuredClone(inDatasets); + //console.log("Files selection 1",JSON.stringify(outDatasets)); + outDatasets.forEach((d) => { + d.files.forEach((f) => { + f.selected = selection.includes(Number(f.path.slice(-1))); + }); + return d; + }); + //console.log("Files selection 2",JSON.stringify(outDatasets)); + return outDatasets; +} + +export const mockActionItemsDatafilesNofiles = { + datasets: filesSelection(mockActionItems.datasets.slice(0, 1), []), +}; +export const mockActionItemsDatafilesFile1 = { + datasets: filesSelection(mockActionItems.datasets.slice(0, 1), [1]), +}; +export const mockActionItemsDatafilesFile2 = { + datasets: filesSelection(mockActionItems.datasets.slice(0, 1), [2]), +}; +export const mockActionItemsDatafilesAllfiles = { + datasets: filesSelection(mockActionItems.datasets.slice(0, 1), [1, 2, 3]), +}; + +export const lowerMaxFileSizeLimit = 5000; +export const higherMaxFileSizeLimit = 20000; +export enum maxSizeType { + lower = "lower", + higher = "higher", +} + +export enum selectedFilesType { + none = "none", + file1 = "file1", + file2 = "file2", + all = "all", +} + +export const mockUserProfiles = [ + {}, + { + accessGroups: ["group1", "group3"], + }, + { + accessGroups: ["group2", "group3"], + }, +]; 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 new file mode 100644 index 0000000000..9efa90e9f3 --- /dev/null +++ b/src/app/shared/modules/configurable-actions/doc/configurable-actions-configuration-reference-table.md @@ -0,0 +1,25 @@ +| 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) | + +--- +> **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 new file mode 100644 index 0000000000..bf59d6901f --- /dev/null +++ b/src/app/shared/modules/configurable-actions/doc/configurable-actions-non-technical.md @@ -0,0 +1,52 @@ +# Using Configurable Actions at ESS + +Configurable actions are special buttons in the ESS user interface that help you perform useful tasks with your datasets, such as downloading files, launching Jupyter notebooks, or publishing data. + +## What Are Configurable Actions? + +- **Action Buttons:** These appear in the data portal and other tools. +- **Purpose:** Each button lets you quickly do something—like download all your data, download selected files, or make your dataset public. +- **Smart Controls:** Actions may only appear or become clickable when you have permission or when it makes sense (e.g. if you have selected files). + +## What Can Actions Do? + +- **Download data** (all files or only selected files) +- **Generate notebooks** (for Jupyter or SciWyrm) +- **Create download links** +- **Change dataset status** (publish/unpublish) +- And more, as new options are added + +## Common Button Types + +| Icon / Label | What it does | +|-------------------------|---------------------------------------------| +| ![download](icon) | Download all or selected files as a zip | +| ![jupyter](icon) | Download or open a Jupyter notebook | +| "Publish" | Make your dataset publicly available | +| "Unpublish" | Make your dataset private again | +| "ESS" | Link to an ESS webpage | + +*(Note: The actual icon may look different in your UI.)* + +## Why Do Some Actions Appear Grayed Out or Hidden? + +An action button might be disabled (grayed out) or hidden if: +- You do not have necessary permissions (e.g. not the owner). +- The action isn't possible (e.g. no files selected). +- The dataset is already published or unpublished. + +Hover over a disabled button for hints about why it's not available. + +## Security and Permissions + +Some actions are only available to dataset owners or users in certain groups. If you think you should see an action but don't, contact the data team. + +## Need Help? + +For more info: +- See the [ESS Data Portal Help](https://essdata.ess.eu/help) *(internal)* +- Contact [dataportal@ess.eu](mailto:dataportal@ess.eu) + +--- + +> **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-technical.md b/src/app/shared/modules/configurable-actions/doc/configurable-actions-technical.md new file mode 100644 index 0000000000..f50e1dd8e7 --- /dev/null +++ b/src/app/shared/modules/configurable-actions/doc/configurable-actions-technical.md @@ -0,0 +1,183 @@ +# Configurable Action Component – Configuration Reference + +This document details configuration options for use with the ESS **ConfigurableActionComponent**. This component is designed to generate action buttons for user workflows (such as Download, Publish, etc.) in a flexible and extensible way. + +--- + +## Overview + +Each action button is configured by an object, usually placed in a list. This object controls logic, UI, access, and integration for the action. + +--- + +## Configuration Fields + +Below are supported configuration properties, their types, and descriptions. + +### 1. `id` +- **Type:** `string` +- **Description:** Unique identifier for the action. + +### 2. `order` +- **Type:** `number` +- **Description:** Determines the UI order (lower first). + +### 3. `label` +- **Type:** `string` +- **Description:** Display name for the action button. + +### 4. `description` +- **Type:** `string` +- **Description:** *Optional*. Additional details shown in tooltips or documentation. + +### 5. `type` +- **Type:** `string` +- **Accepted values:** `form`, `link`, `json-download`, `xhr` +- **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. + +### 6. `method` +- **Type:** `string` +- **Possible values:** HTTP verbs (`GET`, `POST`, `PATCH`, etc.) +- **Default:** `"POST"` if used +- **Description:** HTTP method used (for `form`, `xhr`, `json-download`). + +### 7. `url` +- **Type:** `string` +- **Description:** Destination URL for the action (may use template variables, e.g. `{{ @pid }}`). + +### 8. `target` +- **Type:** `string` +- **Default:** `"_self"` +- **Description:** Target browser window/tab (`_blank`, `_self`, etc.). + +### 9. `icon` +- **Type:** `string` +- **Description:** *Optional*. Path to an icon image for the button. + +### 10. `mat_icon` +- **Type:** `string` +- **Description:** *Optional*. Material icon name for Angular Material `mat-icon`. + +### 11. `files` +- **Type:** `"all"` | `"selected"` | `` +- **Description:** Indicates file context. Common values are `all` or `selected`. + +### 12. `enabled` +- **Type:** `string` +- **Description:** *Optional*. JS-style boolean expression for enabling the action. Example: `#Length(@files) && #MaxDownloadableSize(@totalSize)` + +### 13. `disabled` +- **Type:** `string` +- **Description:** *Optional*. Expression that disables the action. + +### 14. `hidden` +- **Type:** `string` +- **Description:** *Optional*. JS-style boolean expression for hiding the action. + +### 15. `authorization` +- **Type:** `string[]` +- **Description:** List of expressions; action is available only if these pass (e.g. `["#datasetOwner && !@isPublished"]`). + +### 16. `variables` +- **Type:** `object` +- **Format:** `{ key: selector | expression }` +- **Description:** Defines per-action variables, computed via selectors or expressions. Use with `@key` in templating. + + **Selector Examples:** + - `#Dataset0Pid`: Primary dataset PID + - `#Dataset0FilesPath`: File paths array + - See selector details below. + +### 17. `inputs` +- **Type:** `object` +- **Format:** `{ inputName: valueSelector | template }` +- **Description:** (For `"form"` actions) HTML input values sent with the form. + + - Names ending in `[]` create multiple inputs from an array value. + - Values can use selectors, variables, or static values. + +### 18. `headers` +- **Type:** `object` +- **Format:** `{ headerName: valueSelector | template }` +- **Description:** (For `xhr`/`json-download`) HTTP headers for the request. + +### 19. `payload` +- **Type:** `string` +- **Special:** `"#dump"` = all variables as JSON, `"#empty"` = empty body +- **Description:** Body for request (`xhr`/`json-download`). Templating with `{{ @pid }}`, `{{ #jwt }}` etc. supported. + +### 20. `filename` +- **Type:** `string` +- **Description:** *(json-download only)*. Name for downloaded file (can use template, eg: `{{ #uuid }}.ipynb`). + +--- + +## Supported Selectors in `variables` + +**From `processSelector` in the component:** +- `#Dataset0Pid`: First dataset's PID +- `#Dataset0FilesPath`: Array, all file paths in first dataset +- `#Dataset0FilesTotalSize`: Total size of all files in first dataset (bytes) +- `#Dataset0SourceFolder`: Source folder for first dataset +- `#Dataset0SelectedFilesPath`: Array, paths of selected files +- `#Dataset0SelectedFilesCount`: Number of selected files (first dataset) +- `#Dataset0SelectedFilesTotalSize`: Size of selected files total +- `#Dataset[n]Field[fieldName]`: Arbitrary field `fieldName` from dataset[n] +- `#DatasetsPid`: Array of PIDs, all datasets +- `#DatasetsFilesPath`: All file paths, all datasets +- `#DatasetsFilesTotalSize`: Total size of all datasets' files +- `#DatasetsSourceFolder`: Source folders for all datasets +- `#DatasetsSelectedFilesPath`: All selected file paths (all datasets) +- `#DatasetsSelectedFilesCount`: Total number selected files +- `#DatasetsSelectedFilesTotalSize`: Total selected files size +- `#DatasetsField[fieldName]`: Array; `fieldName` in all datasets + +**Other runtime keywords:** +- `#token`, `#tokenBearer`, `#jwt`, `#uuid`: Various tokens and a random UUID. +- `@`: Variable defined in `variables` mapping. + +--- + +## Expression and Templating + +- Expressions in `enabled`, `disabled`, `hidden`, `authorization` are JavaScript-like. +- Variables referenced as `@name` and selectors as `#SelectorName`. +- Templates in `url`, `payload`, `filename` use `{{ ... }}` to interpolate values (e.g. `{{ @pid }}`). + +--- + +## Example Configuration + +```json +{ + "id": "eed8efec-4354-11ef-a3b5-d75573a5d37f", + "description": "Lets users download all files", + "order": 1, + "label": "Download All", + "files": "all", + "mat_icon": "download", + "type": "form", + "url": "https://zip.scicatproject.org/download/all", + "target": "_blank", + "variables": { + "pid": "#Dataset0Pid", + "files": "#Dataset0FilesPath", + "totalSize": "#Dataset0FilesTotalSize", + "folder": "#Dataset0SourceFolder" + }, + "enabled": "#MaxDownloadableSize(@totalSize)", + "inputs": { + "dataset[]": "@pid", + "directory[]": "@folder", + "files[]": "@files" + }, + "authorization": ["#datasetAccess", "#datasetPublic"] +} +``` + +--- +> **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/dynamic-material-table/table/dynamic-mat-table.module.ts b/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.module.ts index 03ef12cb95..cc0abae980 100644 --- a/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.module.ts +++ b/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.module.ts @@ -25,7 +25,6 @@ import { TableVirtualScrollModule } from "../cores/table-virtual-scroll.module"; import { PrintTableDialogComponent } from "./extensions/print-dialog/print-dialog.component"; import { MatMenuModule } from "@angular/material/menu"; import { MatTooltipModule } from "@angular/material/tooltip"; -import { MatRippleModule } from "@angular/material/core"; import { TooltipComponent } from "../tooltip/tooltip.component"; import { FullscreenOverlayContainer, @@ -58,18 +57,17 @@ const ExtensionsModule = [HeaderFilterModule, RowMenuModule]; MatIconModule, DragDropModule, TableMenuModule, + MatDividerModule, MatPaginatorModule, MatDialogModule, MatButtonModule, MatCardModule, MatMenuModule, - MatDividerModule, - MatTooltipModule, - MatRippleModule, - OverlayModule, ExtensionsModule, PipesModule, EmptyContentModule, + OverlayModule, + MatTooltipModule, ], exports: [DynamicMatTableComponent], declarations: [ diff --git a/src/app/shared/modules/filters/clearable-input.component.ts b/src/app/shared/modules/filters/clearable-input.component.ts index 061306d6c4..6c62e7a5a5 100644 --- a/src/app/shared/modules/filters/clearable-input.component.ts +++ b/src/app/shared/modules/filters/clearable-input.component.ts @@ -3,7 +3,6 @@ import { Component, ElementRef, Input, ViewChild } from "@angular/core"; //TODO move to common @Component({ template: "", - standalone: false, }) export class ClearableInputComponent { @ViewChild("input", { static: true }) input!: ElementRef; diff --git a/src/app/shared/modules/filters/condition-filter.component.ts b/src/app/shared/modules/filters/condition-filter.component.ts new file mode 100644 index 0000000000..a11f7c8062 --- /dev/null +++ b/src/app/shared/modules/filters/condition-filter.component.ts @@ -0,0 +1,43 @@ +import { Component, Input } from "@angular/core"; +import { Store } from "@ngrx/store"; +import { ScientificCondition } from "state-management/models"; + +@Component({ + selector: "app-condition-filter", + templateUrl: "condition-filter.component.html", + styleUrls: ["condition-filter.component.scss"], + standalone: false, +}) +export class ConditionFilterComponent { + @Input() condition: ScientificCondition; + + constructor(private store: Store) {} + + formatCondition() { + const condition = this.condition; + let relationSymbol = ""; + switch (condition.relation) { + case "EQUAL_TO_NUMERIC": + case "EQUAL_TO_STRING": + relationSymbol = "="; + break; + case "LESS_THAN": + relationSymbol = "<"; + break; + case "GREATER_THAN": + relationSymbol = ">"; + break; + default: + relationSymbol = ""; + } + + const rhsValue = + condition.relation === "EQUAL_TO_STRING" + ? `"${condition.rhs}"` + : condition.rhs; + + const unit = condition.unit || ""; + + return `${condition.lhs} ${relationSymbol} ${rhsValue} ${unit}`; + } +} diff --git a/src/app/shared/modules/filters/date-range-filter.component.ts b/src/app/shared/modules/filters/date-range-filter.component.ts new file mode 100644 index 0000000000..762629de98 --- /dev/null +++ b/src/app/shared/modules/filters/date-range-filter.component.ts @@ -0,0 +1,76 @@ +import { Component, Input } from "@angular/core"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { MatDatepickerInputEvent } from "@angular/material/datepicker"; +import { DateTime } from "luxon"; +import { setDateRangeFilterAction } from "state-management/actions/datasets.actions"; +import { selectCreationTimeFilter } from "state-management/selectors/datasets.selectors"; +import { Store } from "@ngrx/store"; +import { FilterComponentInterface } from "./interface/filter-component.interface"; +import { AppConfigService } from "app-config.service"; +import { getFilterLabel } from "./utils"; + +interface DateRange { + begin: string; + end: string; +} + +@Component({ + selector: "app-date-range-filter", + templateUrl: "date-range-filter.component.html", + styleUrls: ["date-range-filter.component.scss"], + standalone: false, +}) +export class DateRangeFilterComponent + extends ClearableInputComponent + implements FilterComponentInterface +{ + readonly componentName: string = "DateRangeFilter"; + readonly label: string = "Start Date - End Date"; + readonly tooltipText: string = + "Filters datasets by creation date, within the specified range"; + + appConfig = this.appConfigService.getConfig(); + creationTimeFilter$ = this.store.select(selectCreationTimeFilter); + + dateRange: DateRange = { + begin: "", + end: "", + }; + + constructor( + private store: Store, + private appConfigService: AppConfigService, + ) { + super(); + + const filters = this.appConfig.labelMaps?.filters; + this.label = getFilterLabel(filters, this.componentName, this.label); + } + + dateChanged(event: MatDatepickerInputEvent) { + if (event.value) { + const name = event.targetElement.getAttribute("name"); + if (name === "begin") { + this.dateRange.begin = event.value.toUTC().toISO(); + this.dateRange.end = ""; + } + if (name === "end") { + this.dateRange.end = event.value.toUTC().plus({ days: 1 }).toISO(); + } + if (this.dateRange.begin.length > 0 && this.dateRange.end.length > 0) { + this.store.dispatch(setDateRangeFilterAction(this.dateRange)); + } + } else { + this.store.dispatch(setDateRangeFilterAction({ begin: "", end: "" })); + } + } + + @Input() + set clear(value: boolean) { + if (value) + this.dateRange = { + begin: "", + end: "", + }; + } +} diff --git a/src/app/shared/modules/filters/group-filter.component.ts b/src/app/shared/modules/filters/group-filter.component.ts new file mode 100644 index 0000000000..9d6efd459a --- /dev/null +++ b/src/app/shared/modules/filters/group-filter.component.ts @@ -0,0 +1,88 @@ +import { Component } from "@angular/core"; +import { + selectGroupFacetCounts, + selectGroupFilter, +} from "state-management/selectors/datasets.selectors"; +import { Store } from "@ngrx/store"; +import { + addGroupFilterAction, + removeGroupFilterAction, +} from "state-management/actions/datasets.actions"; +import { + createSuggestionObserver, + getFacetCount, + getFacetId, + getFilterLabel, +} from "./utils"; +import { BehaviorSubject } from "rxjs"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { FilterComponentInterface } from "./interface/filter-component.interface"; +import { AppConfigService } from "app-config.service"; + +@Component({ + selector: "app-group-filter", + templateUrl: "group-filter.component.html", + styleUrls: ["group-filter.component.scss"], + standalone: false, +}) +export class GroupFilterComponent + extends ClearableInputComponent + implements FilterComponentInterface +{ + protected readonly getFacetId = getFacetId; + protected readonly getFacetCount = getFacetCount; + readonly componentName: string = "GroupFilter"; + readonly label: string = "Group Filter"; + readonly tooltipText: string = "Filters datasets by group"; + + appConfig = this.appConfigService.getConfig(); + + groupFilter$ = this.store.select(selectGroupFilter); + + groupFacetCounts$ = this.store.select(selectGroupFacetCounts); + groupInput$ = new BehaviorSubject(""); + typedGroup = ""; + + groupSuggestions$ = createSuggestionObserver( + this.groupFacetCounts$, + this.groupInput$, + this.groupFilter$, + ); + + constructor( + private store: Store, + private appConfigService: AppConfigService, + ) { + super(); + + const filters = this.appConfig.labelMaps?.filters; + this.label = getFilterLabel(filters, this.componentName, this.label); + } + + onGroupInput(event: any) { + const value = (event.target).value; + this.groupInput$.next(value); + this.typedGroup = value.trim(); + } + groupSelected(group: string) { + this.store.dispatch(addGroupFilterAction({ group })); + this.groupInput$.next(""); + this.typedGroup = ""; + } + + groupRemoved(group: string) { + this.store.dispatch(removeGroupFilterAction({ group })); + } + + onGroupKeyEnter() { + if (this.typedGroup) { + this.groupSelected(this.typedGroup); + } + } + + onGroupBlur() { + if (this.typedGroup) { + this.groupSelected(this.typedGroup); + } + } +} diff --git a/src/app/shared/modules/filters/keyword-filter.component.ts b/src/app/shared/modules/filters/keyword-filter.component.ts new file mode 100644 index 0000000000..ada1f764e2 --- /dev/null +++ b/src/app/shared/modules/filters/keyword-filter.component.ts @@ -0,0 +1,107 @@ +import { Component, OnDestroy } from "@angular/core"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { + createSuggestionObserver, + getFacetCount, + getFacetId, + getFilterLabel, +} from "./utils"; +import { + selectKeywordFacetCounts, + selectKeywordsFilter, + selectKeywordsTerms, +} from "state-management/selectors/datasets.selectors"; +import { Store } from "@ngrx/store"; +import { BehaviorSubject } from "rxjs"; +import { + addKeywordFilterAction, + removeKeywordFilterAction, +} from "state-management/actions/datasets.actions"; +import { debounceTime, distinctUntilChanged, skipWhile } from "rxjs/operators"; +import { AppConfigService } from "app-config.service"; + +@Component({ + selector: "app-keyword-filter", + templateUrl: "keyword-filter.component.html", + styleUrls: ["keyword-filter.component.scss"], + standalone: false, +}) +export class KeywordFilterComponent + extends ClearableInputComponent + implements OnDestroy +{ + protected readonly getFacetCount = getFacetCount; + protected readonly getFacetId = getFacetId; + readonly componentName: string = "KeywordFilter"; + readonly label: string = "Keyword Filter"; + readonly tooltipText: string = "Filters datasets by keyword"; + + appConfig = this.appConfigService.getConfig(); + + keywordsTerms$ = this.store.select(selectKeywordsTerms); + + keywordsFilter$ = this.store.select(selectKeywordsFilter); + + keywordsInput$ = new BehaviorSubject(""); + keywordFacetCounts$ = this.store.select(selectKeywordFacetCounts); + + subscription = undefined; + typedKeyword = ""; + + keywordsSuggestions$ = createSuggestionObserver( + this.keywordFacetCounts$, + this.keywordsInput$, + this.keywordsFilter$, + ); + + constructor( + private store: Store, + private appConfigService: AppConfigService, + ) { + super(); + + const filters = this.appConfig.labelMaps?.filters; + this.label = getFilterLabel(filters, this.componentName, this.label); + this.subscription = this.keywordsTerms$ + .pipe( + skipWhile((terms) => terms === ""), + debounceTime(500), + distinctUntilChanged(), + ) + .subscribe((terms) => { + this.store.dispatch(addKeywordFilterAction({ keyword: terms })); + }); + } + + onKeywordInput(event: any) { + const value = (event.target).value; + this.keywordsInput$.next(value); + this.typedKeyword = value.trim(); + } + + keywordSelected(keyword: string) { + this.store.dispatch(addKeywordFilterAction({ keyword })); + this.keywordsInput$.next(""); + this.typedKeyword = ""; + } + + keywordRemoved(keyword: string) { + this.store.dispatch(removeKeywordFilterAction({ keyword })); + } + + onKeywordKeyEnter() { + if (this.typedKeyword) { + this.keywordSelected(this.typedKeyword); + } + } + + onKeywordBlur() { + if (this.typedKeyword) { + this.keywordSelected(this.typedKeyword); + } + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } +} diff --git a/src/app/shared/modules/filters/location-filter.component.ts b/src/app/shared/modules/filters/location-filter.component.ts new file mode 100644 index 0000000000..097c515c74 --- /dev/null +++ b/src/app/shared/modules/filters/location-filter.component.ts @@ -0,0 +1,91 @@ +import { Component } from "@angular/core"; +import { + selectLocationFacetCounts, + selectLocationFilter, +} from "state-management/selectors/datasets.selectors"; +import { + createSuggestionObserver, + getFacetCount, + getFacetId, + getFilterLabel, +} from "./utils"; +import { BehaviorSubject } from "rxjs"; +import { + addLocationFilterAction, + removeLocationFilterAction, +} from "state-management/actions/datasets.actions"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { Store } from "@ngrx/store"; +import { FilterComponentInterface } from "./interface/filter-component.interface"; +import { AppConfigService } from "app-config.service"; + +@Component({ + selector: "app-location-filter", + templateUrl: "location-filter.component.html", + styleUrls: ["location-filter.component.scss"], + standalone: false, +}) +export class LocationFilterComponent + extends ClearableInputComponent + implements FilterComponentInterface +{ + protected readonly getFacetId = getFacetId; + protected readonly getFacetCount = getFacetCount; + readonly componentName: string = "LocationFilter"; + readonly label: string = "Location Filter"; + readonly tooltipText: string = "Filters datasets by location"; + + appConfig = this.appConfigService.getConfig(); + + locationFacetCounts$ = this.store.select(selectLocationFacetCounts); + locationFilter$ = this.store.select(selectLocationFilter); + + locationInput$ = new BehaviorSubject(""); + + typedLocation = ""; + + locationSuggestions$ = createSuggestionObserver( + this.locationFacetCounts$, + this.locationInput$, + this.locationFilter$, + ); + + constructor( + private store: Store, + public appConfigService: AppConfigService, + ) { + super(); + + const filters = this.appConfig.labelMaps?.filters; + this.label = getFilterLabel(filters, this.componentName, this.label); + } + + locationSelected(location: string | null) { + const loc = location || ""; + this.store.dispatch(addLocationFilterAction({ location: loc })); + this.locationInput$.next(""); + this.typedLocation = ""; + } + + locationRemoved(location: string) { + this.store.dispatch(removeLocationFilterAction({ location })); + } + + onLocationInput(event: any) { + const value = (event.target).value; + this.locationInput$.next(value); + this.typedLocation = value.trim(); + } + + onLocationKeyEnter() { + if (this.typedLocation) { + this.locationSelected(this.typedLocation); + } + } + + onLocationBlur() { + if (this.typedLocation) { + this.locationSelected(this.typedLocation); + } + } +} diff --git a/src/app/shared/modules/filters/pid-filter-contains.component.ts b/src/app/shared/modules/filters/pid-filter-contains.component.ts new file mode 100644 index 0000000000..cfad3363a7 --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-contains.component.ts @@ -0,0 +1,18 @@ +import { PidFilterComponent } from "./pid-filter.component"; +import { Component } from "@angular/core"; + +@Component({ + selector: "app-pid-contains-filter", + templateUrl: "./pid-filter-contains.component.html", + styleUrls: ["./pid-filter-contains.component.scss"], + standalone: false, +}) +export class PidFilterContainsComponent extends PidFilterComponent { + readonly componentName: string = "PidFilterContains"; + readonly label: string = "PID filter (Contains)- Not implemented"; + readonly tooltipText: string = "Not implemented"; + + buildPidTermsCondition(terms: string): { $regex: string } { + return { $regex: terms }; + } +} diff --git a/src/app/shared/modules/filters/pid-filter-startsWith.component.ts b/src/app/shared/modules/filters/pid-filter-startsWith.component.ts new file mode 100644 index 0000000000..6b56f55172 --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter-startsWith.component.ts @@ -0,0 +1,18 @@ +import { PidFilterComponent } from "./pid-filter.component"; +import { Component } from "@angular/core"; + +@Component({ + selector: "app-pid-startsWith-filter", + templateUrl: "./pid-filter-startsWith.component.html", + styleUrls: ["./pid-filter-startsWith.component.scss"], + standalone: false, +}) +export class PidFilterStartsWithComponent extends PidFilterComponent { + readonly componentName: string = "PidFilterStartsWith"; + readonly label: string = "PID filter (Starts With)- Not implemented"; + readonly tooltipText: string = "Not implemented"; + + buildPidTermsCondition(terms: string): { $regex: string } { + return { $regex: `^${terms}` }; + } +} diff --git a/src/app/shared/modules/filters/pid-filter.component.ts b/src/app/shared/modules/filters/pid-filter.component.ts new file mode 100644 index 0000000000..7d68a00b0b --- /dev/null +++ b/src/app/shared/modules/filters/pid-filter.component.ts @@ -0,0 +1,69 @@ +import { Component, Input, OnDestroy } from "@angular/core"; +import { Store } from "@ngrx/store"; +import { Subject, Subscription } from "rxjs"; +import { + setPidTermsAction, + setPidTermsFilterAction, +} from "state-management/actions/datasets.actions"; +import { debounceTime } from "rxjs/operators"; +import { AppConfigService } from "app-config.service"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { FilterComponentInterface } from "./interface/filter-component.interface"; +import { getFilterLabel } from "./utils"; + +@Component({ + selector: "app-pid-filter", + templateUrl: `./pid-filter.component.html`, + styleUrls: [`./pid-filter.component.scss`], + standalone: false, +}) +export class PidFilterComponent + extends ClearableInputComponent + implements FilterComponentInterface, OnDestroy +{ + private pidSubject = new Subject(); + private subscription: Subscription; + readonly componentName: string = "PidFilter"; + readonly label: string = "Pid Filter"; + readonly tooltipText: string = "Search by dataset's Persistent Identifier"; + + appConfig = this.appConfigService.getConfig(); + + constructor( + public appConfigService: AppConfigService, + private store: Store, + ) { + super(); + + const filters = this.appConfig.labelMaps?.filters; + this.label = getFilterLabel(filters, this.componentName, this.label); + this.subscription = this.pidSubject + .pipe(debounceTime(500)) + .subscribe((pid) => { + const condition = !pid ? "" : this.buildPidTermsCondition(pid); + this.store.dispatch(setPidTermsFilterAction({ pid: condition })); + }); + } + + buildPidTermsCondition(terms: string): string | { $regex: string } { + return terms; + } + + ngOnDestroy() { + // Unsubscribe to avoid memory leaks + this.subscription.unsubscribe(); + this.pidSubject.complete(); + } + + onPidInput(event: any) { + const pid = (event.target as HTMLInputElement).value; + this.pidSubject.next(pid); + } + + @Input() + set clear(value: boolean) { + super.clear = value; + + if (value) this.store.dispatch(setPidTermsAction({ pid: "" })); + } +} diff --git a/src/app/shared/modules/filters/text-filter.component.ts b/src/app/shared/modules/filters/text-filter.component.ts new file mode 100644 index 0000000000..dda88fd15a --- /dev/null +++ b/src/app/shared/modules/filters/text-filter.component.ts @@ -0,0 +1,57 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { Store } from "@ngrx/store"; +import { setTextFilterAction } from "state-management/actions/datasets.actions"; +import { debounceTime, distinctUntilChanged, skipWhile } from "rxjs/operators"; +import { Subject, Subscription } from "rxjs"; +import { AppConfigService } from "app-config.service"; +import { FilterComponentInterface } from "./interface/filter-component.interface"; +import { getFilterLabel } from "./utils"; + +@Component({ + selector: "app-text-filter", + templateUrl: "text-filter.component.html", + styleUrls: ["text-filter.component.scss"], + standalone: false, +}) +export class TextFilterComponent + extends ClearableInputComponent + implements FilterComponentInterface, OnDestroy +{ + private textSubject = new Subject(); + readonly componentName: string = "TextFilter"; + readonly label: string = "Text Filter"; + readonly tooltipText: string = "Search across dataset name and description"; + + appConfig = this.appConfigService.getConfig(); + subscription: Subscription; + + constructor( + private store: Store, + public appConfigService: AppConfigService, + ) { + super(); + + const filters = this.appConfig.labelMaps?.filters; + this.label = getFilterLabel(filters, this.componentName, this.label); + this.subscription = this.textSubject + .pipe( + skipWhile((terms) => terms === ""), + debounceTime(200), + distinctUntilChanged(), + ) + .subscribe((terms) => { + this.store.dispatch(setTextFilterAction({ text: terms })); + }); + } + + textSearchChanged(event: any) { + const pid = (event.target as HTMLInputElement).value; + this.textSubject.next(pid); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + this.textSubject.complete(); + } +} diff --git a/src/app/shared/modules/filters/type-filter.component.ts b/src/app/shared/modules/filters/type-filter.component.ts new file mode 100644 index 0000000000..a8a123f417 --- /dev/null +++ b/src/app/shared/modules/filters/type-filter.component.ts @@ -0,0 +1,88 @@ +import { Component } from "@angular/core"; +import { ClearableInputComponent } from "./clearable-input.component"; +import { + createSuggestionObserver, + getFacetCount, + getFacetId, + getFilterLabel, +} from "./utils"; +import { + selectTypeFacetCounts, + selectTypeFilter, +} from "state-management/selectors/datasets.selectors"; +import { Store } from "@ngrx/store"; +import { BehaviorSubject } from "rxjs"; +import { + addTypeFilterAction, + removeTypeFilterAction, +} from "state-management/actions/datasets.actions"; +import { AppConfigService } from "app-config.service"; +import { FilterComponentInterface } from "./interface/filter-component.interface"; + +@Component({ + selector: "app-type-filter", + templateUrl: "type-filter.component.html", + styleUrls: ["type-filter.component.scss"], + standalone: false, +}) +export class TypeFilterComponent + extends ClearableInputComponent + implements FilterComponentInterface +{ + protected readonly getFacetCount = getFacetCount; + protected readonly getFacetId = getFacetId; + readonly componentName: string = "TypeFilter"; + readonly label: string = "Type Filter"; + readonly tooltipText: string = "Filters datasets by type"; + + appConfig = this.appConfigService.getConfig(); + + typeFacetCounts$ = this.store.select(selectTypeFacetCounts); + + typeFilter$ = this.store.select(selectTypeFilter); + typeInput$ = new BehaviorSubject(""); + typedType = ""; + + typeSuggestions$ = createSuggestionObserver( + this.typeFacetCounts$, + this.typeInput$, + this.typeFilter$, + ); + + constructor( + private store: Store, + public appConfigService: AppConfigService, + ) { + super(); + + const filters = this.appConfig.labelMaps?.filters; + this.label = getFilterLabel(filters, this.componentName, this.label); + } + onTypeInput(event: any) { + const value = (event.target).value; + this.typeInput$.next(value); + this.typedType = value.trim(); + } + + typeSelected(type: string) { + this.store.dispatch(addTypeFilterAction({ datasetType: type })); + this.typeInput$.next(""); + this.typedType = ""; + } + + typeRemoved(type: string) { + this.store.dispatch(removeTypeFilterAction({ datasetType: type })); + } + + onTypeKeyEnter() { + if (this.typedType) { + this.typeSelected(this.typedType); + } + } + + onTypeBlur() { + if (this.typedType) { + this.typeSelected(this.typedType); + } + } +} diff --git a/src/app/shared/services/route-tracker.service.spec.ts b/src/app/shared/services/route-tracker.service.spec.ts index 4df0180f03..db490341cb 100644 --- a/src/app/shared/services/route-tracker.service.spec.ts +++ b/src/app/shared/services/route-tracker.service.spec.ts @@ -4,9 +4,7 @@ import { RouteTrackerService } from "./route-tracker.service"; import { provideRouter, Router } from "@angular/router"; import { Component } from "@angular/core"; -@Component({ - standalone: false, -}) +@Component({}) class DummyComponent {} describe("RouteTrackerService", () => { diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index c034ed8636..5cd87ee555 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -23,9 +23,11 @@ import { JsonFormsAngularMaterialModule } from "@jsonforms/angular-material"; import { JsonFormsCustomRenderersModule } from "./modules/jsonforms-custom-renderers/jsonforms-custom-renderers.module"; import { FullTextSearchBarModule } from "./modules/full-text-search-bar/full-text-search-bar.module"; import { SharedFilterModule } from "./modules/shared-filter/shared-filter.module"; +import { ConfigurableActionsModule } from "./modules/configurable-actions/configurable-actions.module"; import { NgxNumericRangeFormFieldModule } from "./modules/numeric-range/ngx-numeric-range-form-field.module"; import { EmptyContentModule } from "./modules/generic-empty-content/empty-content.module"; import { JsonformsAccordionRendererService } from "./services/jsonforms-accordion-renderer.service"; +import { TranslateModule } from "@ngx-translate/core"; @NgModule({ imports: [ BreadcrumbModule, @@ -45,6 +47,8 @@ import { JsonformsAccordionRendererService } from "./services/jsonforms-accordio SharedTableModule, ScientificMetadataTreeModule, DynamicMatTableModule.forRoot({}), + TranslateModule, + ConfigurableActionsModule, NgxNumericRangeFormFieldModule, EmptyContentModule, JsonFormsModule, diff --git a/src/assets/config.json b/src/assets/config.json index 8c370fd4ee..3c37493b3f 100644 --- a/src/assets/config.json +++ b/src/assets/config.json @@ -27,7 +27,6 @@ "ingestManual": null, "jobsEnabled": true, "jsonMetadataEnabled": true, - "jupyterHubUrl": "https://jupyterhub.esss.lu.se/", "landingPage": "doi.ess.eu/detail/", "lbBaseURL": "http://127.0.0.1:3000", "ingestorComponent": { @@ -75,45 +74,110 @@ { "id": "eed8efec-4354-11ef-a3b5-d75573a5d37f", "description": "This action let users download all files using the zip service", - "order": 5, + "order": 1, "label": "Download All", "files": "all", "mat_icon": "download", "type": "form", "url": "https://zip.scicatproject.org/download/all", "target": "_blank", - "enabled": "#SizeLimit", + "variables" : { + "pid": "#Dataset0Pid", + "files": "#Dataset0FilesPath", + "totalSize": "#Dataset0FilesTotalSize", + "folder": "#Dataset0SourceFolder" + }, + "enabled": "#MaxDownloadableSize(@totalSize)", + "inputs" : { + "item[]" : "@pid", + "directory[]" : "@folder", + "files[]": "@files" + }, "authorization": ["#datasetAccess", "#datasetPublic"] }, { "id": "3072fafc-4363-11ef-b9f9-ebf568222d26", "description": "This action let users download selected files using the zip service", - "order": 4, + "order": 2, "label": "Download Selected", "files": "selected", "mat_icon": "download", "type": "form", "url": "https://zip.scicatproject.org/download/selected", "target": "_blank", - "enabled": "#Selected && #SizeLimit", + "variables" : { + "pid": "#Dataset0Pid", + "files": "#Dataset0SelectedFilesPath", + "selected": "#Dataset0SelectedFilesCount", + "totalSize": "#Dataset0SelectedFilesTotalSize", + "folder": "#Dataset0SourceFolder" + }, + "inputs" : { + "auth_token" : "#tokenBearer", + "jwt" : "#jwt", + "item[]" : "@pid", + "directory[]" : "@folder", + "files[]": "@files" + }, + "enabled": "#Length(@files) && #MaxDownloadableSize(@totalSize)", "authorization": ["#datasetAccess", "#datasetPublic"] }, { "id": "4f974f0e-4364-11ef-9c63-03d19f813f4e", "description": "This action let users download jupyter notebook properly populated with dataset pid and all files using an instance of sciwyrm", - "order": 2, + "order": 3, "label": "Notebook All (Form)", "files": "all", "icon": "/assets/icons/jupyter_logo.png", "type": "form", "url": "https://www.scicat.info/notebook/all", "target": "_blank", + "variables" : { + "pid": "#Dataset0Pid", + "files": "#Dataset0FilesPath", + "totalSize": "#Dataset0FilesTotalSize", + "folder": "#Dataset0SourceFolder" + }, + "enabled": "", + "inputs" : { + "auth_token" : "#token", + "jwt" : "#jwt", + "item[]" : "@pid", + "directory[]" : "@folder", + "files[]": "@files" + }, + "authorization": ["#datasetAccess", "#datasetPublic"] + }, + { + "id": "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", + "order": 4, + "label": "Notebook Selected", + "files": "selected", + "icon": "/assets/icons/jupyter_logo.png", + "type": "form", + "url": "https://www.scicat.info/notebook/selected", + "target": "_blank", + "variables" : { + "pid": "#Dataset0Pid", + "files": "#Dataset0SelectedFilesPath", + "selected": "#Dataset0SelectedFiles", + "totalSize": "#Dataset0SelectedFilesTotalSize", + "folder": "#Dataset0SourceFolder" + }, + "inputs" : { + "auth_token" : "#token", + "jwt" : "#jwt", + "item[]" : "@pid", + "directory[]" : "@folder", + "files[]": "@files" + }, + "enabled": "@selected", "authorization": ["#datasetAccess", "#datasetPublic"] }, { "id": "0cd5b592-0b1a-11f0-a42c-23e177127ee7", - "description": "This action let users download jupyter notebook properly populated with dataset pid and selected files using an instance of sciwyrm", - "order": 3, + "description": "This action let users download jupyter notebook properly populated with dataset pid and all files using an instance of sciwyrm", + "order": 5, "label": "Notebook All (Download JSON)", "files": "all", "type": "json-download", @@ -121,22 +185,104 @@ "url": "https://www.sciwyrm.info/notebook", "target": "_blank", "authorization": ["#datasetAccess", "#datasetPublic"], - "payload": "{\"template_id\":\"c975455e-ede3-11ef-94fb-138c9cd51fc0\",\"parameters\":{\"dataset\":\"{{ datasetPid }}\",\"directory\":\"{{ sourceFolder }}\",\"files\": {{ filesPath }},\"jwt\":\"{{ jwt }}\",\"scicat_url\":\"https://staging.scicat.ess.url\",\"file_server_url\":\"sftserver2.esss.dk\",\"file_server_port\":\"22\"}}", - "filename": "{{ uuid }}.ipynb" + "variables" : { + "pid": "#Dataset0Pid", + "files": "#Dataset0FilesPath", + "folder": "#Dataset0SourceFolder" + }, + "payload": "{\"template_id\":\"c975455e-ede3-11ef-94fb-138c9cd51fc0\",\"parameters\":{\"dataset\":\"{{ @pid }}\",\"directory\":\"{{ @folder }}\",\"files\": {{ @files[] }},\"jwt\":\"{{ #jwt }}\",\"scicat_url\":\"https://staging.scicat.ess.url\",\"file_server_url\":\"sftserver2.esss.dk\",\"file_server_port\":\"22\"}}", + "filename": "{{ #uuid }}.ipynb" }, { - "id": "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", - "order": 1, - "label": "Notebook Selected", - "files": "selected", + "id": "a414773a-a526-11f0-a7f2-ff1026e5dba9", + "description": "This action let users download jupyter notebook properly populated with dataset pid and selected files using an instance of sciwyrm", + "order": 6, + "label": "Notebook Selected (Download JSON)", + "type": "json-to-download", "icon": "/assets/icons/jupyter_logo.png", - "type": "form", - "url": "https://www.scicat.info/notebook/selected", + "url": "https://www.sciwyrm.info/notebook", "target": "_blank", - "enabled": "#Selected", - "authorization": ["#datasetAccess", "#datasetPublic"] + "enabled": "@selected > 0", + "authorization": ["#datasetAccess", "#datasetPublic"], + "variables" : { + "pid": "#Dataset0Pid", + "files": "#Dataset0SelectedFilesPath", + "selected": "#Dataset0SelectedFiles", + "folder": "#Dataset0SourceFolder" + }, + "payload": "{\"template_id\":\"c975455e-ede3-11ef-94fb-138c9cd51fc0\",\"parameters\":{\"dataset\":\"{{ pid }}\",\"directory\":\"{{ sourceFolder }}\",\"files\": {{ files }},\"jwt\":\"{{ jwt }}\",\"scicat_url\":\"https://staging.scicat.ess.url\",\"file_server_url\":\"sftserver2.esss.dk\",\"file_server_port\":\"22\"}}", + "filename": "{{ uuid }}.ipynb" + }, + { + "id": "9c6a11b6-a526-11f0-8795-6f025b320cc3", + "description": "This action let users make a call an arbitrary URL and store the reply in the store", + "order": 7, + "label": "Publish", + "type": "xhr", + "mat_icon": "action", + "method" : "PATCH", + "url": "http://localhost:3000/dataset/{{ @pid }}/", + "target": "_blank", + "enabled": "#datasetOwner && @isPublished", + "authorization": "#datasetOwner && !@isPublished", + "variables" : { + "pid": "@Dataset0Pid", + "isPublished" : "#Dataset[0]Field[isPublished]" + }, + "payload": "{\"isPublished\":\"true\"}", + "headers": { + "Content-Type": "application/json", + "Authorization": "#tokenBearer" + } + }, + { + "id": "94a1d694-a526-11f0-947b-038d53cd837a", + "description": "This action let users make a call an arbitrary URL and store the reply in the store", + "order": 8, + "label": "Unpublish", + "type": "xhr", + "mat_icon": "action", + "method" : "PATCH", + "url": "http://localhost:3000/dataset/{{ @pid }}/", + "target": "_blank", + "enabled": "#datasetOwner && !@isPublished", + "authorization": "#datasetOwner && @isPublished", + "variables" : { + "pid": "#Dataset0Pid", + "isPublished" : "#Dataset[0]Field[isPublished]" + }, + "payload": "{\"isPublished\":\"false\"}", + "headers": { + "Content-Type": "application/json", + "Authorization": "#tokenBearer" + } + }, + { + "id": "c3bcbd40-a526-11f0-915a-93eeff0860ab", + "description": "This action let users jump to another URL entirely", + "order": 9, + "label": "ESS", + "type": "link", + "mat_icon": "action", + "url": "https://ess.eu", + "target": "_blank" } ], + "datasetDetailsActionsEnabled": false, + "datasetDetailsActions": [], + "selectionActionsEnabled": true, + "selectionActions": [], + "labelMaps": { + "filters": { + "LocationFilter": "Location", + "PidFilter": "Pid", + "GroupFilter": "Group", + "TypeFilter": "Type", + "KeywordFilter": "Keyword", + "DateRangeFilter": "Start Date - End Date", + "TextFilter": "Text" + } + }, "defaultDatasetsListSettings": { "columns": [ {