diff --git a/docs/configuration/datafiles-actions.md b/docs/configuration/datafiles-actions.md new file mode 100644 index 000000000..4aa75b3cd --- /dev/null +++ b/docs/configuration/datafiles-actions.md @@ -0,0 +1,201 @@ +--- +title: Datafiles Action Configuration +created_by: Max Novelli +created_on: 2024/07/29 +--- +# Datafiles Action Configuration + +This page describes how to configure the datafiles actions. +They are action available to users when vieweing the list of files associated to a dataset which can be viewed under the the Datafiles tab of the dataset details page. +This actions are shown as button between the page header and the table listing the files. + +There are two properties in the frontend configuration structure that control the datafiles actions: +- __datafilesActionsEnabled__ + _Type: boolean_ + This property enables the action sfeature on the datafiles page. +- __datafilesActions__ + _Type: array of ActionConfig_ + This property contains an array of action definition. Each element defines an individual action that is rendered as button. Each button can have a label or an icon or both. +- __maxDirectDownloadSize__ + _Type: numeric_ + This property specify the maximum that total size of the files included in any datafiles action can reach. The quantity is expressed in bytes + + +The type _ActionConfig_ define how the button triggring the action is rendered and what the action does. +The structure of _ActionConfig_ is the following: +- __id__ + _Type: string_ + This is a unique id for this action. It does not have any effect on the rendering nor the action. It is included for traceability, management and debugging purposes. +- __order__ + _Type: number_ + This property indicates the order in which this action will be rendered in comparison to the others. +- __label__ + _Type: string_ + This property provides the label rendered in the button. If a label is not needed, leave it empty. +- __files__ + _Type: string_ + This property indicates which files should be submitted with the requested action. + Currently on the following two values are accepted: + - _all_: all the files will be submitted with the action + - _selected_: only the selected files will be submitted with the action +- __mat_icon__ + _Type: string_ + _Optional_ + If specified, this is the name of the mat icon that shouls be rendered in the button. + If it is not present, no mat icon is shown in the button. + This icon takes precedence over the property _icon_ explained next. +- __icon__ + _Type: string_ + _Optional_ + If specified, this is the relative path to the icon file to be shown in the related button. It is shown only if property _mat_icon_ is not defined. + If not present, no icon is shown. +- __url__ + _Type: string_ + This specify the URL where the POST submission should be send. +- __target__ + _Type: string_ + Select what is the behaviour when the action is triggered, as if it should reload th epage, open a different tab or browser window. + For more information, pleae refer to the official HTML form documentation available at this URL: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#target +- __method__ + _Type: string_ + _Optional_ + _Default: POST_ + Underlying form submission method. + It is strongly suggested to leaving undefined and use the default which is POST. + If the action uses a different one, please refer to the official HTML form documentation available at this URL https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#method +- __enabled__ + _Type: string_ + _Optional_ + This property specify the condition when the action is enabled. It takes precedence over the following property _disabled_. It may can contains the following two keywords alone or combined in a logical expression. + - #SizeLimit: true if the total size of the files that will be used in the action is less or equal to the size specified in the configuration property _maxDirectDownloadSize_ + - #Selected: true if any file has been selected in the list. +- __disabled__ + _Type: string_ + _Optional_ + This property specify when the action is disabled. It is used only if the property _enabled_ is not define. Please check property _enabled_ for the possible values. +- __authorization__ + _Type: string[]_ + _Optional_ + __IMPORTANT__: This value is for future use and not yet implemented. + The intented use is to be able to enable/disable the button based on the groups the user belongs to. + +### Behavior +When the action is triggered by a click on the rendered button, a call of the defined tpye, aka POST or GET, is submitted to the URL provided together with the appropriate list of files. +The action acts like a form submission with all the following arguments: +- target: as defined in the action +- method: as defined in the action or POST if not +- action: set as th eurl provided in the action +and inputs: +- auth_token: user current token id +- jwt: a jwt token defined for the user +- dataset: dataset pid of the dataset shown +- directory: dataset source folder +- files[]: array of the selected files if action property _files_ is set to _selected_, or all files if action property _files_ is set to _all_ + + +### Examples +This example define the following 4 type of actions: +1. Download All files + It renders a button with the download icon and label _Download All_. When clicked, the action will send a POST request to the url https://download.scicat.org with the list of all files associated with the dataset. It is enabled only when the total size is lower than the max size defined in configuration. +2. Download Selected Files + It renders a button with the download icon and label _Download Selected_. When clicked, the action will send a POST request to the url https://download.scicat.org with the list of the dataset's files selected by the user. It is enabled only when the total size is lower than the max size defined in configuration and at least one file is selected. +3. Create and download a notebook which load the dataset metadata and download locally all the files + It renders a button with the Jupyter icon and label _Notebook All_. When clicked, the action will send a POST request to the url https://notebook.scicat.org with the list of all the files associated with the dataset. It is always enabled. +4. Create and download a notebook which load the dataset metadata and download locally only the selected files. + It renders a button with the Jupyter icon and label _Notebook Selected_. When clicked, the action will send a POST request to the url https://notebook.scicat.org with the list of the dataset's files selected by the user. It is enabled only when at least one file is selected. + +Configuration properties are set to the following values: +``` +datafilesActionsEnabled = true + +datafilesActions = [ + { + id: "eed8efec-4354-11ef-a3b5-d75573a5d37f", + order: 4, + label: "Download All", + files: "all", + mat_icon: "download", + url: "https://download.scicat.org", + target: "_blank", + enabled: "#SizeLimit", + }, + { + id: "3072fafc-4363-11ef-b9f9-ebf568222d26", + order: 3, + label: "Download Selected", + files: "selected", + mat_icon: "download", + url: "https://download.scicat.org", + target: "_blank", + enabled: "#Selected && #SizeLimit", + }, + { + 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", + }, + { + id: "fa3ce6ee-482d-11ef-95e9-ff2c80dd50bd", + order: 1, + label: "Notebook Selected", + files: "selected", + icon: "/assets/icons/jupyter_logo.png", + url: "https://notebook.scicat.org", + target: "_blank", + enabled: "#Selected", + }, + ] + +``` + +This configuration renders to the following buttons if no files are selected: +![Datafiles actions when no files are selected](./datafiles_actions_no_file_selected.png "Datafiles actions with no selected files") + +or in this other one when at least one file is selected: +![Datafiles actions when at least one files are selected](./datafiles_actions_file_selected.png "Datafiles actions with selected files") + + +### Tests +The previous examples are the settings used for the associated unit tests with the addition of the following settings: +``` +files = [ + { + 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: "", + }, +] + +lowerMaxFileSizeLimit = 9999; +higherMaxFileSizeLimit = 20000; + +``` + +There are three group of tests: +1. testing enabling/disabling of the rendered buttons +2. testing that action and related form submissions contains the correct files and urls +3. testing that the buttons contain the correct label as specified in the configuration + + + diff --git a/docs/configuration/datafiles_actions_file_selected.png b/docs/configuration/datafiles_actions_file_selected.png new file mode 100644 index 000000000..81bd42f69 Binary files /dev/null and b/docs/configuration/datafiles_actions_file_selected.png differ diff --git a/docs/configuration/datafiles_actions_no_file_selected.png b/docs/configuration/datafiles_actions_no_file_selected.png new file mode 100644 index 000000000..0f92986de Binary files /dev/null and b/docs/configuration/datafiles_actions_no_file_selected.png differ diff --git a/karma.conf.js b/karma.conf.js index ac60f4230..804a75171 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -42,7 +42,8 @@ module.exports = function (config) { dir: require('path').join(__dirname, './coverage'), reporters: [ { type: 'html', subdir: 'report-html' }, - { type: 'lcovonly', subdir: '.', file: 'lcov.info' } + { type: 'lcovonly', subdir: '.', file: 'lcov.info' }, + { type: 'text-summary' } ], fixWebpackSourcePaths: true }, diff --git a/src/app/app-config.service.spec.ts b/src/app/app-config.service.spec.ts index 3a59efee6..4538eea22 100644 --- a/src/app/app-config.service.spec.ts +++ b/src/app/app-config.service.spec.ts @@ -135,6 +135,52 @@ const appConfig: AppConfig = { 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"], + }, + ], }; describe("AppConfigService", () => { diff --git a/src/app/app-config.service.ts b/src/app/app-config.service.ts index c66aef158..b91a3539b 100644 --- a/src/app/app-config.service.ts +++ b/src/app/app-config.service.ts @@ -36,6 +36,9 @@ export interface AppConfig { archiveWorkflowEnabled: boolean; datasetJsonScientificMetadata: boolean; datasetReduceEnabled: boolean; + datasetDetailsShowMissingProposalId: boolean; + datafilesActionsEnabled: boolean; + datafilesActions: any[]; editDatasetSampleEnabled: boolean; editMetadataEnabled: boolean; editPublishedData: boolean; @@ -84,7 +87,6 @@ export interface AppConfig { tableSciDataEnabled: boolean; fileserverBaseURL: string; fileserverButtonLabel: string | undefined; - datasetDetailsShowMissingProposalId: boolean; helpMessages?: HelpMessages; notificationInterceptorEnabled: boolean; pidSearchMethod?: string; diff --git a/src/app/datasets/datafiles-actions/datafiles-action.component.html b/src/app/datasets/datafiles-actions/datafiles-action.component.html new file mode 100644 index 000000000..799932489 --- /dev/null +++ b/src/app/datasets/datafiles-actions/datafiles-action.component.html @@ -0,0 +1,14 @@ + + + diff --git a/src/app/datasets/datafiles-actions/datafiles-action.component.scss b/src/app/datasets/datafiles-actions/datafiles-action.component.scss new file mode 100644 index 000000000..43d6d664c --- /dev/null +++ b/src/app/datasets/datafiles-actions/datafiles-action.component.scss @@ -0,0 +1,10 @@ +button { + margin-left: 4px; + margin-right: 0; + + img { + height: 18px; + width: auto; + vertical-align: middle; + } +} \ No newline at end of file diff --git a/src/app/datasets/datafiles-actions/datafiles-action.component.spec.ts b/src/app/datasets/datafiles-actions/datafiles-action.component.spec.ts new file mode 100644 index 000000000..90a0aa67b --- /dev/null +++ b/src/app/datasets/datafiles-actions/datafiles-action.component.spec.ts @@ -0,0 +1,708 @@ +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 { UserApi } from "shared/sdk"; +import { + MockHtmlElement, + MockMatDialogRef, + MockUserApi, +} from "shared/MockStubs"; +import { ActionDataset } from "./datafiles-action.interfaces"; + +describe("1000: DatafilesActionComponent", () => { + let component: DatafilesActionComponent; + let fixture: ComponentFixture; + + const actionsConfig = [ + { + id: "eed8efec-4354-11ef-a3b5-d75573a5d37f", + order: 4, + 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: 3, + 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: 1, + label: "Notebook Selected", + files: "selected", + icon: "/assets/icons/jupyter_logo.png", + url: "https://notebook.scicat.org", + target: "_blank", + enabled: "#Selected", + authorization: ["#datasetAccess", "#datasetPublic"], + }, + ]; + + 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, + } + + const jwt = () => ({ + subscribe: (f: any) => ({ + jwt: "9a2322a8-4a7d-11ef-a0f5-d7c40fcf1693", + }), + }); + + const getCurrentToken = () => ({ + id: "4ac45f3e-4d79-11ef-856c-6339dab93bee", + }); + + const browserWindowMock = { + document: { + write() {}, + body: { + setAttribute() {}, + }, + }, + } as unknown as Window; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + imports: [ + MatButtonModule, + MatIconModule, + MatTableModule, + PipesModule, + ReactiveFormsModule, + MatDialogModule, + RouterModule, + RouterModule.forRoot([]), + StoreModule.forRoot({}), + ], + declarations: [DatafilesActionComponent], + }); + TestBed.overrideComponent(DatafilesActionComponent, { + set: { + providers: [ + { provide: UserApi, useClass: MockUserApi }, + { provide: MatDialogRef, useClass: MockMatDialogRef }, + { provide: UserApi, useValue: { jwt, 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 getFakeElement(elementType: string): HTMLElement { + const element = new MockHtmlElement(elementType); + return element as unknown as HTMLElement; + } + + 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(getFakeElement); + spyOn(window, "open").and.returnValue(browserWindowMock); + + component.perform_action(); + + // eslint-disable-next-line @typescript-eslint/quotes + 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(getFakeElement); + spyOn(window, "open").and.returnValue(browserWindowMock); + + component.perform_action(); + + expect(component.form.action).toEqual( + actionsConfig[actionSelectorType.download_all].url, + ); + }); + + 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(getFakeElement); + spyOn(window, "open").and.returnValue(browserWindowMock); + + component.perform_action(); + + // eslint-disable-next-line @typescript-eslint/quotes + 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(getFakeElement); + spyOn(window, "open").and.returnValue(browserWindowMock); + + component.perform_action(); + + // eslint-disable-next-line @typescript-eslint/quotes + 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(getFakeElement); + spyOn(window, "open").and.returnValue(browserWindowMock); + + component.perform_action(); + + // eslint-disable-next-line @typescript-eslint/quotes + 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(getFakeElement); + spyOn(window, "open").and.returnValue(browserWindowMock); + + component.perform_action(); + + expect(component.form.action).toEqual( + actionsConfig[actionSelectorType.notebook_all].url, + ); + }); + + 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(getFakeElement); + spyOn(window, "open").and.returnValue(browserWindowMock); + + component.perform_action(); + + // eslint-disable-next-line @typescript-eslint/quotes + 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, + ); + }); +}); diff --git a/src/app/datasets/datafiles-actions/datafiles-action.component.ts b/src/app/datasets/datafiles-actions/datafiles-action.component.ts new file mode 100644 index 000000000..ff0675f58 --- /dev/null +++ b/src/app/datasets/datafiles-actions/datafiles-action.component.ts @@ -0,0 +1,146 @@ +import { + Component, + Input, + OnChanges, + OnInit, + SimpleChanges, +} from "@angular/core"; + +import { UserApi } from "shared/sdk"; +import { ActionConfig, ActionDataset } from "./datafiles-action.interfaces"; +import { DataFiles_File } from "datasets/datafiles/datafiles.interfaces"; + +@Component({ + selector: "datafiles-action", + //standalone: true, + //imports: [], + templateUrl: "./datafiles-action.component.html", + styleUrls: ["./datafiles-action.component.scss"], +}) +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 = false; + disabled_condition = "false"; + selectedTotalFileSize = 0; + numberOfFileSelected = 0; + + form: HTMLFormElement; + + constructor(private userApi: UserApi) { + this.userApi.jwt().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) { + console.log(changes); + 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; + } + + // compute_disabled() { + // this.disabled = eval(this.disabled_condition); + // } + + get disabled() { + this.update_status(); + this.prepare_disabled_condition(); + return eval(this.disabled_condition); + } + + add_input(name, value) { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = name; + input.value = value; + return input; + } + + perform_action() { + this.form = document.createElement("form"); + this.form.target = this.actionConfig.target; + this.form.method = this.actionConfig.method; + this.form.action = this.actionConfig.url; + + this.form.appendChild( + this.add_input("auth_token", this.userApi.getCurrentToken().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), + ); + + for (const [index, item] of this.files.entries()) { + if ( + this.actionConfig.files === "all" || + (this.actionConfig.files === "selected" && item.selected) + ) { + this.form.appendChild( + this.add_input("files[" + index + "]", item.path), + ); + } + } + + //document.body.appendChild(form); + this.form.submit(); + window.open("", "view"); + } +} diff --git a/src/app/datasets/datafiles-actions/datafiles-action.interfaces.ts b/src/app/datasets/datafiles-actions/datafiles-action.interfaces.ts new file mode 100644 index 000000000..14410d950 --- /dev/null +++ b/src/app/datasets/datafiles-actions/datafiles-action.interfaces.ts @@ -0,0 +1,19 @@ +export interface ActionConfig { + id: string; + order: number; + label: string; + files: string; + mat_icon?: string; + icon?: string; + url: string; + target: string; + authorization: string[]; + method?: string; + enabled?: string; + disabled?: 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 new file mode 100644 index 000000000..b35f645ea --- /dev/null +++ b/src/app/datasets/datafiles-actions/datafiles-actions.component.html @@ -0,0 +1,12 @@ + +
+ + +
+
diff --git a/src/app/datasets/datafiles-actions/datafiles-actions.component.scss b/src/app/datasets/datafiles-actions/datafiles-actions.component.scss new file mode 100644 index 000000000..cdc435d73 --- /dev/null +++ b/src/app/datasets/datafiles-actions/datafiles-actions.component.scss @@ -0,0 +1,3 @@ +.dataset-datafiles-actions { + float: right; +} \ No newline at end of file diff --git a/src/app/datasets/datafiles-actions/datafiles-actions.component.spec.ts b/src/app/datasets/datafiles-actions/datafiles-actions.component.spec.ts new file mode 100644 index 000000000..f8bdc5284 --- /dev/null +++ b/src/app/datasets/datafiles-actions/datafiles-actions.component.spec.ts @@ -0,0 +1,204 @@ +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 { UserApi } from "shared/sdk"; +import { MockMatDialogRef, MockUserApi } from "shared/MockStubs"; +import { AppConfigService } from "app-config.service"; + +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: UserApi, useClass: MockUserApi }, + { provide: MatDialogRef, useClass: MockMatDialogRef }, + { provide: AppConfigService, useValue: mockAppConfigService }, + { provide: UserApi, 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 new file mode 100644 index 000000000..f449b4c35 --- /dev/null +++ b/src/app/datasets/datafiles-actions/datafiles-actions.component.ts @@ -0,0 +1,48 @@ +import { Component, Input, OnInit } 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"], +}) +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 39877b4ed..8eb776fa3 100644 --- a/src/app/datasets/datafiles/datafiles.component.html +++ b/src/app/datasets/datafiles/datafiles.component.html @@ -38,8 +38,11 @@ {{ count }} datafiles.
-

No datafiles linked to this dataset

+

No files associated to this dataset

+ + + { let component: DatafilesComponent; let fixture: ComponentFixture; - const getConfig = () => ({}); + const getConfig = () => ({ + 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"], + }, + ], + }); beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -44,6 +96,10 @@ describe("DatafilesComponent", () => { { provide: MatDialogRef, useClass: MockMatDialogRef }, { provide: AppConfigService, useValue: { getConfig } }, { provide: UserApi, useClass: MockUserApi }, + { + provide: DatafilesActionsComponent, + useClass: MockDatafilesActionsComponent, + }, ], }, }); diff --git a/src/app/datasets/datafiles/datafiles.component.ts b/src/app/datasets/datafiles/datafiles.component.ts index ff8846583..136e192bd 100644 --- a/src/app/datasets/datafiles/datafiles.component.ts +++ b/src/app/datasets/datafiles/datafiles.component.ts @@ -30,17 +30,8 @@ import { PublicDownloadDialogComponent } from "datasets/public-download-dialog/p import { submitJobAction } from "state-management/actions/jobs.actions"; import { AppConfigService } from "app-config.service"; import { NgForm } from "@angular/forms"; - -export interface File { - path: string; - size: number; - time: string; - chk: string; - uid: string; - gid: string; - perm: string; - selected: boolean; -} +import { DataFiles_File } from "./datafiles.interfaces"; +import { ActionDataset } from "datasets/datafiles-actions/datafiles-action.interfaces"; @Component({ selector: "datafiles", @@ -73,6 +64,7 @@ export class DatafilesComponent files: Array = []; sourcefolder = ""; datasetPid = ""; + actionDataset: ActionDataset; count = 0; pageSize = 25; @@ -111,7 +103,7 @@ export class DatafilesComponent dateFormat: "yyyy-MM-dd HH:mm", }, ]; - tableData: File[] = []; + tableData: DataFiles_File[] = []; constructor( public appConfigService: AppConfigService, @@ -160,6 +152,19 @@ export class DatafilesComponent updateSelectionStatus() { this.areAllSelected = this.getAreAllSelected(); this.isNoneSelected = this.getIsNoneSelected(); + this.updateSelectedInFiles(); + } + + updateSelectedInFiles() { + const selected = this.tableData + .filter((item) => item.selected) + .map((item) => item.path); + const files = this.files.map((item) => { + item.selected = selected.includes(item.path); + return item; + }); + console.log(files); + this.files = [...files]; } onSelectOne(checkboxEvent: CheckboxEvent) { @@ -205,13 +210,14 @@ export class DatafilesComponent if (dataset) { this.sourcefolder = dataset.sourceFolder; this.datasetPid = dataset.pid; + this.actionDataset = dataset; } }), ); this.subscriptions.push( this.datablocks$.subscribe((datablocks) => { if (datablocks) { - const files: File[] = []; + const files: DataFiles_File[] = []; datablocks.forEach((block) => { block.dataFileList.map((file) => { this.totalFileSize += file.size; diff --git a/src/app/datasets/datafiles/datafiles.interfaces.ts b/src/app/datasets/datafiles/datafiles.interfaces.ts new file mode 100644 index 000000000..a781da1ee --- /dev/null +++ b/src/app/datasets/datafiles/datafiles.interfaces.ts @@ -0,0 +1,11 @@ +export interface DataFiles_File { + path: string; + size: number; + time: string; + chk: string; + uid: string; + gid: string; + perm: string; + selected: boolean; + hash: string; +} diff --git a/src/app/datasets/datasets.module.ts b/src/app/datasets/datasets.module.ts index 72e36d577..72c17a0bf 100644 --- a/src/app/datasets/datasets.module.ts +++ b/src/app/datasets/datasets.module.ts @@ -82,6 +82,8 @@ import { instrumentsReducer } from "state-management/reducers/instruments.reduce import { InstrumentEffects } from "state-management/effects/instruments.effects"; import { RelatedDatasetsComponent } from "./related-datasets/related-datasets.component"; import { FullTextSearchBarComponent } from "./dashboard/full-text-search/full-text-search-bar.component"; +import { DatafilesActionsComponent } from "./datafiles-actions/datafiles-actions.component"; +import { DatafilesActionComponent } from "./datafiles-actions/datafiles-action.component"; @NgModule({ imports: [ @@ -161,6 +163,8 @@ import { FullTextSearchBarComponent } from "./dashboard/full-text-search/full-te DatasetFileUploaderComponent, AdminTabComponent, RelatedDatasetsComponent, + DatafilesActionsComponent, + DatafilesActionComponent, ], providers: [ ArchivingService, diff --git a/src/app/shared/MockStubs.ts b/src/app/shared/MockStubs.ts index edaa2a986..3e1dc2776 100644 --- a/src/app/shared/MockStubs.ts +++ b/src/app/shared/MockStubs.ts @@ -6,6 +6,11 @@ import { AppConfig } from "app-config.module"; import { SciCatDataSource } from "./services/scicat.datasource"; import { LoopBackAuth } from "./sdk"; import { Injectable } from "@angular/core"; +import { + ActionConfig, + ActionDataset, +} from "datasets/datafiles-actions/datafiles-action.interfaces"; +import { DataFiles_File } from "datasets/datafiles/datafiles.interfaces"; export class MockUserApi { getCurrentId() { @@ -200,3 +205,61 @@ export class MockLoopBackAuth extends LoopBackAuth { getAccessToken = () => ({ id: "test" }); getAccessTokenId = () => "test"; } + +export class MockDatafilesActionsComponent { + actionsConfig: ActionConfig[]; + dataset: ActionDataset; + files: DataFiles_File[]; +} + +export class MockHtmlElement { + id = ""; + tag = "HTML"; + innerHTML = ""; + value = ""; + name = ""; + disabled = false; + style: unknown = { display: "block", backgroundColor: "red" }; + children: MockHtmlElement[] = []; + + constructor(tag = "", id = "") { + this.id = id; + this.tag = tag; + } + createElement(t: string, id = "") { + return new MockHtmlElement(t, id); + } + appendChild(x: MockHtmlElement) { + this.children.push(x); + return x; + } + clear() { + this.children = []; + } + querySelector(sel: string) { + // too hard to implement correctly, so just hack something + const list = this.getElementsByTagName(sel); + return list.length > 0 ? list[0] : this.children[0]; + } + querySelectorAll(sel: string) { + // too hard to implement correctly, so just return all children + return this.children; + } + getElementById(id: string): any { + // if not found, just CREATE one!! + return ( + this.children.find((x) => x.id == id) || + this.appendChild(this.createElement("", id)) + ); + } + getElementsByClassName(classname: string): any[] { + return this.children.filter((x: any) => x.classList.contains(classname)); + } + getElementsByName(name: string): any[] { + return this.children.filter((x: any) => x.name == name); + } + getElementsByTagName(tag: string): any[] { + return this.children.filter((x: any) => x.tag == tag.toUpperCase()); + } + submit() {} +} diff --git a/src/assets/config.json b/src/assets/config.json index c32cf212b..5d70653dc 100644 --- a/src/assets/config.json +++ b/src/assets/config.json @@ -9,8 +9,8 @@ "editPublishedData": false, "addSampleEnabled": true, "externalAuthEndpoint": "/auth/msad", - "facility": "ESS", - "siteIcon": "esslogo-white.png", + "facility": "Local", + "siteIcon": "site-header-logo.png", "loginFacilityLabel": "ESS", "loginLdapLabel": "Ldap", "loginLocalLabel": "Local", @@ -111,8 +111,8 @@ "maxDirectDownloadSize": 5000000000, "metadataPreviewEnabled": true, "metadataStructure": "", - "multipleDownloadAction": "http://localhost:3012/zip", - "multipleDownloadEnabled": true, + "multipleDownloadAction": "http://localhost:3012/zip", + "multipleDownloadEnabled": true, "oAuth2Endpoints": [ { "authURL": "api/v3/auth/oidc", @@ -131,5 +131,51 @@ "shoppingCartEnabled": true, "shoppingCartOnHeader": true, "tableSciDataEnabled": true, - "datasetDetailsShowMissingProposalId": false + "datasetDetailsShowMissingProposalId": false, + "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" ] + } + ] } diff --git a/src/assets/icons/jupyter_logo.png b/src/assets/icons/jupyter_logo.png new file mode 100644 index 000000000..58623ebfb Binary files /dev/null and b/src/assets/icons/jupyter_logo.png differ