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 6956393a6..bef0238b8 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 @@ -10,6 +10,7 @@ import { DatasetDetailComponent } from "datasets/dataset-detail/dataset-detail.c import { DatasetFileUploaderComponent } from "datasets/dataset-file-uploader/dataset-file-uploader.component"; import { DatasetLifecycleComponent } from "datasets/dataset-lifecycle/dataset-lifecycle.component"; import { ReduceComponent } from "datasets/reduce/reduce.component"; +import { RelatedDatasetsComponent } from "datasets/related-datasets/related-datasets.component"; import { LogbooksDashboardComponent } from "logbooks/logbooks-dashboard/logbooks-dashboard.component"; const routes: Routes = [ { @@ -22,6 +23,10 @@ const routes: Routes = [ component: DatafilesComponent, }, + { + path: "related-datasets", + component: RelatedDatasetsComponent, + }, // For reduce && logbook this is a work around because guard priority somehow doesn't work and this work around make guards excuted sequencial // Expected behavior should be that ServiceGuard return false should have higher priority than AuthGuard therefore it shoulds navigate to /404 instead of /login { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 1513bfda1..876868266 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -81,4 +81,4 @@ const appThemeInitializerFn = (appTheme: AppThemeService) => { ], bootstrap: [AppComponent], }) -export class AppModule {} +export class AppModule { } diff --git a/src/app/datasets/dataset-detail/dataset-detail.component.ts b/src/app/datasets/dataset-detail/dataset-detail.component.ts index 00fe9165d..be9a06f3a 100644 --- a/src/app/datasets/dataset-detail/dataset-detail.component.ts +++ b/src/app/datasets/dataset-detail/dataset-detail.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, OnInit, OnDestroy } from "@angular/core"; +import { Component, OnInit, OnDestroy } from "@angular/core"; import { Dataset, Proposal, Sample } from "shared/sdk/models"; import { ENTER, COMMA, SPACE } from "@angular/cdk/keycodes"; import { MatChipInputEvent } from "@angular/material/chips"; @@ -74,7 +74,7 @@ export class DatasetDetailComponent public dialog: MatDialog, private store: Store, private router: Router - ) { } + ) {} ngOnInit() { this.subscriptions.push( 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 f3d390ce2..59204da37 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 @@ -32,38 +32,37 @@ describe("DetailsDashboardComponent", () => { editMetadataEnabled: true, }); - beforeEach( - waitForAsync(() => { - TestBed.configureTestingModule({ - schemas: [NO_ERRORS_SCHEMA], - declarations: [DatasetDetailsDashboardComponent], - imports: [ - MatButtonModule, - MatIconModule, - MatSlideToggleModule, - MatTabsModule, - SharedScicatFrontendModule, - StoreModule.forRoot({}), - ], - }); - TestBed.overrideComponent(DatasetDetailsDashboardComponent, { - set: { - providers: [ - { provide: Router, useValue: router }, - { - provide: AppConfigService, - useValue: { - getConfig, - }, + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [DatasetDetailsDashboardComponent], + imports: [ + MatButtonModule, + MatIconModule, + MatSlideToggleModule, + MatTabsModule, + SharedScicatFrontendModule, + StoreModule.forRoot({}), + ], + providers: [], + }); + TestBed.overrideComponent(DatasetDetailsDashboardComponent, { + set: { + providers: [ + { provide: Router, useValue: router }, + { + provide: AppConfigService, + useValue: { + getConfig, }, - { provide: ActivatedRoute, useClass: MockActivatedRoute }, - { provide: UserApi, useClass: MockUserApi }, - ], - }, - }); - TestBed.compileComponents(); - }) - ); + }, + { provide: ActivatedRoute, useClass: MockActivatedRoute }, + { provide: UserApi, useClass: MockUserApi }, + ], + }, + }); + TestBed.compileComponents(); + })); beforeEach(() => { fixture = TestBed.createComponent(DatasetDetailsDashboardComponent); 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 5249c8a99..e04223589 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 @@ -16,13 +16,14 @@ import { } from "state-management/selectors/user.selectors"; import { ActivatedRoute } from "@angular/router"; import { Subscription, Observable, combineLatest } from "rxjs"; -import { map, pluck, takeWhile } from "rxjs/operators"; +import { map, pluck } from "rxjs/operators"; import { clearCurrentDatasetStateAction, fetchAttachmentsAction, fetchDatablocksAction, fetchDatasetAction, fetchOrigDatablocksAction, + fetchRelatedDatasetsAction, } from "state-management/actions/datasets.actions"; import { clearLogbookAction, @@ -51,6 +52,7 @@ export interface FileObject { enum TAB { details = "Details", datafiles = "Datafiles", + relatedDatasets = "Related Datasets", reduce = "Reduce", logbook = "Logbook", attachments = "Attachments", @@ -81,6 +83,10 @@ export class DatasetDetailsDashboardComponent }[] = []; fetchDataActions: { [tab: string]: { action: any; loaded: boolean } } = { [TAB.details]: { action: fetchDatasetAction, loaded: false }, + [TAB.relatedDatasets]: { + action: fetchRelatedDatasetsAction, + loaded: false, + }, [TAB.datafiles]: { action: fetchOrigDatablocksAction, loaded: false }, [TAB.logbook]: { action: fetchLogbookAction, loaded: false }, [TAB.attachments]: { action: fetchAttachmentsAction, loaded: false }, @@ -99,23 +105,24 @@ export class DatasetDetailsDashboardComponent private store: Store, private userApi: UserApi, public dialog: MatDialog - ) {} + ) { } ngOnInit() { - this.route.params - .pipe(pluck("id")) - .subscribe((id: string) => { + this.subscriptions.push( + this.route.params.pipe(pluck("id")).subscribe((id: string) => { if (id) { + this.resetTabs(); // Fetch dataset details this.store.dispatch(fetchDatasetAction({ pid: id })); this.fetchDataActions[TAB.details].loaded = true; } }) - .unsubscribe(); + ); + const datasetSub = this.dataset$ - .pipe(takeWhile((dataset) => !dataset, true)) .subscribe((dataset) => { - if (dataset) { + // Only run this code when dataset.pid is different from this.dataset.pid or this.dataset = null + if (dataset && (!this.dataset || this.dataset && (dataset.pid != this.dataset.pid))) { this.dataset = dataset; combineLatest([this.accessGroups$, this.isAdmin$, this.loggedIn$]) .subscribe(([groups, isAdmin, isLoggedIn]) => { @@ -134,6 +141,12 @@ export class DatasetDetailsDashboardComponent icon: "cloud_download", enabled: true, }, + { + location: "./related-datasets", + label: TAB.relatedDatasets, + icon: "folder", + enabled: true, + }, { location: "./reduce", label: TAB.reduce, @@ -181,28 +194,17 @@ export class DatasetDetailsDashboardComponent }) .unsubscribe(); - if ("proposalId" in dataset) { - this.store.dispatch( - fetchProposalAction({ proposalId: dataset["proposalId"] }) - ); - } else { - this.store.dispatch(clearLogbookAction()); - } - if ("sampleId" in dataset) { - this.store.dispatch( - fetchSampleAction({ sampleId: dataset["sampleId"] }) - ); - } - if ("instrumentId" in dataset) { - this.store.dispatch( - fetchInstrumentAction({ pid: dataset["instrumentId"] }) - ); - } + this.fetchDatasetRelatedDocuments(); } }); this.subscriptions.push(datasetSub); this.jwt$ = this.userApi.jwt(); - }; + } + resetTabs() { + Object.values(this.fetchDataActions).forEach(tab => { + tab.loaded = false; + }); + } onTabSelected(tab: string) { this.fetchDataForTab(tab); } @@ -239,6 +241,29 @@ export class DatasetDetailsDashboardComponent } } } + + fetchDatasetRelatedDocuments(): void { + if (this.dataset) { + if ("proposalId" in this.dataset) { + this.store.dispatch( + fetchProposalAction({ proposalId: this.dataset["proposalId"] }) + ); + } else { + this.store.dispatch(clearLogbookAction()); + } + if ("sampleId" in this.dataset) { + this.store.dispatch( + fetchSampleAction({ sampleId: this.dataset["sampleId"] }) + ); + } + if ("instrumentId" in this.dataset) { + this.store.dispatch( + fetchInstrumentAction({ pid: this.dataset["instrumentId"] }) + ); + } + } + } + ngAfterViewChecked() { this.cdRef.detectChanges(); } diff --git a/src/app/datasets/dataset-lifecycle/dataset-lifecycle.component.ts b/src/app/datasets/dataset-lifecycle/dataset-lifecycle.component.ts index 4251f6e71..730fbd31e 100644 --- a/src/app/datasets/dataset-lifecycle/dataset-lifecycle.component.ts +++ b/src/app/datasets/dataset-lifecycle/dataset-lifecycle.component.ts @@ -59,7 +59,7 @@ export class DatasetLifecycleComponent implements OnInit, OnChanges { ) {} private parseHistoryItems(): HistoryItem[] { - if (this.dataset) { + if (this.dataset && this.dataset.history) { const history = this.dataset.history.map( ({ updatedAt, updatedBy, id, ...properties }) => Object.keys(properties).map( diff --git a/src/app/datasets/datasets.module.ts b/src/app/datasets/datasets.module.ts index 610922a0d..74d3c78a4 100644 --- a/src/app/datasets/datasets.module.ts +++ b/src/app/datasets/datasets.module.ts @@ -79,6 +79,7 @@ import { DatasetFileUploaderComponent } from "./dataset-file-uploader/dataset-fi import { AdminTabComponent } from "./admin-tab/admin-tab.component"; import { instrumentsReducer } from "state-management/reducers/instruments.reducer"; import { InstrumentEffects } from "state-management/effects/instruments.effects"; +import { RelatedDatasetsComponent } from "./related-datasets/related-datasets.component"; @NgModule({ imports: [ @@ -155,6 +156,7 @@ import { InstrumentEffects } from "state-management/effects/instruments.effects" ShareDialogComponent, DatasetFileUploaderComponent, AdminTabComponent, + RelatedDatasetsComponent, ], providers: [ ArchivingService, diff --git a/src/app/datasets/related-datasets/related-datasets.component.html b/src/app/datasets/related-datasets/related-datasets.component.html new file mode 100644 index 000000000..d3b8ba930 --- /dev/null +++ b/src/app/datasets/related-datasets/related-datasets.component.html @@ -0,0 +1,12 @@ +
+ +
diff --git a/src/app/datasets/related-datasets/related-datasets.component.scss b/src/app/datasets/related-datasets/related-datasets.component.scss new file mode 100644 index 000000000..e13431329 --- /dev/null +++ b/src/app/datasets/related-datasets/related-datasets.component.scss @@ -0,0 +1,3 @@ +.dataset-table { + margin-top: 5em; +} \ No newline at end of file diff --git a/src/app/datasets/related-datasets/related-datasets.component.spec.ts b/src/app/datasets/related-datasets/related-datasets.component.spec.ts new file mode 100644 index 000000000..023d499b0 --- /dev/null +++ b/src/app/datasets/related-datasets/related-datasets.component.spec.ts @@ -0,0 +1,98 @@ +import { DatePipe } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { Store } from "@ngrx/store"; +import { provideMockStore } from "@ngrx/store/testing"; +import { PageChangeEvent } from "shared/modules/table/table.component"; +import { Dataset } from "shared/sdk"; +import { + changeRelatedDatasetsPageAction, + fetchRelatedDatasetsAction, +} from "state-management/actions/datasets.actions"; +import { selectRelatedDatasetsPageViewModel } from "state-management/selectors/datasets.selectors"; + +import { RelatedDatasetsComponent } from "./related-datasets.component"; + +describe("RelatedDatasetsComponent", () => { + let component: RelatedDatasetsComponent; + let fixture: ComponentFixture; + + const router = { + navigateByUrl: jasmine.createSpy("navigateByUrl"), + }; + let store: Store; + let dispatchSpy; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RelatedDatasetsComponent], + providers: [ + DatePipe, + provideMockStore({ + selectors: [ + { + selector: selectRelatedDatasetsPageViewModel, + value: { + relatedDatasets: [], + relatedDatasetsCount: 0, + relatedDatasetsFilters: { + skip: 0, + limit: 25, + sortField: "creationTime:desc", + }, + }, + }, + ], + }), + { provide: Router, useValue: router }, + ], + }).compileComponents(); + + store = TestBed.inject(Store); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RelatedDatasetsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("#onPageChange", () => { + it("should dispatch a changeRelatedDatasetsPageAction and a fetchRelatedDatasetsAction", () => { + dispatchSpy = spyOn(store, "dispatch"); + + const event: PageChangeEvent = { + pageIndex: 0, + pageSize: 25, + length: 25, + }; + + component.onPageChange(event); + + expect(dispatchSpy).toHaveBeenCalledTimes(2); + expect(dispatchSpy).toHaveBeenCalledWith( + changeRelatedDatasetsPageAction({ + page: event.pageIndex, + limit: event.pageSize, + }) + ); + expect(dispatchSpy).toHaveBeenCalledWith(fetchRelatedDatasetsAction()); + }); + }); + + describe("#onRowClick()", () => { + it("should navigate to a dataset", () => { + const dataset = new Dataset(); + + component.onRowClick(dataset); + + expect(router.navigateByUrl).toHaveBeenCalledOnceWith( + "/datasets/" + encodeURIComponent(dataset.pid) + ); + }); + }); +}); diff --git a/src/app/datasets/related-datasets/related-datasets.component.ts b/src/app/datasets/related-datasets/related-datasets.component.ts new file mode 100644 index 000000000..4a6a4eded --- /dev/null +++ b/src/app/datasets/related-datasets/related-datasets.component.ts @@ -0,0 +1,109 @@ +import { DatePipe } from "@angular/common"; +import { Component } from "@angular/core"; +import { Router } from "@angular/router"; +import { Store } from "@ngrx/store"; +import { map } from "rxjs/operators"; +import { + PageChangeEvent, + TableColumn, +} from "shared/modules/table/table.component"; +import { Dataset } from "shared/sdk"; +import { + changeRelatedDatasetsPageAction, + fetchRelatedDatasetsAction, +} from "state-management/actions/datasets.actions"; +import { selectRelatedDatasetsPageViewModel } from "state-management/selectors/datasets.selectors"; + +@Component({ + selector: "app-related-datasets", + templateUrl: "./related-datasets.component.html", + styleUrls: ["./related-datasets.component.scss"], +}) +export class RelatedDatasetsComponent { + vm$ = this.store.select(selectRelatedDatasetsPageViewModel).pipe( + map((vm) => ({ + ...vm, + relatedDatasets: this.formatTableData(vm.relatedDatasets), + })) + ); + + tablePaginate = true; + tableColumns: TableColumn[] = [ + { + name: "name", + icon: "portrait", + sort: true, + inList: true, + }, + { + name: "sourceFolder", + icon: "explore", + sort: true, + inList: true, + }, + { + name: "size", + icon: "save", + sort: true, + inList: true, + }, + { + name: "type", + icon: "bubble_chart", + sort: true, + inList: true, + }, + { + name: "creationTime", + icon: "calendar_today", + sort: true, + inList: true, + }, + { + name: "owner", + icon: "face", + sort: true, + inList: true, + }, + ]; + + constructor( + private datePipe: DatePipe, + private router: Router, + private store: Store + ) { } + + formatTableData(datasets: Dataset[]): Record[] { + if (!datasets) { + return []; + } + + return datasets.map((dataset) => ({ + pid: dataset.pid, + name: dataset.datasetName, + sourceFolder: dataset.sourceFolder, + size: dataset.size, + type: dataset.type, + creationTime: this.datePipe.transform( + dataset.creationTime, + "yyyy-MM-dd, hh:mm" + ), + owner: dataset.owner, + })); + } + + onPageChange(event: PageChangeEvent): void { + this.store.dispatch( + changeRelatedDatasetsPageAction({ + page: event.pageIndex, + limit: event.pageSize, + }) + ); + this.store.dispatch(fetchRelatedDatasetsAction()); + } + + onRowClick(dataset: Dataset): void { + const pid = encodeURIComponent(dataset.pid); + this.router.navigateByUrl("/datasets/" + pid); + } +} diff --git a/src/app/state-management/actions/datasets.actions.spec.ts b/src/app/state-management/actions/datasets.actions.spec.ts index 37e3de096..aa888ddd3 100644 --- a/src/app/state-management/actions/datasets.actions.spec.ts +++ b/src/app/state-management/actions/datasets.actions.spec.ts @@ -126,6 +126,75 @@ describe("Dataset Actions", () => { }); }); + describe("fetchRelatedDatasetsAction", () => { + it("should create an action", () => { + const action = fromActions.fetchRelatedDatasetsAction(); + expect({ ...action }).toEqual({ + type: "[Dataset] Fetch Related Datasets", + }); + }); + }); + + describe("fetchRelatedDatasetsCompleteAction", () => { + it("should create an action", () => { + const relatedDatasets = [new Dataset()]; + const action = fromActions.fetchRelatedDatasetsCompleteAction({ + relatedDatasets, + }); + expect({ ...action }).toEqual({ + type: "[Dataset] Fetch Related Datasets Complete", + relatedDatasets, + }); + }); + }); + + describe("fetchRelatedDatasetsFailedAction", () => { + it("should create an action", () => { + const action = fromActions.fetchRelatedDatasetsFailedAction(); + expect({ ...action }).toEqual({ + type: "[Datasets] Fetch Related Datasets Failed", + }); + }); + }); + + describe("fetchRelatedDatasetsCountCompleteAction", () => { + it("should create an action", () => { + const count = 0; + const action = fromActions.fetchRelatedDatasetsCountCompleteAction({ + count, + }); + expect({ ...action }).toEqual({ + type: "[Dataset] Fetch Related Datasets Count Complete", + count, + }); + }); + }); + + describe("fetchRelatedDatasetsCountFailedAction", () => { + it("should create an action", () => { + const action = fromActions.fetchRelatedDatasetsCountFailedAction(); + expect({ ...action }).toEqual({ + type: "[Datasets] Fetch Related Datasets Count Failed", + }); + }); + }); + + describe("changeRelatedDatasetsPageAction", () => { + it("should create an action", () => { + const page = 0; + const limit = 25; + const action = fromActions.changeRelatedDatasetsPageAction({ + page, + limit, + }); + expect({ ...action }).toEqual({ + type: "[Dataset] Change Related Datasets Page", + page, + limit, + }); + }); + }); + describe("prefillBatchAction", () => { it("should create an action", () => { const action = fromActions.prefillBatchAction(); diff --git a/src/app/state-management/actions/datasets.actions.ts b/src/app/state-management/actions/datasets.actions.ts index bb63b0b38..40c202036 100644 --- a/src/app/state-management/actions/datasets.actions.ts +++ b/src/app/state-management/actions/datasets.actions.ts @@ -1,5 +1,11 @@ import { createAction, props } from "@ngrx/store"; -import { Dataset, Attachment, DerivedDataset, OrigDatablock, Datablock } from "shared/sdk"; +import { + Dataset, + Attachment, + DerivedDataset, + OrigDatablock, + Datablock, +} from "shared/sdk"; import { FacetCounts } from "state-management/state/datasets.store"; import { ArchViewMode, @@ -86,6 +92,30 @@ export const fetchAttachmentsFailedAction = createAction( "[Dataset] Fetch Attachments Failed" ); +export const fetchRelatedDatasetsAction = createAction( + "[Dataset] Fetch Related Datasets" +); +export const fetchRelatedDatasetsCompleteAction = createAction( + "[Dataset] Fetch Related Datasets Complete", + props<{ relatedDatasets: Dataset[] }>() +); +export const fetchRelatedDatasetsFailedAction = createAction( + "[Datasets] Fetch Related Datasets Failed" +); + +export const fetchRelatedDatasetsCountCompleteAction = createAction( + "[Dataset] Fetch Related Datasets Count Complete", + props<{ count: number }>() +); +export const fetchRelatedDatasetsCountFailedAction = createAction( + "[Datasets] Fetch Related Datasets Count Failed" +); + +export const changeRelatedDatasetsPageAction = createAction( + "[Dataset] Change Related Datasets Page", + props<{ page: number; limit: number }>() +); + export const prefillBatchAction = createAction("[Dataset] Prefill Batch"); export const prefillBatchCompleteAction = createAction( "[Dataset] Prefill Batch Complete", @@ -222,7 +252,7 @@ export const setArchiveViewModeAction = createAction( ); export const setPublicViewModeAction = createAction( "[Dataset] Set Public View Mode", - props<{ isPublished: boolean | ""}>() + props<{ isPublished: boolean | "" }>() ); export const prefillFiltersAction = createAction( @@ -288,4 +318,6 @@ export const removeScientificConditionAction = createAction( export const clearDatasetsStateAction = createAction("[Dataset] Clear State"); -export const clearCurrentDatasetStateAction = createAction("[Dataset] Clear Current Dataset State"); +export const clearCurrentDatasetStateAction = createAction( + "[Dataset] Clear Current Dataset State" +); diff --git a/src/app/state-management/effects/datasets.effects.spec.ts b/src/app/state-management/effects/datasets.effects.spec.ts index ca33226c0..fea8c849b 100644 --- a/src/app/state-management/effects/datasets.effects.spec.ts +++ b/src/app/state-management/effects/datasets.effects.spec.ts @@ -17,6 +17,8 @@ import { FacetCounts } from "state-management/state/datasets.store"; import { selectFullqueryParams, selectFullfacetParams, + selectCurrentDataset, + selectRelatedDatasetsFilters, } from "state-management/selectors/datasets.selectors"; import { loadingAction, @@ -63,6 +65,11 @@ describe("DatasetEffects", () => { provideMockActions(() => actions), provideMockStore({ selectors: [ + { selector: selectCurrentDataset, value: dataset }, + { + selector: selectRelatedDatasetsFilters, + value: { skip: 0, limit: 25, sortField: "creationTime:desc" }, + }, { selector: selectFullqueryParams, value: { @@ -80,6 +87,7 @@ describe("DatasetEffects", () => { "fullquery", "fullfacet", "metadataKeys", + "find", "findOne", "updateAttributes", "createAttachments", @@ -306,6 +314,62 @@ describe("DatasetEffects", () => { }); }); + describe("fetchRelatedDatasets$", () => { + it("should result in a fetchRelatedDatasetsCompleteAction", () => { + const relatedDatasets = [dataset]; + const action = fromActions.fetchRelatedDatasetsAction(); + const outcome = fromActions.fetchRelatedDatasetsCompleteAction({ + relatedDatasets, + }); + + actions = hot("-a", { a: action }); + const response = cold("-a|", { a: relatedDatasets }); + datasetApi.find.and.returnValue(response); + + const expected = cold("--b", { b: outcome }); + expect(effects.fetchRelatedDatasets$).toBeObservable(expected); + }); + it("should result in a fetchRelatedDatasetsFailedAction", () => { + const action = fromActions.fetchRelatedDatasetsAction(); + const outcome = fromActions.fetchRelatedDatasetsFailedAction(); + + actions = hot("-a", { a: action }); + const response = cold("-#", {}); + datasetApi.find.and.returnValue(response); + + const expected = cold("--b", { b: outcome }); + expect(effects.fetchRelatedDatasets$).toBeObservable(expected); + }); + }); + + describe("fetchRelatedDatasetsCount$", () => { + it("should result in a fetchRelatedDatasetsCountCompleteAction", () => { + const relatedDatasets = [dataset]; + const action = fromActions.fetchRelatedDatasetsAction(); + const outcome = fromActions.fetchRelatedDatasetsCountCompleteAction({ + count: relatedDatasets.length, + }); + + actions = hot("-a", { a: action }); + const response = cold("-a|", { a: relatedDatasets }); + datasetApi.find.and.returnValue(response); + + const expected = cold("--b", { b: outcome }); + expect(effects.fetchRelatedDatasetsCount$).toBeObservable(expected); + }); + it("should result in a fetchRelatedDatasetsCountFailedAction", () => { + const action = fromActions.fetchRelatedDatasetsAction(); + const outcome = fromActions.fetchRelatedDatasetsCountFailedAction(); + + actions = hot("-a", { a: action }); + const response = cold("-#", {}); + datasetApi.find.and.returnValue(response); + + const expected = cold("--b", { b: outcome }); + expect(effects.fetchRelatedDatasetsCount$).toBeObservable(expected); + }); + }); + describe("addDataset$", () => { it("should result in an addDatasetCompleteAction, a fetchDatasetsAction and a fetchDatasetAction", () => { const action = fromActions.addDatasetAction({ dataset: derivedDataset }); diff --git a/src/app/state-management/effects/datasets.effects.ts b/src/app/state-management/effects/datasets.effects.ts index e1833a13e..d0562f578 100644 --- a/src/app/state-management/effects/datasets.effects.ts +++ b/src/app/state-management/effects/datasets.effects.ts @@ -1,11 +1,21 @@ import { Injectable } from "@angular/core"; import { Actions, concatLatestFrom, createEffect, ofType } from "@ngrx/effects"; -import { DatasetApi, Dataset, LoopBackFilter, OrigDatablock, Attachment, Datablock } from "shared/sdk"; +import { + DatasetApi, + Dataset, + LoopBackFilter, + OrigDatablock, + Attachment, + Datablock, + DerivedDataset, +} from "shared/sdk"; import { Store } from "@ngrx/store"; import { selectFullqueryParams, selectFullfacetParams, selectDatasetsInBatch, + selectCurrentDataset, + selectRelatedDatasetsFilters, } from "state-management/selectors/datasets.selectors"; import * as fromActions from "state-management/actions/datasets.actions"; import { @@ -29,6 +39,8 @@ import { @Injectable() export class DatasetEffects { + currentDataset$ = this.store.select(selectCurrentDataset); + relatedDatasetsFilters$ = this.store.select(selectRelatedDatasetsFilters); fullqueryParams$ = this.store.select(selectFullqueryParams); fullfacetParams$ = this.store.select(selectFullfacetParams); datasetsInBatch$ = this.store.select(selectDatasetsInBatch); @@ -123,7 +135,7 @@ export class DatasetEffects { ofType(fromActions.fetchDatasetAction), switchMap(({ pid, filters }) => { const datasetFilter: LoopBackFilter = { - where: { pid } + where: { pid }, }; if (filters) { @@ -134,7 +146,7 @@ export class DatasetEffects { return this.datasetApi.findOne(datasetFilter).pipe( map((dataset: Dataset) => - fromActions.fetchDatasetCompleteAction({ dataset }) + fromActions.fetchDatasetCompleteAction({ dataset }) ), catchError(() => of(fromActions.fetchDatasetFailedAction())) ); @@ -145,12 +157,14 @@ export class DatasetEffects { return this.actions$.pipe( ofType(fromActions.fetchDatablocksAction), switchMap(({ pid, filters }) => { - return this.datasetApi.getDatablocks(encodeURIComponent(pid), filters).pipe( - map((datablocks: Datablock[]) => - fromActions.fetchDatablocksCompleteAction({ datablocks }) - ), - catchError(() => of(fromActions.fetchDatablocksFailedAction())) - ); + return this.datasetApi + .getDatablocks(encodeURIComponent(pid), filters) + .pipe( + map((datablocks: Datablock[]) => + fromActions.fetchDatablocksCompleteAction({ datablocks }) + ), + catchError(() => of(fromActions.fetchDatablocksFailedAction())) + ); }) ); }); @@ -159,12 +173,14 @@ export class DatasetEffects { return this.actions$.pipe( ofType(fromActions.fetchOrigDatablocksAction), switchMap(({ pid, filters }) => { - return this.datasetApi.getOrigdatablocks(encodeURIComponent(pid), {}).pipe( - map((origdatablocks: OrigDatablock[]) => - fromActions.fetchOrigDatablocksCompleteAction({ origdatablocks }) - ), - catchError(() => of(fromActions.fetchOrigDatablocksFailedAction())) - ); + return this.datasetApi + .getOrigdatablocks(encodeURIComponent(pid), {}) + .pipe( + map((origdatablocks: OrigDatablock[]) => + fromActions.fetchOrigDatablocksCompleteAction({ origdatablocks }) + ), + catchError(() => of(fromActions.fetchOrigDatablocksFailedAction())) + ); }) ); }); @@ -173,11 +189,81 @@ export class DatasetEffects { return this.actions$.pipe( ofType(fromActions.fetchAttachmentsAction), switchMap(({ pid, filters }) => { - return this.datasetApi.getAttachments(encodeURIComponent(pid), filters).pipe( - map((attachments: Attachment[]) => - fromActions.fetchAttachmentsCompleteAction({ attachments }) + return this.datasetApi + .getAttachments(encodeURIComponent(pid), filters) + .pipe( + map((attachments: Attachment[]) => + fromActions.fetchAttachmentsCompleteAction({ attachments }) + ), + catchError(() => of(fromActions.fetchAttachmentsFailedAction())) + ); + }) + ); + }); + + fetchRelatedDatasets$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.fetchRelatedDatasetsAction), + concatLatestFrom(() => [ + this.currentDataset$, + this.relatedDatasetsFilters$, + ]), + switchMap(([_, dataset, filters]) => { + const queryFilter: LoopBackFilter = { + where: {}, + skip: filters.skip, + limit: filters.limit, + order: filters.sortField, + }; + if (dataset.type === "raw") { + queryFilter.where = { + type: "derived", + inputDatasets: dataset.pid, + }; + } + if (dataset.type === "derived") { + queryFilter.where = { + pid: { $in: (dataset as unknown as DerivedDataset).inputDatasets }, + }; + } + return this.datasetApi.find(queryFilter).pipe( + map((relatedDatasets: Dataset[]) => + fromActions.fetchRelatedDatasetsCompleteAction({ relatedDatasets }) ), - catchError(() => of(fromActions.fetchAttachmentsFailedAction())) + catchError(() => of(fromActions.fetchRelatedDatasetsFailedAction())) + ); + }) + ); + }); + + fetchRelatedDatasetsCount$ = createEffect(() => { + return this.actions$.pipe( + ofType(fromActions.fetchRelatedDatasetsAction), + concatLatestFrom(() => [this.currentDataset$]), + switchMap(([_, dataset]) => { + const queryFilter: LoopBackFilter = { + where: {}, + }; + if (dataset.type === "raw") { + queryFilter.where = { + type: "derived", + inputDatasets: dataset.pid, + }; + } + if (dataset.type === "derived") { + queryFilter.where = { + pid: { $in: (dataset as unknown as DerivedDataset).inputDatasets }, + }; + } + return this.datasetApi.find(queryFilter).pipe( + map((datasets) => + fromActions.fetchRelatedDatasetsCountCompleteAction({ + count: datasets.length, + }) + ), + catchError(() => + of(fromActions.fetchRelatedDatasetsCountFailedAction()) + ) ); }) ); diff --git a/src/app/state-management/reducers/datasets.reducer.spec.ts b/src/app/state-management/reducers/datasets.reducer.spec.ts index 7a94f0a2b..3269a78af 100644 --- a/src/app/state-management/reducers/datasets.reducer.spec.ts +++ b/src/app/state-management/reducers/datasets.reducer.spec.ts @@ -1,6 +1,12 @@ import * as fromDatasets from "./datasets.reducer"; import * as fromActions from "../actions/datasets.actions"; -import { Dataset, DatasetInterface, Attachment, DerivedDatasetInterface, DerivedDataset } from "shared/sdk/models"; +import { + Dataset, + DatasetInterface, + Attachment, + DerivedDatasetInterface, + DerivedDataset, +} from "shared/sdk/models"; import { FacetCounts, initialDatasetState, @@ -78,6 +84,46 @@ describe("DatasetsReducer", () => { }); }); + describe("on fetchRelatedDatasetsCompleteAction", () => { + it("should set relatedDatasets property", () => { + const relatedDatasets = [dataset]; + const action = fromActions.fetchRelatedDatasetsCompleteAction({ + relatedDatasets, + }); + const state = fromDatasets.datasetsReducer(initialDatasetState, action); + + expect(state.relatedDatasets).toEqual(relatedDatasets); + }); + }); + + describe("on fetchRelatedDatasetsCountCompleteAction", () => { + it("should set relatedDatasetsCount property", () => { + const count = 0; + const action = fromActions.fetchRelatedDatasetsCountCompleteAction({ + count, + }); + const state = fromDatasets.datasetsReducer(initialDatasetState, action); + + expect(state.relatedDatasetsCount).toEqual(count); + }); + }); + + describe("on changeRelatedDatasetsPageAction", () => { + it("should set relatedDatasetsFilters skip and limit property", () => { + const page = 0; + const limit = 25; + const skip = page * limit; + const action = fromActions.changeRelatedDatasetsPageAction({ + page, + limit, + }); + const state = fromDatasets.datasetsReducer(initialDatasetState, action); + + expect(state.relatedDatasetsFilters.skip).toEqual(skip); + expect(state.relatedDatasetsFilters.limit).toEqual(limit); + }); + }); + describe("on prefillBatchCompleteAction", () => { it("should set batch property", () => { const batch: Dataset[] = []; @@ -132,10 +178,12 @@ describe("DatasetsReducer", () => { describe("on addDatasetCompleteAction", () => { it("should set currentSet", () => { - const action = fromActions.addDatasetCompleteAction({ dataset: derivedDataset }); + const action = fromActions.addDatasetCompleteAction({ + dataset: derivedDataset, + }); const state = fromDatasets.datasetsReducer(initialDatasetState, action); - expect(state.currentSet).toEqual((derivedDataset as unknown) as Dataset); + expect(state.currentSet).toEqual(derivedDataset as unknown as Dataset); }); }); diff --git a/src/app/state-management/reducers/datasets.reducer.ts b/src/app/state-management/reducers/datasets.reducer.ts index 5f1672a1d..5e2278cfb 100644 --- a/src/app/state-management/reducers/datasets.reducer.ts +++ b/src/app/state-management/reducers/datasets.reducer.ts @@ -38,33 +38,67 @@ const reducer = createReducer( }) ), - on(fromActions.fetchDatablocksCompleteAction, (state, { datablocks }) =>{ + on(fromActions.fetchDatablocksCompleteAction, (state, { datablocks }) => { return { - ...state, - currentSet: { - ...state.currentSet, - datablocks - }}; + ...state, + currentSet: { + ...state.currentSet, + datablocks, + }, + }; }), - on(fromActions.fetchOrigDatablocksCompleteAction, (state, { origdatablocks }) =>{ - return { - ...state, - currentSet: { - ...state.currentSet, - origdatablocks - }}; - }), + on( + fromActions.fetchOrigDatablocksCompleteAction, + (state, { origdatablocks }) => { + return { + ...state, + currentSet: { + ...state.currentSet, + origdatablocks, + }, + }; + } + ), - on(fromActions.fetchAttachmentsCompleteAction, (state, { attachments }) =>{ + on(fromActions.fetchAttachmentsCompleteAction, (state, { attachments }) => { return { - ...state, - currentSet: { - ...state.currentSet, - attachments - }}; + ...state, + currentSet: { + ...state.currentSet, + attachments, + }, + }; }), + on( + fromActions.fetchRelatedDatasetsCompleteAction, + (state, { relatedDatasets }): DatasetState => ({ + ...state, + relatedDatasets, + }) + ), + on( + fromActions.fetchRelatedDatasetsCountCompleteAction, + (state, { count }): DatasetState => ({ + ...state, + relatedDatasetsCount: count, + }) + ), + + on( + fromActions.changeRelatedDatasetsPageAction, + (state, { page, limit }): DatasetState => { + const skip = page * limit; + const relatedDatasetsFilters = { + ...state.relatedDatasetsFilters, + skip, + limit, + }; + return { ...state, relatedDatasetsFilters }; + } + ), + on(fromActions.prefillBatchCompleteAction, (state, { batch }) => ({ ...state, batch, @@ -147,9 +181,10 @@ const reducer = createReducer( }) ), - on(fromActions.clearCurrentDatasetStateAction, (state) => ({ + on(fromActions.clearCurrentDatasetStateAction, (state): DatasetState => ({ ...state, - currentSet: undefined + currentSet: undefined, + relatedDatasets: [], })), on(fromActions.selectDatasetAction, (state, { dataset }) => { diff --git a/src/app/state-management/selectors/datasets.selectors.spec.ts b/src/app/state-management/selectors/datasets.selectors.spec.ts index e7b3c3842..31c4f1a86 100644 --- a/src/app/state-management/selectors/datasets.selectors.spec.ts +++ b/src/app/state-management/selectors/datasets.selectors.spec.ts @@ -8,6 +8,8 @@ const initialDatasetState: DatasetState = { datasets: [], selectedSets: [], currentSet: dataset, + relatedDatasets: [], + relatedDatasetsCount: 0, totalCount: 0, facetCounts: {}, @@ -37,6 +39,11 @@ const initialDatasetState: DatasetState = { scientific: [], isPublished: false, }, + relatedDatasetsFilters: { + skip: 0, + limit: 25, + sortField: "creationTime:desc", + }, }; describe("test dataset selectors", () => { @@ -377,4 +384,22 @@ describe("test dataset selectors", () => { ).toEqual({}); }); }); + + describe("selectRelatedDatasets", () => { + it("should return the current related datasets", () => { + expect( + fromDatasetSelectors.selectRelatedDatasetsPageViewModel.projector( + initialDatasetState + ) + ).toEqual({ + relatedDatasets: [], + relatedDatasetsCount: 0, + relatedDatasetsFilters: { + skip: 0, + limit: 25, + sortField: "creationTime:desc", + }, + }); + }); + }); }); diff --git a/src/app/state-management/selectors/datasets.selectors.ts b/src/app/state-management/selectors/datasets.selectors.ts index 19aed03a3..d4af5050d 100644 --- a/src/app/state-management/selectors/datasets.selectors.ts +++ b/src/app/state-management/selectors/datasets.selectors.ts @@ -237,3 +237,17 @@ export const selectOpenwhiskResult = createSelector( selectDatasetState, (state) => state.openwhiskResult ); + +export const selectRelatedDatasetsPageViewModel = createSelector( + selectDatasetState, + ({ relatedDatasets, relatedDatasetsCount, relatedDatasetsFilters }) => ({ + relatedDatasets, + relatedDatasetsCount, + relatedDatasetsFilters, + }) +); + +export const selectRelatedDatasetsFilters = createSelector( + selectDatasetState, + (state) => state.relatedDatasetsFilters +); diff --git a/src/app/state-management/state/datasets.store.ts b/src/app/state-management/state/datasets.store.ts index 980bb23fd..7f299aa5e 100644 --- a/src/app/state-management/state/datasets.store.ts +++ b/src/app/state-management/state/datasets.store.ts @@ -19,6 +19,8 @@ export interface DatasetState { datasets: Dataset[]; selectedSets: Dataset[]; currentSet: Dataset | undefined; + relatedDatasets: Dataset[]; + relatedDatasetsCount: number; totalCount: number; facetCounts: FacetCounts; @@ -28,6 +30,12 @@ export interface DatasetState { keywordsTerms: string; filters: DatasetFilters; + relatedDatasetsFilters: { + skip: number; + limit: number; + sortField: string; + }; + batch: Dataset[]; openwhiskResult: Record | undefined; @@ -37,6 +45,8 @@ export const initialDatasetState: DatasetState = { datasets: [], selectedSets: [], currentSet: undefined, + relatedDatasets: [], + relatedDatasetsCount: 0, totalCount: 0, facetCounts: {}, @@ -57,10 +67,15 @@ export const initialDatasetState: DatasetState = { sortField: "creationTime:desc", keywords: [], scientific: [], - isPublished: "" + isPublished: "", + }, + relatedDatasetsFilters: { + skip: 0, + limit: 25, + sortField: "creationTime:desc", }, batch: [], - openwhiskResult: undefined + openwhiskResult: undefined, };