From 8decc49319515a859ba148cf971b45f719ece977 Mon Sep 17 00:00:00 2001 From: abdellah belaid Date: Mon, 27 Apr 2026 14:13:03 +0200 Subject: [PATCH] feat: Add a dynamic datafile component with configurable options --- docs/contributors/default-list-settings.md | 111 +++- src/app/app-config.service.ts | 2 + ...ataset-details-dashboard.routing.module.ts | 4 +- .../datafiles-wrapper.component.spec.ts | 50 ++ .../datafiles/datafiles-wrapper.component.ts | 19 + .../datafiles/datafiles.interfaces.ts | 11 - .../dynamic-datafiles.component.html | 59 +++ .../dynamic-datafiles.component.scss} | 0 .../dynamic-datafiles.component.ts | 474 ++++++++++++++++++ .../_datafiles-theme.scss | 0 .../datafiles.component.html | 0 .../static-datafiles/datafiles.component.scss | 16 + .../datafiles.component.spec.ts | 0 .../datafiles.component.ts | 10 +- .../dataset-detail.component.spec.ts | 2 +- ...ataset-details-dashboard.component.spec.ts | 18 +- .../dataset-details-dashboard.component.ts | 41 +- src/app/datasets/datasets.module.ts | 14 +- src/app/shared/MockStubs.ts | 4 +- .../configurable-action.component.spec.ts | 21 +- .../configurable-action.component.ts | 15 +- .../configurable-action.interfaces.ts | 4 +- .../models/table-field.model.ts | 3 + .../table/dynamic-mat-table.component.ts | 44 +- .../table/dynamic-mat-table.module.ts | 7 +- src/app/shared/pipes/pipes.module.ts | 3 + src/app/shared/pipes/time-duration.pipe.ts | 21 + .../actions/files.actions.spec.ts | 111 +++- .../state-management/actions/files.actions.ts | 49 +- .../effects/files.effects.spec.ts | 45 +- .../state-management/effects/files.effects.ts | 53 +- src/app/state-management/models/index.ts | 32 ++ .../reducers/files.reducer.spec.ts | 133 ++++- .../reducers/files.reducer.ts | 117 ++++- .../selectors/files.selectors.spec.ts | 93 +++- .../selectors/files.selectors.ts | 41 ++ src/app/state-management/state/files.store.ts | 36 +- src/app/state-management/state/user.store.ts | 1 + src/assets/config.json | 49 ++ src/styles.scss | 2 +- 40 files changed, 1648 insertions(+), 67 deletions(-) create mode 100644 src/app/datasets/datafiles/datafiles-wrapper.component.spec.ts create mode 100644 src/app/datasets/datafiles/datafiles-wrapper.component.ts delete mode 100644 src/app/datasets/datafiles/datafiles.interfaces.ts create mode 100644 src/app/datasets/datafiles/dynamic-datafiles/dynamic-datafiles.component.html rename src/app/datasets/datafiles/{datafiles.component.scss => dynamic-datafiles/dynamic-datafiles.component.scss} (100%) create mode 100644 src/app/datasets/datafiles/dynamic-datafiles/dynamic-datafiles.component.ts rename src/app/datasets/datafiles/{ => static-datafiles}/_datafiles-theme.scss (100%) rename src/app/datasets/datafiles/{ => static-datafiles}/datafiles.component.html (100%) create mode 100644 src/app/datasets/datafiles/static-datafiles/datafiles.component.scss rename src/app/datasets/datafiles/{ => static-datafiles}/datafiles.component.spec.ts (100%) rename src/app/datasets/datafiles/{ => static-datafiles}/datafiles.component.ts (97%) create mode 100644 src/app/shared/pipes/time-duration.pipe.ts diff --git a/docs/contributors/default-list-settings.md b/docs/contributors/default-list-settings.md index f85fc53530..538c2cbf38 100644 --- a/docs/contributors/default-list-settings.md +++ b/docs/contributors/default-list-settings.md @@ -1,10 +1,14 @@ # Default List Settings (Frontend) -This page explains where to configure default list behavior in the frontend Admin Settings UI. +This page explains where to configure default list and table behavior in the +frontend. -Currently it only covers default sorting. +The datasets and proposals defaults are configured in the Admin Settings UI. The +dynamic datafiles table also supports configurable default columns through the +frontend configuration. ## Where To Find It + Open the user menu and go to **Admin Settings**. ![Admin Settings](admin_settings_menu.png) @@ -14,24 +18,125 @@ Then open **List settings** ![List settings overview](admin_list_settings_overview.png) _Figure 2: List settings overview._ + ## List Settings Structure -There are two sections: +The Admin Settings UI currently contains two list settings sections: - **Default Datasets List Settings** - **Default Proposals List Settings** +The dynamic datafiles table is configured with `defaultDatafilesColumnsList` in +the frontend configuration, for example `src/assets/config.json` in local +development or the deployed frontend config for an environment. It is not +currently exposed as a separate **List settings** section in the Admin Settings +UI. + ## Default Sorting Default sorting is configured at column level with the `sort` property. Accepted values: + - `asc` - `desc` Rules: + - Define `sort` on only one column per list section. - If `sort` is not defined, the table falls back to `createdAt` in descending order. ![Sort field in columns list](sort_field_columns_list.png) _Figure 3: Sort field in Default Datasets List Settings._ + +## Dynamic Datafiles Table + +The dynamic datafiles table is the table shown in the dataset details +**Datafiles** tab when `dynamicDatafilesViewEnabled` is enabled. + +Configuration keys: + +- `dynamicDatafilesViewEnabled`: switches the dataset **Datafiles** tab from the + static datafiles component to the dynamic table component. +- `defaultDatafilesColumnsList`: defines the default columns shown in the + dynamic datafiles table. +- `datafilesActions`: defines the configurable action buttons shown above the + table. + +The dynamic datafiles table uses the shared `dynamic-mat-table` component with +server-side pagination. By default it requests 25 rows per page and offers page +sizes of 10, 25, 50, 100, and 200. + +The component also applies the same default row divider style as the datasets +table through the table settings `rowStyle`. + +### Datafiles Columns + +`defaultDatafilesColumnsList` is an array of column definitions. Each column +usually points to a property on the file origdatablock row. Datafile fields are +available under `dataFileList`. + +Example: + +```json +"defaultDatafilesColumnsList": [ + { + "name": "dataFileList.path", + "type": "standard", + "icon": "text_snippet", + "header": "Filename", + "enabled": true + }, + { + "name": "dataFileList.size", + "type": "standard", + "icon": "save", + "header": "Size", + "pipe": "filesize" + }, + { + "name": "dataFileList.metadata.measurement_type", + "type": "standard", + "icon": "science", + "header": "Measurement type" + }, + { + "name": "dataFileList.time", + "type": "date", + "icon": "access_time", + "header": "Created at", + "format": "yyyy-MM-dd HH:mm" + } +] +``` + +Common column properties: + +- `name`: field path used by the table. For datafiles this is usually + `dataFileList.`. +- `header`: visible column label. +- `type`: table cell type, usually `standard` or `date`. +- `icon`: Material icon shown in the column settings menu. +- `enabled`: whether the column is enabled by default. +- `format`: date formatting string for date columns. +- `pipe`: display pipe. The datafiles table supports `date`, `filesize`, and + `timeDuration` through the dynamic table pipe mapping. Pipe names are matched + case-insensitively. +- `width`: optional column width. +- `sort`: optional default sort direction. Use `asc` or `desc`. + +### Saved User Settings + +The admin defaults are only the starting point. When a user saves table settings +from the dynamic datafiles table menu, the selected columns are stored in the +user setting `fe_datafiles_table_columns`. + +At runtime, the table merges saved user columns with the current +`defaultDatafilesColumnsList`: + +- saved columns keep the user's order, visibility, and width; +- new default columns are appended when they are not already saved; +- saved custom columns with `userAdded: true` are preserved. + +To return to the site defaults, use the table settings menu and choose the +default settings option. diff --git a/src/app/app-config.service.ts b/src/app/app-config.service.ts index 8f4cb7b5d2..8031f651a9 100644 --- a/src/app/app-config.service.ts +++ b/src/app/app-config.service.ts @@ -84,6 +84,7 @@ export interface AppConfigInterface { addDatasetEnabled: boolean; archiveWorkflowEnabled: boolean; datasetJsonScientificMetadata: boolean; + dynamicDatafilesViewEnabled?: boolean; datasetPageSizeOptions?: number[]; datasetReduceEnabled: boolean; datasetDetailsShowMissingProposalId: boolean; @@ -156,6 +157,7 @@ export interface AppConfigInterface { metadataEditingUnitListDisabled?: boolean; defaultDatasetsListSettings?: ListSettings; defaultProposalsListSettings?: ListSettings; + defaultDatafilesColumnsList?: TableColumn[]; thumbnailFetchLimitPerPage: number; maxFileUploadSizeInMb?: string; datasetDetailComponent?: DatasetDetailComponentConfig; diff --git a/src/app/app-routing/lazy/dataset-details-dashboard-routing/dataset-details-dashboard.routing.module.ts b/src/app/app-routing/lazy/dataset-details-dashboard-routing/dataset-details-dashboard.routing.module.ts index d48eeab3a4..258db6087b 100644 --- a/src/app/app-routing/lazy/dataset-details-dashboard-routing/dataset-details-dashboard.routing.module.ts +++ b/src/app/app-routing/lazy/dataset-details-dashboard-routing/dataset-details-dashboard.routing.module.ts @@ -4,7 +4,7 @@ import { AdminGuard } from "app-routing/admin.guard"; import { AuthGuard } from "app-routing/auth.guard"; import { ServiceGuard } from "app-routing/service.guard"; import { AdminTabComponent } from "datasets/admin-tab/admin-tab.component"; -import { DatafilesComponent } from "datasets/datafiles/datafiles.component"; +import { DatafilesWrapperComponent } from "datasets/datafiles/datafiles-wrapper.component"; import { JsonScientificMetadataComponent } from "datasets/jsonScientificMetadata/jsonScientificMetadata.component"; import { DatasetFileUploaderComponent } from "datasets/dataset-file-uploader/dataset-file-uploader.component"; import { DatasetLifecycleComponent } from "datasets/dataset-lifecycle/dataset-lifecycle.component"; @@ -24,7 +24,7 @@ const routes: Routes = [ }, { path: "datafiles", - component: DatafilesComponent, + component: DatafilesWrapperComponent, }, { path: "relatedDatasets", diff --git a/src/app/datasets/datafiles/datafiles-wrapper.component.spec.ts b/src/app/datasets/datafiles/datafiles-wrapper.component.spec.ts new file mode 100644 index 0000000000..c52d60ffa1 --- /dev/null +++ b/src/app/datasets/datafiles/datafiles-wrapper.component.spec.ts @@ -0,0 +1,50 @@ +import { CommonModule } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { AppConfigInterface, AppConfigService } from "app-config.service"; +import { DatafilesWrapperComponent } from "./datafiles-wrapper.component"; +import { DynamicDatafilesComponent } from "./dynamic-datafiles/dynamic-datafiles.component"; +import { DatafilesComponent } from "./static-datafiles/datafiles.component"; + +describe("DatafilesWrapperComponent", () => { + let component: DatafilesWrapperComponent; + let fixture: ComponentFixture; + let appConfigService: jasmine.SpyObj; + + beforeEach(() => { + const appConfigServiceSpy = jasmine.createSpyObj("AppConfigService", [ + "getConfig", + ]); + + TestBed.configureTestingModule({ + imports: [CommonModule], + declarations: [DatafilesWrapperComponent], + providers: [{ provide: AppConfigService, useValue: appConfigServiceSpy }], + }).compileComponents(); + + fixture = TestBed.createComponent(DatafilesWrapperComponent); + component = fixture.componentInstance; + appConfigService = TestBed.inject( + AppConfigService, + ) as jasmine.SpyObj; + }); + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("should load DynamicDatafilesComponent when dynamicDatafilesViewEnabled is true", () => { + appConfigService.getConfig.and.returnValue({ + dynamicDatafilesViewEnabled: true, + } as AppConfigInterface); + + expect(component.getDatafilesComponent()).toBe(DynamicDatafilesComponent); + }); + + it("should load DatafilesComponent when dynamicDatafilesViewEnabled is false", () => { + appConfigService.getConfig.and.returnValue({ + dynamicDatafilesViewEnabled: false, + } as AppConfigInterface); + + expect(component.getDatafilesComponent()).toBe(DatafilesComponent); + }); +}); diff --git a/src/app/datasets/datafiles/datafiles-wrapper.component.ts b/src/app/datasets/datafiles/datafiles-wrapper.component.ts new file mode 100644 index 0000000000..108c3674b5 --- /dev/null +++ b/src/app/datasets/datafiles/datafiles-wrapper.component.ts @@ -0,0 +1,19 @@ +import { Component } from "@angular/core"; +import { AppConfigService } from "app-config.service"; +import { DynamicDatafilesComponent } from "./dynamic-datafiles/dynamic-datafiles.component"; +import { DatafilesComponent } from "./static-datafiles/datafiles.component"; + +@Component({ + selector: "app-datafiles-wrapper", + template: ` `, + standalone: false, +}) +export class DatafilesWrapperComponent { + constructor(private appConfigService: AppConfigService) {} + + getDatafilesComponent() { + return this.appConfigService.getConfig().dynamicDatafilesViewEnabled + ? DynamicDatafilesComponent + : DatafilesComponent; + } +} diff --git a/src/app/datasets/datafiles/datafiles.interfaces.ts b/src/app/datasets/datafiles/datafiles.interfaces.ts deleted file mode 100644 index e096db50ed..0000000000 --- a/src/app/datasets/datafiles/datafiles.interfaces.ts +++ /dev/null @@ -1,11 +0,0 @@ -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/datafiles/dynamic-datafiles/dynamic-datafiles.component.html b/src/app/datasets/datafiles/dynamic-datafiles/dynamic-datafiles.component.html new file mode 100644 index 0000000000..1cb1ac45f6 --- /dev/null +++ b/src/app/datasets/datafiles/dynamic-datafiles/dynamic-datafiles.component.html @@ -0,0 +1,59 @@ + + + + warning + + + + + +
+ info + Maximum allowed download size: {{ maxFileSize | filesize }} +
+
+ + +
+
+ Selected: {{ selectedFileSize | filesize + }}{{ maxFileSize ? " / " + (maxFileSize | filesize) : "" }} +
+
+
+ +
+ + No datafiles. + 1 datafile. + {{ count }} datafiles. + + + +
+ + + +
diff --git a/src/app/datasets/datafiles/datafiles.component.scss b/src/app/datasets/datafiles/dynamic-datafiles/dynamic-datafiles.component.scss similarity index 100% rename from src/app/datasets/datafiles/datafiles.component.scss rename to src/app/datasets/datafiles/dynamic-datafiles/dynamic-datafiles.component.scss diff --git a/src/app/datasets/datafiles/dynamic-datafiles/dynamic-datafiles.component.ts b/src/app/datasets/datafiles/dynamic-datafiles/dynamic-datafiles.component.ts new file mode 100644 index 0000000000..7b9fed7eee --- /dev/null +++ b/src/app/datasets/datafiles/dynamic-datafiles/dynamic-datafiles.component.ts @@ -0,0 +1,474 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { SelectionModel } from "@angular/cdk/collections"; +import { BehaviorSubject, Subscription, take } from "rxjs"; +import { TableField } from "shared/modules/dynamic-material-table/models/table-field.model"; +import { + ITableSetting, + TableSettingEventType, +} from "shared/modules/dynamic-material-table/models/table-setting.model"; +import { + TablePagination, + TablePaginationMode, +} from "shared/modules/dynamic-material-table/models/table-pagination.model"; +import { + IRowEvent, + ITableEvent, + RowEventType, + TableEventType, + TableSelectionMode, +} from "shared/modules/dynamic-material-table/models/table-row.model"; +import { Store } from "@ngrx/store"; +import { ActivatedRoute, Router } from "@angular/router"; +import { updateUserSettingsAction } from "state-management/actions/user.actions"; +import { Sort } from "@angular/material/sort"; +import { + selectCurrentDatasetFilesWithCountAndTableSettings, + selectSelectedOrigDatablocks, +} from "state-management/selectors/files.selectors"; +import { actionMenu } from "shared/modules/dynamic-material-table/utilizes/default-table-settings"; +import { TableConfigService } from "shared/services/table-config.service"; +import { + clearSelectionAction, + deselectOrigDatablockAction, + deselectOrigDatablocksAction, + fetchDatasetOrigDatablocksAction, + selectAllOrigDatablocksAction, + selectOrigDatablockAction, +} from "state-management/actions/files.actions"; +import { CreateUserJWT } from "@scicatproject/scicat-sdk-ts-angular"; +import { FileSizePipe } from "shared/pipes/filesize.pipe"; +import { TimeDurationPipe } from "shared/pipes/time-duration.pipe"; +import { AppConfigService } from "app-config.service"; +import { DataFile, FileOrigdatablock } from "state-management/models"; +import { + ActionItemDataset, + ActionItems, +} from "shared/modules/configurable-actions/configurable-action.interfaces"; +import { selectCurrentDataset } from "state-management/selectors/datasets.selectors"; + +@Component({ + selector: "dynamic-datafiles", + templateUrl: "./dynamic-datafiles.component.html", + styleUrls: ["./dynamic-datafiles.component.scss"], + standalone: false, +}) +export class DynamicDatafilesComponent implements OnInit, OnDestroy { + subscriptions: Subscription[] = []; + appConfig = this.appConfigService.getConfig(); + + tableName = "datafilesTable"; + columns: TableField[]; + pending = true; + setting: ITableSetting = {}; + paginationMode: TablePaginationMode = "server-side"; + dataSource: BehaviorSubject = new BehaviorSubject< + FileOrigdatablock[] + >([]); + pagination: TablePagination = {}; + rowSelectionMode: TableSelectionMode = "multi"; + rowSelectionModel = new SelectionModel(true, []); + + tooLargeFile = false; + totalFileSize = 0; + selectedFileSize = 0; + + areAllSelected = false; + isNoneSelected = true; + + files: Array = []; + origDatablocks: FileOrigdatablock[] = []; + selectedOrigDatablocks: FileOrigdatablock[] = []; + dataset$ = this.store.select(selectCurrentDataset); + datasetId?: string; + actionItems: ActionItems = { + datasets: [], + }; + + globalTextSearch = ""; + defaultPageSize = 25; + defaultPageSizeOptions = [10, 25, 50, 100, 200]; + tablesSettings: { columns?: TableField[] } = {}; + tableDefaultSettingsConfig: ITableSetting = { + visibleActionMenu: actionMenu, + settingList: [ + { + visibleActionMenu: actionMenu, + isDefaultSetting: true, + isCurrentSetting: true, + columnSetting: this.appConfig.defaultDatafilesColumnsList ?? [], + }, + ], + rowStyle: { + "border-bottom": "1px solid #d2d2d2", + }, + }; + + count = 0; + pageSize = 25; + currentPage = 0; + fileDownloadEnabled: boolean = this.appConfig.fileDownloadEnabled; + multipleDownloadEnabled: boolean = this.appConfig.multipleDownloadEnabled; + fileserverBaseURL: string | undefined = this.appConfig.fileserverBaseURL; + fileserverButtonLabel: string = + this.appConfig.fileserverButtonLabel || "Download"; + multipleDownloadAction: string | null = this.appConfig.multipleDownloadAction; + maxFileSize: number | null = this.appConfig.maxDirectDownloadSize; + sourceFolder: string = + this.appConfig.sourceFolder || "No source folder provided"; + sftpHost: string = this.appConfig.sftpHost || "No sftp host provided"; + maxFileSizeWarning: string | null = + this.appConfig.maxFileSizeWarning || + `Some files are above the max size ${this.fileSizePipe.transform(this.maxFileSize)}`; + jwt: CreateUserJWT; + auth_token: string; + + constructor( + public appConfigService: AppConfigService, + private store: Store, + private router: Router, + private route: ActivatedRoute, + private fileSizePipe: FileSizePipe, + private timeDurationPipe: TimeDurationPipe, + private tableConfigService: TableConfigService, + ) {} + + private getFileOrigdatablockKey = ( + origDatablock: FileOrigdatablock, + ): string => + [origDatablock.datasetId, origDatablock.dataFileList?.path].join(":"); + + private getSelectedOrigDatablockKeys(): Set { + return new Set( + this.selectedOrigDatablocks.map((origDatablock) => + this.getFileOrigdatablockKey(origDatablock), + ), + ); + } + + private updateActionItemsFiles(): void { + const files = new Map(); + + this.origDatablocks.forEach((odb) => { + odb.dataFileList.selected = false; + files.set(this.getFileOrigdatablockKey(odb), odb.dataFileList); + }); + this.selectedOrigDatablocks.forEach((odb) => { + odb.dataFileList.selected = true; + files.set(this.getFileOrigdatablockKey(odb), odb.dataFileList); + }); + + this.files = Array.from(files.values()); + + this.selectedFileSize = this.files + .filter((file) => file.selected) + .reduce((sum, file) => sum + file.size, 0); + this.tooLargeFile = this.hasTooLargeFiles(this.files); + + if (this.actionItems.datasets.length) { + this.actionItems = { + ...this.actionItems, + datasets: this.actionItems.datasets.map((dataset, index) => + index === 0 ? { ...dataset, files: this.files } : dataset, + ), + }; + } + } + + private updateRowSelectionModel(): void { + const selectedKeys = this.getSelectedOrigDatablockKeys(); + const selectedRowsOnCurrentPage = this.origDatablocks.filter( + (origDatablock) => + selectedKeys.has(this.getFileOrigdatablockKey(origDatablock)), + ); + + this.rowSelectionModel = new SelectionModel( + true, + selectedRowsOnCurrentPage, + ); + } + + private updateSelectionState(): void { + this.updateRowSelectionModel(); + this.updateActionItemsFiles(); + } + + private refreshSelectedOrigDatablocks(): void { + this.store + .select(selectSelectedOrigDatablocks) + .pipe(take(1)) + .subscribe((selectedOrigDatablocks) => { + this.selectedOrigDatablocks = selectedOrigDatablocks; + this.updateSelectionState(); + }); + } + + ngOnInit(): void { + this.subscriptions.push( + this.dataset$.subscribe((dataset) => { + if (dataset) { + this.actionItems = { + ...this.actionItems, + datasets: [ + { + ...(dataset as ActionItemDataset), + files: this.files, + }, + ], + }; + this.datasetId = dataset.pid; + this.updateSelectionState(); + } + }), + ); + this.subscriptions.push( + this.store + .select(selectCurrentDatasetFilesWithCountAndTableSettings) + .subscribe( + ({ + origDatablocks, + count, + selectedOrigDatablocks, + tablesSettings, + }) => { + this.tablesSettings = tablesSettings; + this.origDatablocks = origDatablocks; + this.selectedOrigDatablocks = selectedOrigDatablocks; + this.updateSelectionState(); + this.dataSource.next(origDatablocks); + this.pending = false; + this.count = count; + + const savedTableConfigColumns = tablesSettings?.columns; + const tableSort = this.getTableSort(); + const paginationConfig = this.getTablePaginationConfig(count); + + const tableSettingsConfig = + this.tableConfigService.getTableSettingsConfig( + this.tableName, + this.tableDefaultSettingsConfig, + savedTableConfigColumns, + tableSort, + ); + + if (tableSettingsConfig?.settingList.length) { + this.initTable(tableSettingsConfig, paginationConfig); + } + }, + ), + ); + + this.subscriptions.push( + this.route.queryParams.subscribe((queryParams) => { + this.pending = true; + const limit = queryParams.pageSize + ? +queryParams.pageSize + : this.defaultPageSize; + const skip = queryParams.pageIndex ? +queryParams.pageIndex * limit : 0; + if (queryParams.textSearch) { + this.globalTextSearch = queryParams.textSearch; + } + if (this.datasetId) { + this.store.dispatch( + fetchDatasetOrigDatablocksAction({ + limit: limit, + skip: skip, + search: queryParams.textSearch, + sortColumn: queryParams.sortColumn, + sortDirection: queryParams.sortDirection, + datasetId: this.datasetId, + }), + ); + } + }), + ); + } + + getTableSort(): ITableSetting["tableSort"] { + const { queryParams } = this.route.snapshot; + + if (queryParams.sortDirection && queryParams.sortColumn) { + return { + sortColumn: queryParams.sortColumn, + sortDirection: queryParams.sortDirection, + }; + } + + return null; + } + + getTablePaginationConfig(dataCount = 0): TablePagination { + const { queryParams } = this.route.snapshot; + + return { + pageSizeOptions: this.defaultPageSizeOptions, + pageIndex: queryParams.pageIndex, + pageSize: queryParams.pageSize || this.defaultPageSize, + length: dataCount, + }; + } + + initTable( + settingConfig: ITableSetting, + paginationConfig: TablePagination, + ): void { + const currentColumnSetting = settingConfig.settingList.find( + (s) => s.isCurrentSetting, + )?.columnSetting; + + this.columns = currentColumnSetting?.map((column) => ({ + emptyValue: "-", + ...column, + })); + this.setting = settingConfig; + this.pagination = paginationConfig; + } + + onPaginationChange(pagination: TablePagination) { + this.router.navigate([], { + queryParams: { + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + }, + queryParamsHandling: "merge", + }); + } + + onGlobalTextSearchChange(text: string) { + this.router.navigate([], { + queryParams: { + textSearch: text || undefined, + pageIndex: 0, + }, + queryParamsHandling: "merge", + }); + } + + saveTableSettings(setting: ITableSetting) { + this.pending = true; + const columnsSetting = setting.columnSetting.map((column, index) => { + const { name, display, width } = column; + + return { name, display, order: index, width }; + }); + + this.store.dispatch( + updateUserSettingsAction({ + property: { + fe_datafiles_table_columns: columnsSetting, + }, + }), + ); + } + + onSettingChange(event: { + type: TableSettingEventType; + setting: ITableSetting; + }) { + if ( + event.type === TableSettingEventType.save || + event.type === TableSettingEventType.create + ) { + this.saveTableSettings(event.setting); + } + } + + onRowClick({ event, sender }: IRowEvent) { + if (event === RowEventType.RowSelectionChange) { + const fileOrigdatablock = sender.row; + if (!fileOrigdatablock) { + return; + } + + if (sender.checked) { + this.store.dispatch( + selectOrigDatablockAction({ origDatablock: fileOrigdatablock }), + ); + } else { + this.store.dispatch( + deselectOrigDatablockAction({ origDatablock: fileOrigdatablock }), + ); + } + this.refreshSelectedOrigDatablocks(); + } else if (event === RowEventType.MasterSelectionChange) { + if (sender.checked) { + this.store.dispatch( + selectAllOrigDatablocksAction({ + origDatablocks: sender.selectionModel?.selected ?? [], + }), + ); + } else { + this.store.dispatch( + deselectOrigDatablocksAction({ + origDatablocks: this.origDatablocks, + }), + ); + } + this.refreshSelectedOrigDatablocks(); + } + } + + onTableEvent({ event, sender }: ITableEvent) { + if (event === TableEventType.SortChanged) { + const { active: sortColumn, direction: sortDirection } = sender as Sort; + this.router.navigate([], { + queryParams: { + pageIndex: 0, + sortDirection: sortDirection || undefined, + sortColumn: sortDirection ? sortColumn : undefined, + }, + queryParamsHandling: "merge", + }); + } + } + + hasTooLargeFiles(files: DataFile[]) { + if (this.maxFileSize) { + const maxFileSize = this.maxFileSize; + const largeFiles = files.filter((file) => file.size > maxFileSize); + if (largeFiles.length > 0) { + return true; + } else { + return false; + } + } else { + return false; + } + } + + hasFileAboveMaxSizeWarning() { + /** + * Template for a file size warning message. + * Placeholders: + * - : Maximum file size allowed (e.g., "10 MB"). + * - : SFTP host for downloading large files. + * - : Directory path on the SFTP host. + * + * Example usage: + * Some files are above . These file can be accessed via sftp host: in directory: + */ + + const valueMapping = { + sftpHost: this.sftpHost, + sourceFolder: this.sourceFolder, + maxDirectDownloadSize: this.fileSizePipe.transform(this.maxFileSize), + }; + + let warning = this.maxFileSizeWarning; + + Object.keys(valueMapping).forEach((key) => { + warning = warning.replace( + "<" + key + ">", + `${valueMapping[key]}`, + ); + }); + + return warning; + } + + ngOnDestroy() { + this.store.dispatch(clearSelectionAction()); + this.subscriptions.forEach((sub) => { + sub.unsubscribe(); + }); + } +} diff --git a/src/app/datasets/datafiles/_datafiles-theme.scss b/src/app/datasets/datafiles/static-datafiles/_datafiles-theme.scss similarity index 100% rename from src/app/datasets/datafiles/_datafiles-theme.scss rename to src/app/datasets/datafiles/static-datafiles/_datafiles-theme.scss diff --git a/src/app/datasets/datafiles/datafiles.component.html b/src/app/datasets/datafiles/static-datafiles/datafiles.component.html similarity index 100% rename from src/app/datasets/datafiles/datafiles.component.html rename to src/app/datasets/datafiles/static-datafiles/datafiles.component.html diff --git a/src/app/datasets/datafiles/static-datafiles/datafiles.component.scss b/src/app/datasets/datafiles/static-datafiles/datafiles.component.scss new file mode 100644 index 0000000000..f0663d1d2d --- /dev/null +++ b/src/app/datasets/datafiles/static-datafiles/datafiles.component.scss @@ -0,0 +1,16 @@ +mat-icon { + vertical-align: middle; +} + +.selected { + margin-left: 2em; +} + +.datafiles-header { + margin-top: 1em; + height: 40px; + + .nbr-of-files { + font-size: larger; + } +} diff --git a/src/app/datasets/datafiles/datafiles.component.spec.ts b/src/app/datasets/datafiles/static-datafiles/datafiles.component.spec.ts similarity index 100% rename from src/app/datasets/datafiles/datafiles.component.spec.ts rename to src/app/datasets/datafiles/static-datafiles/datafiles.component.spec.ts diff --git a/src/app/datasets/datafiles/datafiles.component.ts b/src/app/datasets/datafiles/static-datafiles/datafiles.component.ts similarity index 97% rename from src/app/datasets/datafiles/datafiles.component.ts rename to src/app/datasets/datafiles/static-datafiles/datafiles.component.ts index 8766755f17..91dd0fa2a1 100644 --- a/src/app/datasets/datafiles/datafiles.component.ts +++ b/src/app/datasets/datafiles/static-datafiles/datafiles.component.ts @@ -35,7 +35,7 @@ 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"; -import { DataFiles_File } from "./datafiles.interfaces"; +import { DataFile } from "state-management/models"; import { ActionItemDataset, ActionItems, @@ -69,7 +69,7 @@ export class DatafilesComponent implements OnDestroy, OnInit, AfterViewChecked { subscriptions: Subscription[] = []; - files: Array = []; + files: Array = []; datasetPid = ""; actionItems: ActionItems = { datasets: [], @@ -116,7 +116,7 @@ export class DatafilesComponent implements OnDestroy, OnInit, AfterViewChecked { dateFormat: "yyyy-MM-dd HH:mm", }, ]; - tableData: DataFiles_File[] = []; + tableData: DataFile[] = []; constructor( public appConfigService: AppConfigService, @@ -259,9 +259,9 @@ export class DatafilesComponent implements OnDestroy, OnInit, AfterViewChecked { this.subscriptions.push( this.datablocks$.subscribe((datablocks) => { if (datablocks) { - const files: DataFiles_File[] = []; + const files: DataFile[] = []; datablocks.forEach((block) => { - block.dataFileList.map((file: DataFiles_File) => { + block.dataFileList.map((file: DataFile) => { this.totalFileSize += file.size; file.selected = false; files.push(file); diff --git a/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.spec.ts b/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.spec.ts index d77c98e326..e615e61d4d 100644 --- a/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.spec.ts +++ b/src/app/datasets/dataset-detail/dataset-detail/dataset-detail.component.spec.ts @@ -1,4 +1,4 @@ -import { DatafilesComponent } from "../../datafiles/datafiles.component"; +import { DatafilesComponent } from "../../datafiles/static-datafiles/datafiles.component"; import { DatasetDetailComponent } from "./dataset-detail.component"; import { LinkyPipe } from "ngx-linky"; import { NO_ERRORS_SCHEMA } from "@angular/core"; diff --git a/src/app/datasets/dataset-details-dashboard/dataset-details-dashboard.component.spec.ts b/src/app/datasets/dataset-details-dashboard/dataset-details-dashboard.component.spec.ts index 541a4e459d..5d2f761686 100644 --- a/src/app/datasets/dataset-details-dashboard/dataset-details-dashboard.component.spec.ts +++ b/src/app/datasets/dataset-details-dashboard/dataset-details-dashboard.component.spec.ts @@ -16,7 +16,7 @@ import { MatTabsModule } from "@angular/material/tabs"; import { MatIconModule } from "@angular/material/icon"; import { MatButtonModule } from "@angular/material/button"; import { MockStore } from "@ngrx/store/testing"; -import { AppConfigService } from "app-config.service"; +import { AppConfigInterface, AppConfigService } from "app-config.service"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { UsersService } from "@scicatproject/scicat-sdk-ts-angular"; @@ -81,4 +81,20 @@ describe("DetailsDashboardComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + it("should use the dynamic datafiles fetch tab when dynamic datafiles are enabled", () => { + component.appConfig = { + dynamicDatafilesViewEnabled: true, + } as AppConfigInterface; + + expect(component.getFetchDataTab("Datafiles")).toBe("Dynamic Datafiles"); + }); + + it("should use the static datafiles fetch tab when dynamic datafiles are disabled", () => { + component.appConfig = { + dynamicDatafilesViewEnabled: false, + } as AppConfigInterface; + + expect(component.getFetchDataTab("Datafiles")).toBe("Datafiles"); + }); }); diff --git a/src/app/datasets/dataset-details-dashboard/dataset-details-dashboard.component.ts b/src/app/datasets/dataset-details-dashboard/dataset-details-dashboard.component.ts index 65cf86131b..bb6d3d4db7 100644 --- a/src/app/datasets/dataset-details-dashboard/dataset-details-dashboard.component.ts +++ b/src/app/datasets/dataset-details-dashboard/dataset-details-dashboard.component.ts @@ -47,6 +47,7 @@ import { import { MatDialog } from "@angular/material/dialog"; import { AppConfigService } from "app-config.service"; import { fetchInstrumentAction } from "state-management/actions/instruments.actions"; +import { fetchDatasetOrigDatablocksAction } from "state-management/actions/files.actions"; export interface JWT { jwt: string; @@ -60,6 +61,7 @@ enum TAB { details = "Details", jsonScientificMetadata = "Scientific Metadata (JSON)", datafiles = "Datafiles", + dynamicDatafiles = "Dynamic Datafiles", relatedDatasets = "Related Datasets", reduce = "Reduce", logbook = "Logbook", @@ -105,7 +107,14 @@ export class DatasetDetailsDashboardComponent action: fetchRelatedDatasetsAction, loaded: false, }, - [TAB.datafiles]: { action: fetchOrigDatablocksAction, loaded: false }, + [TAB.datafiles]: { + action: fetchOrigDatablocksAction, + loaded: false, + }, + [TAB.dynamicDatafiles]: { + action: fetchDatasetOrigDatablocksAction, + loaded: false, + }, [TAB.logbook]: { action: fetchDatasetLogbookAction, loaded: false }, [TAB.attachments]: { action: fetchAttachmentsAction, loaded: false }, [TAB.admin]: { action: fetchDatablocksAction, loaded: false }, @@ -246,11 +255,19 @@ export class DatasetDetailsDashboardComponent onTabSelected(tab: string) { this.fetchDataForTab(tab); } + + getFetchDataTab(tab: string): string { + return tab === TAB.datafiles && this.appConfig.dynamicDatafilesViewEnabled + ? TAB.dynamicDatafiles + : tab; + } + fetchDataForTab(tab: string) { - if (tab in this.fetchDataActions) { + const fetchDataTab = this.getFetchDataTab(tab); + if (fetchDataTab in this.fetchDataActions) { const args: { [key: string]: any } = { pid: this.dataset?.pid }; // load related data for selected tab - switch (tab) { + switch (fetchDataTab) { case TAB.details: { const { action, loaded } = this.fetchDataActions[TAB.attachments]; @@ -268,10 +285,24 @@ export class DatasetDetailsDashboardComponent } } break; + case TAB.dynamicDatafiles: + { + const { action, loaded } = this.fetchDataActions[fetchDataTab]; + if (!loaded) { + const dargs = { + datasetId: this.dataset?.pid, + skip: 0, + limit: 25, + }; + this.fetchDataActions[fetchDataTab].loaded = true; + this.store.dispatch(action(dargs)); + } + } + break; default: { - const { action, loaded } = this.fetchDataActions[tab]; + const { action, loaded } = this.fetchDataActions[fetchDataTab]; if (!loaded) { - this.fetchDataActions[tab].loaded = true; + this.fetchDataActions[fetchDataTab].loaded = true; this.store.dispatch(action(args)); } } diff --git a/src/app/datasets/datasets.module.ts b/src/app/datasets/datasets.module.ts index f07d563e83..87b6abb794 100644 --- a/src/app/datasets/datasets.module.ts +++ b/src/app/datasets/datasets.module.ts @@ -43,7 +43,9 @@ import { ReduceComponent } from "./reduce/reduce.component"; import { DatasetDetailsDashboardComponent } from "./dataset-details-dashboard/dataset-details-dashboard.component"; import { DashboardComponent } from "./dashboard/dashboard.component"; import { DatablocksComponent } from "./datablocks-table/datablocks-table.component"; -import { DatafilesComponent } from "./datafiles/datafiles.component"; +import { DatafilesWrapperComponent } from "./datafiles/datafiles-wrapper.component"; +import { DatafilesComponent } from "./datafiles/static-datafiles/datafiles.component"; +import { DynamicDatafilesComponent } from "./datafiles/dynamic-datafiles/dynamic-datafiles.component"; import { JsonScientificMetadataComponent } from "./jsonScientificMetadata/jsonScientificMetadata.component"; import { DatasetDetailComponent } from "./dataset-detail/dataset-detail/dataset-detail.component"; import { DatasetTableComponent } from "./dataset-table/dataset-table.component"; @@ -89,9 +91,12 @@ 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 { TimeDurationPipe } from "shared/pipes/time-duration.pipe"; import { ConfigurableActionsModule } from "shared/modules/configurable-actions/configurable-actions.module"; import { OverlayModule } from "@angular/cdk/overlay"; import { SharedConditionModule } from "shared/modules/shared-condition/shared-condition.module"; +import { FilesEffects } from "state-management/effects/files.effects"; +import { filesReducer } from "state-management/reducers/files.reducer"; @NgModule({ imports: [ @@ -140,6 +145,7 @@ import { SharedConditionModule } from "shared/modules/shared-condition/shared-co SampleEffects, PublishedDataEffects, LogbookEffects, + FilesEffects, ]), StoreModule.forFeature("datasets", datasetsReducer), StoreModule.forFeature("instruments", instrumentsReducer), @@ -149,6 +155,7 @@ import { SharedConditionModule } from "shared/modules/shared-condition/shared-co StoreModule.forFeature("publishedData", publishedDataReducer), StoreModule.forFeature("logbooks", logbooksReducer), StoreModule.forFeature("users", userReducer), + StoreModule.forFeature("files", filesReducer), LogbooksModule, MatMenuModule, CdkDropList, @@ -167,7 +174,9 @@ import { SharedConditionModule } from "shared/modules/shared-condition/shared-co DashboardComponent, DatablocksComponent, JsonScientificMetadataComponent, + DatafilesWrapperComponent, DatafilesComponent, + DynamicDatafilesComponent, DatasetDetailWrapperComponent, DatasetDetailComponent, DatasetDetailDynamicComponent, @@ -197,12 +206,15 @@ import { SharedConditionModule } from "shared/modules/shared-condition/shared-co SharedScicatFrontendModule, FileSizePipe, TitleCasePipe, + TimeDurationPipe, ], exports: [ DashboardComponent, DatablocksComponent, JsonScientificMetadataComponent, + DatafilesWrapperComponent, DatafilesComponent, + DynamicDatafilesComponent, DatasetDetailWrapperComponent, DatasetTableComponent, DatasetsFilterComponent, diff --git a/src/app/shared/MockStubs.ts b/src/app/shared/MockStubs.ts index fd668f791a..ca20a5038d 100644 --- a/src/app/shared/MockStubs.ts +++ b/src/app/shared/MockStubs.ts @@ -8,7 +8,7 @@ import { ActionConfig, ActionItems, } from "shared/modules/configurable-actions/configurable-action.interfaces"; -import { DataFiles_File } from "datasets/datafiles/datafiles.interfaces"; +import { DataFile } from "state-management/models"; import { Instrument, OutputJobV3Dto, @@ -277,7 +277,7 @@ export class MockScicatDataSource extends SciCatDataSource { export class MockDatafilesActionsComponent { actionsConfig: ActionConfig[]; actionItems: ActionItems; - files: DataFiles_File[]; + files: DataFile[]; visible: boolean; } diff --git a/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts b/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts index 2a5c8bc342..9925a5353d 100644 --- a/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts +++ b/src/app/shared/modules/configurable-actions/configurable-action.component.spec.ts @@ -17,13 +17,10 @@ import { 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, @@ -606,6 +603,24 @@ describe("1000: ConfigurableActionComponent", () => { }); }); + it("0640: ESS link should not log errors when disabled is checked repeatedly", () => { + const consoleErrorSpy = spyOn(console, "error"); + + selectTestCase({ + test: "n/a", + action: actionSelectorType.link, + limit: maxSizeType.higher, + actionItems: mockActionItemsDatafilesNofiles, + result: true, + } as TestCase); + + expect(component.disabled).toBeFalse(); + expect(component.disabled).toBeFalse(); + expect(component.disabled).toBeFalse(); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + function createFakeElement(elementType: string): HTMLElement { let element: HTMLElement = null; diff --git a/src/app/shared/modules/configurable-actions/configurable-action.component.ts b/src/app/shared/modules/configurable-actions/configurable-action.component.ts index ff84d9aa4f..ae0b943b65 100644 --- a/src/app/shared/modules/configurable-actions/configurable-action.component.ts +++ b/src/app/shared/modules/configurable-actions/configurable-action.component.ts @@ -30,7 +30,7 @@ import { Subscription } from "rxjs"; export class ConfigurableActionComponent implements OnInit, OnChanges { @Input({ required: true }) actionConfig: ActionConfig; @Input({ required: true }) actionItems: ActionItems; - //@Input() files?: DataFiles_File[]; + //@Input() files?: DataFile[]; userProfile$ = this.store.select(selectProfile); isAdmin$ = this.store.select(selectIsAdmin); @@ -225,7 +225,7 @@ export class ConfigurableActionComponent implements OnInit, OnChanges { return selector; } - buildDependenciesGraph(variables: Record) { + buildDependenciesGraph(variables: Record = {}) { /** * Builds a dependency graph for configured variables. * @@ -256,7 +256,8 @@ export class ConfigurableActionComponent implements OnInit, OnChanges { * then the final value is passed through `processSelector` so configured * selectors are converted into values from the current action items. */ - const depsGraph = this.buildDependenciesGraph(this.actionConfig.variables); + const variableDefinitions = this.actionConfig.variables ?? {}; + const depsGraph = this.buildDependenciesGraph(variableDefinitions); const visited: Set = new Set(); const resolveVariable = (varKey: string): any => { const deps = depsGraph[varKey] ?? new Set(); @@ -270,7 +271,7 @@ export class ConfigurableActionComponent implements OnInit, OnChanges { this.variables[dep] = resolveVariable(dep); } } - const varDef = this.actionConfig.variables?.[varKey]; + const varDef = variableDefinitions[varKey]; const resolved = varDef.replace( /@(\w+)(\[(\d+)\])?/g, (_, name, _fullIndex, index) => { @@ -285,7 +286,7 @@ export class ConfigurableActionComponent implements OnInit, OnChanges { return this.processSelector(this.actionItems, resolved); }; - for (const key of Object.keys(this.actionConfig.variables ?? {})) { + for (const key of Object.keys(variableDefinitions)) { this.variables[key] = resolveVariable(key); } } @@ -341,7 +342,9 @@ export class ConfigurableActionComponent implements OnInit, OnChanges { get disabled() { let res = false; try { - this.update_status(); + if (this.actionConfig.variables) { + this.update_status(); + } const expr = this.disabled_condition; const fn = new Function("ctx", `with (ctx) { return (${expr}); }`); diff --git a/src/app/shared/modules/configurable-actions/configurable-action.interfaces.ts b/src/app/shared/modules/configurable-actions/configurable-action.interfaces.ts index b44904c2a3..cbb768da3c 100644 --- a/src/app/shared/modules/configurable-actions/configurable-action.interfaces.ts +++ b/src/app/shared/modules/configurable-actions/configurable-action.interfaces.ts @@ -1,4 +1,4 @@ -import { DataFiles_File } from "datasets/datafiles/datafiles.interfaces"; +import { DataFile } from "state-management/models"; import { Instrument } from "@scicatproject/scicat-sdk-ts-angular"; export interface ActionConfig { @@ -35,7 +35,7 @@ export interface ActionItemDataset { pid: string; sourceFolder?: string; isPublished?: boolean; - files?: DataFiles_File[]; + files?: DataFile[]; } export interface ActionItems { diff --git a/src/app/shared/modules/dynamic-material-table/models/table-field.model.ts b/src/app/shared/modules/dynamic-material-table/models/table-field.model.ts index e9949c5f61..3cef8ed4ec 100644 --- a/src/app/shared/modules/dynamic-material-table/models/table-field.model.ts +++ b/src/app/shared/modules/dynamic-material-table/models/table-field.model.ts @@ -53,6 +53,7 @@ export interface AbstractField { style?: any /* private property used only in html */; header?: string /* The title of the column */; tooltip?: string /* The tooltip of the column */; + emptyValue?: string /* Displayed when the cell value is empty */; isKey?: boolean; inlineEdit?: boolean; display?: FieldDisplay /* Hide and visible this column */; @@ -84,6 +85,8 @@ export interface AbstractField { option?: any; // for store share data show in cell of column categoryData?: any[]; pipes?: IPipe[]; + pipe?: string; + pipeArgs?: unknown[]; toString?: (column: TableField, row: TableRow) => string; customSort?: (column: TableField, row: any) => string; customRender?: (column: TableField, row: any) => string; diff --git a/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.ts b/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.ts index 28647d0d9a..c34e9b3089 100644 --- a/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.ts +++ b/src/app/shared/modules/dynamic-material-table/table/dynamic-mat-table.component.ts @@ -78,6 +78,8 @@ import { import { TableDataSource } from "../cores/table-data-source"; import { DatePipe } from "@angular/common"; import { AppConfigService } from "app-config.service"; +import { FileSizePipe } from "shared/pipes/filesize.pipe"; +import { TimeDurationPipe } from "shared/pipes/time-duration.pipe"; export interface IDynamicCell { row: TableRow; @@ -332,6 +334,8 @@ export class DynamicMatTableComponent private overlayPositionBuilder: OverlayPositionBuilder, public readonly config: TableSetting, private datePipe: DatePipe, + private fileSizePipe: FileSizePipe, + private timeDurationPipe: TimeDurationPipe, public appConfigService: AppConfigService, ) { super(tableService, cdr, config); @@ -1045,13 +1049,14 @@ export class DynamicMatTableComponent getColumnValue(data: Record, column: TableField) { const fieldName = column.name.trim(); + const emptyValue = column.emptyValue ?? ""; if (fieldName.includes(",")) { const fields = fieldName.split(",").map((f) => f.trim()); const values = fields.map((field) => - this.getColumnValue(data, { name: field }), + this.getColumnValue(data, { ...column, name: field, emptyValue: "" }), ); - return values.filter((value) => value !== "").join(", "); + return values.filter((value) => value !== "").join(", ") || emptyValue; } // get nested value if name has dots @@ -1059,8 +1064,17 @@ export class DynamicMatTableComponent ? fieldName.split(".").reduce((acc, key) => acc?.[key], data) : data[fieldName]; - if (value === null || value === undefined) { - return ""; + if (value === null || value === undefined || value === "") { + return emptyValue; + } + + if (column.pipe) { + const pipedValue = this.applyColumnPipe(value, column); + return pipedValue === null || + pipedValue === undefined || + pipedValue === "" + ? emptyValue + : pipedValue; } // If column format for date is provided, format the value @@ -1074,7 +1088,11 @@ export class DynamicMatTableComponent column, ); } - return this.datePipe.transform(value as string, column.format); + const formattedValue = this.datePipe.transform( + value as string, + column.format, + ); + return formattedValue || emptyValue; } catch (e) { console.error("Date format error:", e); return value; @@ -1084,6 +1102,22 @@ export class DynamicMatTableComponent return value; } + private applyColumnPipe(value: unknown, column: TableField) { + const pipeArgs = column.pipeArgs ?? []; + + switch (column.pipe.toLowerCase()) { + case "date": + return this.datePipe.transform(value as string, pipeArgs[0] as string); + case "filesize": + return this.fileSizePipe.transform(Number(value)); + case "timeduration": + return this.timeDurationPipe.transform(Number(value)); + default: + console.log("No such pipe exists:", value, column.pipe); + return value; + } + } + metadataNameHoverContent(row: any) { if (!row.human_name) { return `Metadata name: `; 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 9ae1197ce5..6b31d934e3 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 @@ -1,5 +1,5 @@ import { NgModule, ModuleWithProviders } from "@angular/core"; -import { CommonModule } from "@angular/common"; +import { CommonModule, DatePipe } from "@angular/common"; import { MatIconModule } from "@angular/material/icon"; import { MatSortModule } from "@angular/material/sort"; import { DragDropModule } from "@angular/cdk/drag-drop"; @@ -37,6 +37,8 @@ import { ITableSetting, TableSetting } from "../models/table-setting.model"; import { PipesModule } from "shared/pipes/pipes.module"; import { EmptyContentModule } from "shared/modules/generic-empty-content/empty-content.module"; import { MatCardModule } from "@angular/material/card"; +import { FileSizePipe } from "shared/pipes/filesize.pipe"; +import { TimeDurationPipe } from "shared/pipes/time-duration.pipe"; // eslint-disable-next-line @typescript-eslint/naming-convention const ExtensionsModule = [RowMenuModule]; @@ -83,6 +85,9 @@ const ExtensionsModule = [RowMenuModule]; provide: OverlayContainer, useClass: FullscreenOverlayContainer, }, + DatePipe, + FileSizePipe, + TimeDurationPipe, ], }) diff --git a/src/app/shared/pipes/pipes.module.ts b/src/app/shared/pipes/pipes.module.ts index 08971a6c8a..8fb6408c11 100644 --- a/src/app/shared/pipes/pipes.module.ts +++ b/src/app/shared/pipes/pipes.module.ts @@ -14,6 +14,7 @@ import { NewDynamicPipe } from "./newDynamicPipe.pipe"; import { DescriptionTitlePipe } from "./description-title.pipe"; import { FormatNumberPipe } from "./format-number.pipe"; import { ComponentTranslatePipe } from "./component-translate.pipe"; +import { TimeDurationPipe } from "./time-duration.pipe"; @NgModule({ declarations: [ FileSizePipe, @@ -30,6 +31,7 @@ import { ComponentTranslatePipe } from "./component-translate.pipe"; NewDynamicPipe, DescriptionTitlePipe, ComponentTranslatePipe, + TimeDurationPipe, ], imports: [CommonModule], exports: [ @@ -47,6 +49,7 @@ import { ComponentTranslatePipe } from "./component-translate.pipe"; NewDynamicPipe, DescriptionTitlePipe, ComponentTranslatePipe, + TimeDurationPipe, ], }) export class PipesModule {} diff --git a/src/app/shared/pipes/time-duration.pipe.ts b/src/app/shared/pipes/time-duration.pipe.ts new file mode 100644 index 0000000000..91d2d9859f --- /dev/null +++ b/src/app/shared/pipes/time-duration.pipe.ts @@ -0,0 +1,21 @@ +import { Injectable, Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + name: "secondsTimeDuration", + standalone: false, +}) +@Injectable() +export class TimeDurationPipe implements PipeTransform { + transform(seconds: number): string { + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const leftMinutes = minutes % 60; + const leftSeconds = Math.round(seconds % 60); + + if (hours === 0) { + if (leftMinutes === 0) return `${leftSeconds}s`; + return `${leftMinutes}m ${leftSeconds}s`; + } + return `${hours}h ${leftMinutes}m ${leftSeconds}s`; + } +} diff --git a/src/app/state-management/actions/files.actions.spec.ts b/src/app/state-management/actions/files.actions.spec.ts index 15d4d0a02e..dca7769712 100644 --- a/src/app/state-management/actions/files.actions.spec.ts +++ b/src/app/state-management/actions/files.actions.spec.ts @@ -1,7 +1,16 @@ -import { mockOrigDatablock as origDatablock } from "shared/MockStubs"; +import { FileOrigdatablock } from "state-management/models"; import * as fromActions from "./files.actions"; describe("File Actions", () => { + const origDatablock: FileOrigdatablock = { + id: "orig-datablock-id", + datasetId: "dataset-id", + dataFileList: { + path: "/file/1", + size: 100, + time: "2019-09-06T13:11:37.102Z", + }, + }; const origDatablocks = [origDatablock]; describe("fetchAllOrigDatablocksAction", () => { @@ -69,6 +78,106 @@ describe("File Actions", () => { }); }); + describe("fetchDatasetOrigDatablocksAction", () => { + it("should create an action", () => { + const action = fromActions.fetchDatasetOrigDatablocksAction({ + datasetId: "dataset-id", + limit: 10, + skip: 0, + }); + + expect({ ...action }).toEqual({ + type: "[OrigDatablock] Fetch Dataset Orig Datablocks", + datasetId: "dataset-id", + limit: 10, + skip: 0, + }); + }); + }); + + describe("fetchDatasetOrigDatablocksCompleteAction", () => { + it("should create an action", () => { + const action = fromActions.fetchDatasetOrigDatablocksCompleteAction({ + currentDatasetOrigDatablocks: origDatablocks, + }); + + expect({ ...action }).toEqual({ + type: "[OrigDatablock] Fetch Dataset Orig Datablocks Complete", + currentDatasetOrigDatablocks: origDatablocks, + }); + }); + }); + + describe("fetchDatasetOrigDatablocksFailedAction", () => { + it("should create an action", () => { + const action = fromActions.fetchDatasetOrigDatablocksFailedAction(); + + expect({ ...action }).toEqual({ + type: "[OrigDatablock] Fetch Dataset Orig Datablocks Failed", + }); + }); + }); + + describe("selectOrigDatablockAction", () => { + it("should create an action", () => { + const action = fromActions.selectOrigDatablockAction({ origDatablock }); + + expect({ ...action }).toEqual({ + type: "[OrigDatablock] Select Orig Datablock", + origDatablock, + }); + }); + }); + + describe("deselectOrigDatablockAction", () => { + it("should create an action", () => { + const action = fromActions.deselectOrigDatablockAction({ + origDatablock, + }); + + expect({ ...action }).toEqual({ + type: "[OrigDatablock] Deselect Orig Datablock", + origDatablock, + }); + }); + }); + + describe("selectAllOrigDatablocksAction", () => { + it("should create an action", () => { + const action = fromActions.selectAllOrigDatablocksAction({ + origDatablocks, + }); + + expect({ ...action }).toEqual({ + type: "[OrigDatablock] Select All Orig Datablocks", + origDatablocks, + }); + }); + }); + + describe("deselectOrigDatablocksAction", () => { + it("should create an action", () => { + const action = fromActions.deselectOrigDatablocksAction({ + origDatablocks, + }); + + expect({ ...action }).toEqual({ + type: "[OrigDatablock] Deselect Orig Datablocks", + origDatablocks, + }); + }); + }); + + describe("clearSelectionAction", () => { + it("should create an action", () => { + const action = fromActions.clearSelectionAction(); + + expect({ ...action }).toEqual({ + type: "[OrigDatablock] Clear Selection", + }); + }); + }); + describe("fetchOrigDatablockAction", () => { it("should create an action", () => { const id = "testId"; diff --git a/src/app/state-management/actions/files.actions.ts b/src/app/state-management/actions/files.actions.ts index f938920876..5c47b6a2dc 100644 --- a/src/app/state-management/actions/files.actions.ts +++ b/src/app/state-management/actions/files.actions.ts @@ -1,5 +1,5 @@ import { createAction, props } from "@ngrx/store"; -import { OrigDatablock } from "@scicatproject/scicat-sdk-ts-angular"; +import { FileOrigdatablock } from "state-management/models"; export const fetchAllOrigDatablocksAction = createAction( "[OrigDatablock] Fetch All Orig Datablocks", @@ -13,7 +13,7 @@ export const fetchAllOrigDatablocksAction = createAction( ); export const fetchAllOrigDatablocksCompleteAction = createAction( "[OrigDatablock] Fetch All Orig Datablocks Complete", - props<{ origDatablocks: object[] }>(), + props<{ origDatablocks: FileOrigdatablock[] }>(), ); export const fetchAllOrigDatablocksFailedAction = createAction( "[OrigDatablock] Fetch All Orig Datablocks Failed", @@ -25,19 +25,60 @@ export const fetchCountAction = createAction( ); export const fetchCountCompleteAction = createAction( "[OrigDatablock] Fetch Count Complete", - props<{ count: number }>(), + props<{ count: number; fields?: Record }>(), ); export const fetchCountFailedAction = createAction( "[OrigDatablock] Fetch Count Failed", ); +// Dataset Dynamic DataFiles actions +export const fetchDatasetOrigDatablocksAction = createAction( + "[OrigDatablock] Fetch Dataset Orig Datablocks", + props<{ + datasetId?: string; + skip?: number; + limit?: number; + search?: string; + sortDirection?: string; + sortColumn?: string; + }>(), +); +export const fetchDatasetOrigDatablocksCompleteAction = createAction( + "[OrigDatablock] Fetch Dataset Orig Datablocks Complete", + props<{ currentDatasetOrigDatablocks: FileOrigdatablock[] }>(), +); +export const fetchDatasetOrigDatablocksFailedAction = createAction( + "[OrigDatablock] Fetch Dataset Orig Datablocks Failed", +); + +// DataFiles Table selection +export const selectOrigDatablockAction = createAction( + "[OrigDatablock] Select Orig Datablock", + props<{ origDatablock: FileOrigdatablock }>(), +); +export const deselectOrigDatablockAction = createAction( + "[OrigDatablock] Deselect Orig Datablock", + props<{ origDatablock: FileOrigdatablock }>(), +); +export const selectAllOrigDatablocksAction = createAction( + "[OrigDatablock] Select All Orig Datablocks", + props<{ origDatablocks: FileOrigdatablock[] }>(), +); +export const deselectOrigDatablocksAction = createAction( + "[OrigDatablock] Deselect Orig Datablocks", + props<{ origDatablocks: FileOrigdatablock[] }>(), +); +export const clearSelectionAction = createAction( + "[OrigDatablock] Clear Selection", +); + export const fetchOrigDatablockAction = createAction( "[OrigDatablock] Fetch Orig Datablock", props<{ id: string }>(), ); export const fetchOrigDatablockCompleteAction = createAction( "[OrigDatablock] Fetch Orig Datablock Complete", - props<{ origDatablock: OrigDatablock }>(), + props<{ origDatablock: FileOrigdatablock }>(), ); export const fetchOrigDatablockFailedAction = createAction( "[OrigDatablock] Fetch Orig Datablock Failed", diff --git a/src/app/state-management/effects/files.effects.spec.ts b/src/app/state-management/effects/files.effects.spec.ts index 964a9ed81e..2112775aba 100644 --- a/src/app/state-management/effects/files.effects.spec.ts +++ b/src/app/state-management/effects/files.effects.spec.ts @@ -12,13 +12,14 @@ import { Type } from "@angular/core"; import { TestObservable } from "jasmine-marbles/src/test-observables"; import { createMock } from "shared/MockStubs"; import { FilesEffects } from "./files.effects"; +import { FileOrigdatablock } from "state-management/models"; describe("FileEffects", () => { let actions: TestObservable; let effects: FilesEffects; let origDatablocksApi: jasmine.SpyObj; - const mockOrigDatablockFile = createMock({ + const mockOrigDatablockFile = createMock({ _id: "2b568c4d-7b0f-47f5-b4af-b94098d6b98f", size: 913818, dataFileList: { @@ -106,6 +107,48 @@ describe("FileEffects", () => { }); }); + describe("fetchDatasetOrigDatablocks$", () => { + describe("ofType fetchDatasetOrigDatablocksAction", () => { + it("should result in a fetchDatasetOrigDatablocksCompleteAction and a fetchCountAction", () => { + const action = fromActions.fetchDatasetOrigDatablocksAction({ + datasetId: "20.500.12269/BRIGHTNESS/V200022", + }); + const outcome1 = fromActions.fetchDatasetOrigDatablocksCompleteAction({ + currentDatasetOrigDatablocks: origDatablockFiles, + }); + const outcome2 = fromActions.fetchCountAction({ + fields: { + datasetId: "20.500.12269/BRIGHTNESS/V200022", + text: undefined, + }, + }); + + actions = hot("-a", { a: action }); + const response = cold("-a|", { a: origDatablockFiles }); + origDatablocksApi.origDatablocksControllerFullqueryFilesV3.and.returnValue( + response, + ); + + const expected = cold("--(bc)", { b: outcome1, c: outcome2 }); + expect(effects.fetchDatasetOrigDatablocks$).toBeObservable(expected); + }); + + it("should result in a fetchDatasetOrigDatablocksFailedAction", () => { + const action = fromActions.fetchDatasetOrigDatablocksAction({}); + const outcome = fromActions.fetchDatasetOrigDatablocksFailedAction(); + + actions = hot("-a", { a: action }); + const response = cold("-#", {}); + origDatablocksApi.origDatablocksControllerFullqueryFilesV3.and.returnValue( + response, + ); + + const expected = cold("--b", { b: outcome }); + expect(effects.fetchDatasetOrigDatablocks$).toBeObservable(expected); + }); + }); + }); + describe("fetchCount$", () => { it("should result in a fetchCountCompleteAction", () => { const count = origDatablockFiles.length; diff --git a/src/app/state-management/effects/files.effects.ts b/src/app/state-management/effects/files.effects.ts index 1697e70075..8eeebe1ae8 100644 --- a/src/app/state-management/effects/files.effects.ts +++ b/src/app/state-management/effects/files.effects.ts @@ -11,6 +11,7 @@ import { loadingAction, loadingCompleteAction, } from "state-management/actions/user.actions"; +import { FileOrigdatablock } from "state-management/models"; @Injectable() export class FilesEffects { @@ -38,7 +39,8 @@ export class FilesEffects { .pipe( mergeMap((origDatablocks: OrigDatablock[]) => [ fromActions.fetchAllOrigDatablocksCompleteAction({ - origDatablocks, + origDatablocks: + origDatablocks as unknown as FileOrigdatablock[], }), fromActions.fetchCountAction({ fields: queryParams, @@ -52,6 +54,50 @@ export class FilesEffects { ); }); + fetchDatasetOrigDatablocks$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.fetchDatasetOrigDatablocksAction), + switchMap( + ({ datasetId, limit, search, skip, sortColumn, sortDirection }) => { + const limitsParam = { + skip: skip, + limit: limit, + order: undefined, + }; + + if (sortColumn && sortDirection) { + limitsParam.order = `${sortColumn}:${sortDirection}`; + } + + const queryParams = { + datasetId: datasetId, + text: search || undefined, + }; + + return this.origDataBlocksService + .origDatablocksControllerFullqueryFilesV3( + JSON.stringify(limitsParam), + JSON.stringify(queryParams), + ) + .pipe( + mergeMap((origDatablocks: OrigDatablock[]) => [ + fromActions.fetchDatasetOrigDatablocksCompleteAction({ + currentDatasetOrigDatablocks: + origDatablocks as unknown as FileOrigdatablock[], + }), + fromActions.fetchCountAction({ + fields: queryParams, + }), + ]), + catchError(() => + of(fromActions.fetchDatasetOrigDatablocksFailedAction()), + ), + ); + }, + ), + ); + }); + fetchCount$ = createEffect(() => { return this.actions$.pipe( ofType(fromActions.fetchCountAction), @@ -65,7 +111,7 @@ export class FilesEffects { const { all } = res[0] as any; const count = all && all.length > 0 ? all[0].totalSets : 0; - return fromActions.fetchCountCompleteAction({ count }); + return fromActions.fetchCountCompleteAction({ count, fields }); }), catchError(() => of(fromActions.fetchCountFailedAction())), ), @@ -77,6 +123,7 @@ export class FilesEffects { return this.actions$.pipe( ofType( fromActions.fetchAllOrigDatablocksAction, + fromActions.fetchDatasetOrigDatablocksAction, fromActions.fetchCountAction, ), switchMap(() => of(loadingAction())), @@ -88,6 +135,8 @@ export class FilesEffects { ofType( fromActions.fetchAllOrigDatablocksCompleteAction, fromActions.fetchAllOrigDatablocksFailedAction, + fromActions.fetchDatasetOrigDatablocksCompleteAction, + fromActions.fetchDatasetOrigDatablocksFailedAction, fromActions.fetchCountCompleteAction, fromActions.fetchCountFailedAction, ), diff --git a/src/app/state-management/models/index.ts b/src/app/state-management/models/index.ts index 16ce256ff8..833117dc8d 100644 --- a/src/app/state-management/models/index.ts +++ b/src/app/state-management/models/index.ts @@ -19,6 +19,7 @@ export interface Settings { fe_sample_table_conditions?: ConditionConfig[]; fe_instrument_table_columns?: TableColumn[]; fe_file_table_columns?: TableColumn[]; + fe_datafiles_table_columns?: TableColumn[]; } export interface TableColumn { @@ -31,6 +32,9 @@ export interface TableColumn { enabled: boolean; format?: string; tooltip?: string; + emptyValue?: string; + pipe?: string; + pipeArgs?: unknown[]; width?: number; sort?: FieldSort; } @@ -196,6 +200,33 @@ export interface JobFilters extends GenericFilters { mode: Record | undefined; } +export interface FilesDatasetFilter { + datasetId: string; + skip: number; + limit: number; + sortField: string; +} + +export interface DataFile { + path: string; + size?: number; + time?: string; + chk?: string; + uid?: string; + gid?: string; + perm?: string; + hash?: string; + metadata?: Record; + selected?: boolean; +} + +export interface FileOrigdatablock { + id: string; + datasetId: string; + dataFileList: DataFile; + [key: string]: unknown; +} + export type ConditionSettingScope = "dataset" | "sample"; export const SETTINGS_CONFIG = [ @@ -220,6 +251,7 @@ export const SETTINGS_CONFIG = [ configKey: "columns", }, { key: "fe_file_table_columns", scope: "file", configKey: "columns" }, + { key: "fe_datafiles_table_columns", scope: "dataset", configKey: "columns" }, ]; export type SettingScope = diff --git a/src/app/state-management/reducers/files.reducer.spec.ts b/src/app/state-management/reducers/files.reducer.spec.ts index 915e7764d3..6796dafd73 100644 --- a/src/app/state-management/reducers/files.reducer.spec.ts +++ b/src/app/state-management/reducers/files.reducer.spec.ts @@ -1,9 +1,28 @@ import * as fromActions from "state-management/actions/files.actions"; import { initialFilesState } from "state-management/state/files.store"; -import { mockOrigDatablock as origDatablock } from "shared/MockStubs"; +import { FileOrigdatablock } from "state-management/models"; import { filesReducer } from "./files.reducer"; describe("FilesReducer", () => { + const origDatablock: FileOrigdatablock = { + id: "orig-datablock-id", + datasetId: "dataset-id", + dataFileList: { + path: "/file/1", + size: 100, + time: "2019-09-06T13:11:37.102Z", + }, + }; + const secondOrigDatablock: FileOrigdatablock = { + id: "orig-datablock-id", + datasetId: "dataset-id", + dataFileList: { + path: "/file/2", + size: 200, + time: "2019-09-06T13:11:37.102Z", + }, + }; + describe("on fetchAllOrigDatablocksCompleteAction", () => { it("should set origDatablocks property", () => { const origDatablocks = [origDatablock]; @@ -16,6 +35,20 @@ describe("FilesReducer", () => { }); }); + describe("on fetchDatasetOrigDatablocksCompleteAction", () => { + it("should set currentDatasetOrigDatablocks property", () => { + const currentDatasetOrigDatablocks = [origDatablock]; + const action = fromActions.fetchDatasetOrigDatablocksCompleteAction({ + currentDatasetOrigDatablocks, + }); + const state = filesReducer(initialFilesState, action); + + expect(state.currentDatasetOrigDatablocks).toEqual( + currentDatasetOrigDatablocks, + ); + }); + }); + describe("on fetchCountCompleteAction", () => { it("should set count property", () => { const count = 100; @@ -24,6 +57,17 @@ describe("FilesReducer", () => { expect(state.totalCount).toEqual(count); }); + + it("should set current dataset count if fields include datasetId", () => { + const count = 100; + const action = fromActions.fetchCountCompleteAction({ + count, + fields: { datasetId: "dataset-id" }, + }); + const state = filesReducer(initialFilesState, action); + + expect(state.currentDatasetCount).toEqual(count); + }); }); describe("on fetchOrigDatablockCompleteAction", () => { @@ -45,4 +89,91 @@ describe("FilesReducer", () => { expect(state).toEqual(initialFilesState); }); }); + + describe("on selectOrigDatablockAction", () => { + it("should add an origDatablock to selectedOrigDatablocks", () => { + const action = fromActions.selectOrigDatablockAction({ origDatablock }); + const state = filesReducer(initialFilesState, action); + + expect(state.selectedOrigDatablocks).toEqual([origDatablock]); + expect(state.selectedOrigDatablocksCount).toEqual(1); + }); + + it("should not duplicate an already selected origDatablock file", () => { + const stateWithSelection = { + ...initialFilesState, + selectedOrigDatablocks: [origDatablock], + selectedOrigDatablocksCount: 1, + }; + const action = fromActions.selectOrigDatablockAction({ origDatablock }); + const state = filesReducer(stateWithSelection, action); + + expect(state.selectedOrigDatablocks).toEqual([origDatablock]); + expect(state.selectedOrigDatablocksCount).toEqual(1); + }); + }); + + describe("on selectAllOrigDatablocksAction", () => { + it("should merge origDatablocks into selectedOrigDatablocks", () => { + const action = fromActions.selectAllOrigDatablocksAction({ + origDatablocks: [origDatablock, secondOrigDatablock], + }); + const state = filesReducer(initialFilesState, action); + + expect(state.selectedOrigDatablocks).toEqual([ + origDatablock, + secondOrigDatablock, + ]); + expect(state.selectedOrigDatablocksCount).toEqual(2); + }); + }); + + describe("on deselectOrigDatablockAction", () => { + it("should remove an origDatablock from selectedOrigDatablocks", () => { + const stateWithSelection = { + ...initialFilesState, + selectedOrigDatablocks: [origDatablock, secondOrigDatablock], + selectedOrigDatablocksCount: 2, + }; + const action = fromActions.deselectOrigDatablockAction({ + origDatablock, + }); + const state = filesReducer(stateWithSelection, action); + + expect(state.selectedOrigDatablocks).toEqual([secondOrigDatablock]); + expect(state.selectedOrigDatablocksCount).toEqual(1); + }); + }); + + describe("on deselectOrigDatablocksAction", () => { + it("should remove multiple origDatablocks from selectedOrigDatablocks", () => { + const stateWithSelection = { + ...initialFilesState, + selectedOrigDatablocks: [origDatablock, secondOrigDatablock], + selectedOrigDatablocksCount: 2, + }; + const action = fromActions.deselectOrigDatablocksAction({ + origDatablocks: [origDatablock, secondOrigDatablock], + }); + const state = filesReducer(stateWithSelection, action); + + expect(state.selectedOrigDatablocks).toEqual([]); + expect(state.selectedOrigDatablocksCount).toEqual(0); + }); + }); + + describe("on clearSelectionAction", () => { + it("should clear selectedOrigDatablocks", () => { + const stateWithSelection = { + ...initialFilesState, + selectedOrigDatablocks: [origDatablock], + selectedOrigDatablocksCount: 1, + }; + const action = fromActions.clearSelectionAction(); + const state = filesReducer(stateWithSelection, action); + + expect(state.selectedOrigDatablocks).toEqual([]); + expect(state.selectedOrigDatablocksCount).toEqual(0); + }); + }); }); diff --git a/src/app/state-management/reducers/files.reducer.ts b/src/app/state-management/reducers/files.reducer.ts index 855520249c..ecbc792452 100644 --- a/src/app/state-management/reducers/files.reducer.ts +++ b/src/app/state-management/reducers/files.reducer.ts @@ -2,8 +2,53 @@ import { createReducer, Action, on } from "@ngrx/store"; import * as fromActions from "state-management/actions/files.actions"; import { FilesState, + getFileOrigdatablockKey, initialFilesState, } from "state-management/state/files.store"; +import { FileOrigdatablock } from "state-management/models"; + +const addSelectedOrigDatablocks = ( + currentSelection: FileOrigdatablock[], + origDatablocksToSelect: FileOrigdatablock[], +) => { + const selectedByKey = new Map( + currentSelection.map((origDatablock) => [ + getFileOrigdatablockKey(origDatablock), + origDatablock, + ]), + ); + + origDatablocksToSelect.forEach((origDatablock) => { + selectedByKey.set(getFileOrigdatablockKey(origDatablock), origDatablock); + }); + + return Array.from(selectedByKey.values()); +}; + +const removeSelectedOrigDatablocks = ( + currentSelection: FileOrigdatablock[], + origDatablocksToDeselect: FileOrigdatablock[], +) => { + const deselectedKeys = new Set( + origDatablocksToDeselect.map((origDatablock) => + getFileOrigdatablockKey(origDatablock), + ), + ); + + return currentSelection.filter( + (selectedOrigDatablock) => + !deselectedKeys.has(getFileOrigdatablockKey(selectedOrigDatablock)), + ); +}; + +const updateSelectedOrigDatablocks = ( + state: FilesState, + selectedOrigDatablocks: FileOrigdatablock[], +): FilesState => ({ + ...state, + selectedOrigDatablocks, + selectedOrigDatablocksCount: selectedOrigDatablocks.length, +}); const reducer = createReducer( initialFilesState, @@ -16,13 +61,29 @@ const reducer = createReducer( ), on( - fromActions.fetchCountCompleteAction, - (state, { count }): FilesState => ({ + fromActions.fetchDatasetOrigDatablocksCompleteAction, + (state, { currentDatasetOrigDatablocks }): FilesState => ({ ...state, - totalCount: count, + currentDatasetOrigDatablocks, }), ), + on( + fromActions.fetchCountCompleteAction, + (state, { count, fields }): FilesState => { + if (fields?.datasetId) { + return { + ...state, + currentDatasetCount: count, + }; + } + return { + ...state, + totalCount: count, + }; + }, + ), + on( fromActions.fetchOrigDatablockCompleteAction, (state, { origDatablock }): FilesState => ({ @@ -37,10 +98,58 @@ const reducer = createReducer( ...initialFilesState, }), ), + + on( + fromActions.selectOrigDatablockAction, + (state, { origDatablock }): FilesState => + updateSelectedOrigDatablocks( + state, + addSelectedOrigDatablocks(state.selectedOrigDatablocks, [ + origDatablock, + ]), + ), + ), + + on( + fromActions.selectAllOrigDatablocksAction, + (state, { origDatablocks }): FilesState => + updateSelectedOrigDatablocks( + state, + addSelectedOrigDatablocks(state.selectedOrigDatablocks, origDatablocks), + ), + ), + + on( + fromActions.deselectOrigDatablockAction, + (state, { origDatablock }): FilesState => + updateSelectedOrigDatablocks( + state, + removeSelectedOrigDatablocks(state.selectedOrigDatablocks, [ + origDatablock, + ]), + ), + ), + + on( + fromActions.deselectOrigDatablocksAction, + (state, { origDatablocks }): FilesState => + updateSelectedOrigDatablocks( + state, + removeSelectedOrigDatablocks( + state.selectedOrigDatablocks, + origDatablocks, + ), + ), + ), + + on( + fromActions.clearSelectionAction, + (state): FilesState => updateSelectedOrigDatablocks(state, []), + ), ); export const filesReducer = (state: FilesState | undefined, action: Action) => { - if (action.type.indexOf("[Orig]") !== -1) { + if (action.type.indexOf("[OrigDatablock]") !== -1) { console.log("Action came in! " + action.type); } return reducer(state, action); diff --git a/src/app/state-management/selectors/files.selectors.spec.ts b/src/app/state-management/selectors/files.selectors.spec.ts index 83de12a4f1..45eba41a43 100644 --- a/src/app/state-management/selectors/files.selectors.spec.ts +++ b/src/app/state-management/selectors/files.selectors.spec.ts @@ -1,7 +1,10 @@ import * as fromSelectors from "./files.selectors"; import { selectSettings } from "./user.selectors"; -import { GenericFilters } from "state-management/models"; -import { mockOrigDatablock as origDatablock } from "shared/MockStubs"; +import { + GenericFilters, + FilesDatasetFilter, + FileOrigdatablock, +} from "state-management/models"; import { FilesState } from "state-management/state/files.store"; import { initialUserState } from "state-management/state/user.store"; @@ -11,12 +14,34 @@ const filesFilters: GenericFilters = { limit: 25, }; +const filesDatasetFilter: FilesDatasetFilter = { + sortField: "name desc", + skip: 0, + limit: 25, + datasetId: "", +}; + +const origDatablock: FileOrigdatablock = { + id: "orig-datablock-id", + datasetId: "dataset-id", + dataFileList: { + path: "/file/1", + size: 100, + time: "2019-09-06T13:11:37.102Z", + }, +}; + const initialFilesState: FilesState = { origDatablocks: [], + currentDatasetOrigDatablocks: [origDatablock], currentOrigDatablock: origDatablock, + selectedOrigDatablocks: [origDatablock], totalCount: 0, + currentDatasetCount: 1, + selectedOrigDatablocksCount: 1, filters: filesFilters, + datasetFilter: filesDatasetFilter, }; describe("Files Selectors", () => { @@ -36,6 +61,16 @@ describe("Files Selectors", () => { }); }); + describe("selectCurrentDatasetOrigDatablocks", () => { + it("should select current dataset origDatablocks", () => { + expect( + fromSelectors.selectCurrentDatasetOrigDatablocks.projector( + initialFilesState, + ), + ).toEqual([origDatablock]); + }); + }); + describe("selectOrigDatablocksCount", () => { it("should select the total origDatablocks count", () => { expect( @@ -44,6 +79,34 @@ describe("Files Selectors", () => { }); }); + describe("selectCurrentDatasetOrigDatablocksCount", () => { + it("should select the current dataset origDatablocks count", () => { + expect( + fromSelectors.selectCurrentDatasetOrigDatablocksCount.projector( + initialFilesState, + ), + ).toEqual(1); + }); + }); + + describe("selectSelectedOrigDatablocks", () => { + it("should select selected origDatablocks", () => { + expect( + fromSelectors.selectSelectedOrigDatablocks.projector(initialFilesState), + ).toEqual([origDatablock]); + }); + }); + + describe("selectSelectedOrigDatablocksCount", () => { + it("should select selected origDatablocks count", () => { + expect( + fromSelectors.selectSelectedOrigDatablocksCount.projector( + initialFilesState, + ), + ).toEqual(1); + }); + }); + describe("selectFilesWithCountAndTableSettings", () => { it("should select the origDatablocks with count and table settings", () => { expect( @@ -61,4 +124,30 @@ describe("Files Selectors", () => { }); }); }); + + describe("selectCurrentDatasetFilesWithCountAndTableSettings", () => { + it("should select current dataset origDatablocks with count, selection, and table settings", () => { + expect( + fromSelectors.selectCurrentDatasetFilesWithCountAndTableSettings.projector( + fromSelectors.selectCurrentDatasetOrigDatablocks.projector( + initialFilesState, + ), + fromSelectors.selectCurrentDatasetOrigDatablocksCount.projector( + initialFilesState, + ), + fromSelectors.selectSelectedOrigDatablocks.projector( + initialFilesState, + ), + selectSettings.projector(initialUserState), + ), + ).toEqual({ + origDatablocks: [origDatablock], + count: 1, + selectedOrigDatablocks: [origDatablock], + tablesSettings: { + columns: initialUserState.settings.fe_datafiles_table_columns, + }, + }); + }); + }); }); diff --git a/src/app/state-management/selectors/files.selectors.ts b/src/app/state-management/selectors/files.selectors.ts index 32c8bf6983..b4c1f11556 100644 --- a/src/app/state-management/selectors/files.selectors.ts +++ b/src/app/state-management/selectors/files.selectors.ts @@ -9,6 +9,11 @@ export const selectAllOrigDatablocks = createSelector( (state) => state.origDatablocks, ); +export const selectCurrentDatasetOrigDatablocks = createSelector( + selectFilesState, + (state) => state.currentDatasetOrigDatablocks, +); + export const selectCurrentOrigDatablock = createSelector( selectFilesState, (state) => state.currentOrigDatablock, @@ -19,6 +24,26 @@ export const selectOrigDatablocksCount = createSelector( (state) => state.totalCount, ); +export const selectCurrentDatasetOrigDatablocksCount = createSelector( + selectFilesState, + (state) => state.currentDatasetCount, +); + +export const selectSelectedOrigDatablocks = createSelector( + selectFilesState, + (state) => state.selectedOrigDatablocks, +); + +export const selectSelectedOrigDatablocksCount = createSelector( + selectFilesState, + (state) => state.selectedOrigDatablocksCount, +); + +export const selectDatasetFilter = createSelector( + selectFilesState, + (state) => state.datasetFilter, +); + export const selectFilesWithCountAndTableSettings = createSelector( selectAllOrigDatablocks, selectOrigDatablocksCount, @@ -31,3 +56,19 @@ export const selectFilesWithCountAndTableSettings = createSelector( }, }), ); + +export const selectCurrentDatasetFilesWithCountAndTableSettings = + createSelector( + selectCurrentDatasetOrigDatablocks, + selectCurrentDatasetOrigDatablocksCount, + selectSelectedOrigDatablocks, + selectSettings, + (origDatablocks, count, selectedOrigDatablocks, settings) => ({ + origDatablocks, + count, + selectedOrigDatablocks, + tablesSettings: { + columns: settings.fe_datafiles_table_columns, + }, + }), + ); diff --git a/src/app/state-management/state/files.store.ts b/src/app/state-management/state/files.store.ts index 896ed4a4ee..976ea59709 100644 --- a/src/app/state-management/state/files.store.ts +++ b/src/app/state-management/state/files.store.ts @@ -1,23 +1,53 @@ -import { GenericFilters } from "state-management/models"; +import { + GenericFilters, + FilesDatasetFilter, + FileOrigdatablock, +} from "state-management/models"; + +export const getFileOrigdatablockKey = ( + origDatablock: FileOrigdatablock, +): string => + [origDatablock.datasetId ?? "", origDatablock.dataFileList?.path ?? ""].join( + ":", + ); export interface FilesState { - origDatablocks: object[]; - currentOrigDatablock: object | undefined; + origDatablocks: FileOrigdatablock[]; + currentOrigDatablock: FileOrigdatablock | undefined; + currentDatasetOrigDatablocks: FileOrigdatablock[]; + + selectedOrigDatablocks: FileOrigdatablock[]; totalCount: number; + currentDatasetCount: number; + + selectedOrigDatablocksCount: number; filters: GenericFilters; + + datasetFilter: FilesDatasetFilter; } export const initialFilesState: FilesState = { origDatablocks: [], currentOrigDatablock: undefined, + currentDatasetOrigDatablocks: [], + selectedOrigDatablocks: [], totalCount: 0, + currentDatasetCount: 0, + selectedOrigDatablocksCount: 0, filters: { sortField: "createdAt desc", skip: 0, limit: 25, }, + + datasetFilter: { + datasetId: "", + skip: 0, + limit: 25, + sortField: "createdAt desc", + }, }; diff --git a/src/app/state-management/state/user.store.ts b/src/app/state-management/state/user.store.ts index 9e01a3c661..9c62f44588 100644 --- a/src/app/state-management/state/user.store.ts +++ b/src/app/state-management/state/user.store.ts @@ -79,6 +79,7 @@ export const initialUserState: UserState = { fe_sample_table_conditions: [], fe_instrument_table_columns: [], fe_file_table_columns: [], + fe_datafiles_table_columns: [], }, // TODO sync with server settings? message: undefined, diff --git a/src/assets/config.json b/src/assets/config.json index 887725a3c1..24e4e76156 100644 --- a/src/assets/config.json +++ b/src/assets/config.json @@ -79,6 +79,7 @@ "notificationInterceptorEnabled": true, "metadataEditingUnitListDisabled": true, "hideEmptyMetadataTable": false, + "dynamicDatafilesViewEnabled": false, "datafilesActionsEnabled": true, "datafilesActions": [ { @@ -677,6 +678,54 @@ } ] }, + "defaultDatafilesColumnsList": [ + { + "name": "dataFileList.path", + "type": "standard", + "icon": "text_snippet", + "header": "Filename", + "enabled": true + }, + { + "name": "dataFileList.size", + "type": "standard", + "icon": "save", + "header": "Size", + "pipe": "filesize" + }, + { + "name": "dataFileList.metadata.measurement_type", + "type": "standard", + "icon": "person", + "header": "Measurement type" + }, + { + "name": "dataFileList.metadata.sample_type", + "type": "standard", + "icon": "person", + "header": "Sample type" + }, + { + "name": "dataFileList.metadata.measurement_subtype", + "type": "standard", + "icon": "person", + "header": "Measurement Subtype" + }, + { + "name": "dataFileList.metadata.duration", + "type": "standard", + "icon": "access_time", + "header": "Duration", + "pipe": "timeDuration" + }, + { + "name": "dataFileList.time", + "type": "date", + "icon": "access_time", + "header": "Created at", + "format": "yyyy-mm-dd HH:MM" + } + ], "labelsLocalization": { "dataset": { "pid": "PID", diff --git a/src/styles.scss b/src/styles.scss index 59d65484af..b31520ea76 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -6,7 +6,7 @@ @use "./app/_layout/app-header/app-header-theme" as app-header; @use "./app/datasets/batch-view/batch-view-theme" as batch-view; @use "./app/datasets/dashboard/dashboard-theme" as dashboard; -@use "./app/datasets/datafiles/datafiles-theme" as datafiles; +@use "./app/datasets/datafiles/static-datafiles/datafiles-theme" as datafiles; @use "./app/datasets/dataset-detail/dataset-detail/dataset-detail-theme" as dataset-detail; @use "./app/datasets/dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic-theme"