diff --git a/config/config.example.yml b/config/config.example.yml index 0933619e01..4fbc98fea2 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -136,7 +136,7 @@ submission: # NOTE: example of configuration # # NOTE: metadata name # - name: dc.author - # # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used + # # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used # style: fas fa-user - name: dc.author style: fas fa-user @@ -147,18 +147,40 @@ submission: confidence: # NOTE: example of configuration # # NOTE: confidence value - # - name: dc.author - # # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used - # style: fa-user + # - value: 600 + # # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used + # style: text-success + # icon: fa-circle-check + # # NOTE: the class configured in property style is used by default, the icon property could be used in component + # configured to use a 'icon mode' display (mainly in edit-item page) - value: 600 style: text-success + icon: fa-circle-check - value: 500 style: text-info + icon: fa-gear - value: 400 style: text-warning + icon: fa-circle-question + - value: 300 + style: text-muted + icon: fa-thumbs-down + - value: 200 + style: text-muted + icon: fa-circle-exclamation + - value: 100 + style: text-muted + icon: fa-circle-stop + - value: 0 + style: text-muted + icon: fa-ban + - value: -1 + style: text-muted + icon: fa-circle-xmark # default configuration - value: default style: text-muted + icon: fa-circle-xmark # Default Language in which the UI will be rendered if the user's browser language is not an active language defaultLanguage: en @@ -272,6 +294,8 @@ homePage: # No. of communities to list per page on the home page # This will always round to the nearest number from the list of page sizes. e.g. if you set it to 7 it'll use 10 pageSize: 5 + # Enable or disable the Discover filters on the homepage + showDiscoverFilters: false # Item Config item: diff --git a/src/app/collection-page/collection-page.component.html b/src/app/collection-page/collection-page.component.html index 21cc94af68..d5da37c12f 100644 --- a/src/app/collection-page/collection-page.component.html +++ b/src/app/collection-page/collection-page.component.html @@ -58,4 +58,4 @@ - + \ No newline at end of file diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts index 3eb83ebe8a..f717943e8e 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts @@ -88,7 +88,7 @@ describe('CollectionSourceControlsComponent', () => { invoke: createSuccessfulRemoteDataObject$(process), }); processDataService = jasmine.createSpyObj('processDataService', { - findById: createSuccessfulRemoteDataObject$(process), + autoRefreshUntilCompletion: createSuccessfulRemoteDataObject$(process), }); bitstreamService = jasmine.createSpyObj('bitstreamService', { findByHref: createSuccessfulRemoteDataObject$(bitstream), @@ -137,7 +137,7 @@ describe('CollectionSourceControlsComponent', () => { {name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)}, ], []); - expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false); + expect(processDataService.autoRefreshUntilCompletion).toHaveBeenCalledWith(process.processId); expect(bitstreamService.findByHref).toHaveBeenCalledWith(process._links.output.href); expect(notificationsService.info).toHaveBeenCalledWith(jasmine.anything() as any, 'Script text'); }); @@ -151,7 +151,7 @@ describe('CollectionSourceControlsComponent', () => { {name: '-r', value: null}, {name: '-c', value: collection.uuid}, ], []); - expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false); + expect(processDataService.autoRefreshUntilCompletion).toHaveBeenCalledWith(process.processId); expect(notificationsService.success).toHaveBeenCalled(); }); }); @@ -164,7 +164,7 @@ describe('CollectionSourceControlsComponent', () => { {name: '-o', value: null}, {name: '-c', value: collection.uuid}, ], []); - expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false); + expect(processDataService.autoRefreshUntilCompletion).toHaveBeenCalledWith(process.processId); expect(notificationsService.success).toHaveBeenCalled(); }); }); diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts index 7113c25e9f..1e6e3dd511 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts @@ -1,15 +1,14 @@ -import { Component, Input, OnDestroy } from '@angular/core'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { ScriptDataService } from '../../../../core/data/processes/script-data.service'; import { ContentSource } from '../../../../core/shared/content-source.model'; import { ProcessDataService } from '../../../../core/data/processes/process-data.service'; import { - getAllCompletedRemoteData, getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators'; import { filter, map, switchMap, tap } from 'rxjs/operators'; -import { hasValue, hasValueOperator } from '../../../../shared/empty.util'; +import { hasValue } from '../../../../shared/empty.util'; import { ProcessStatus } from '../../../../process-page/processes/process-status.model'; import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { RequestService } from '../../../../core/data/request.service'; @@ -30,7 +29,7 @@ import { ContentSourceSetSerializer } from '../../../../core/shared/content-sour styleUrls: ['./collection-source-controls.component.scss'], templateUrl: './collection-source-controls.component.html', }) -export class CollectionSourceControlsComponent implements OnDestroy { +export class CollectionSourceControlsComponent implements OnInit, OnDestroy { /** * Should the controls be enabled. @@ -49,6 +48,7 @@ export class CollectionSourceControlsComponent implements OnDestroy { contentSource$: Observable; private subs: Subscription[] = []; + private autoRefreshIDs: string[] = []; testConfigRunning$ = new BehaviorSubject(false); importRunning$ = new BehaviorSubject(false); @@ -95,36 +95,28 @@ export class CollectionSourceControlsComponent implements OnDestroy { }), // filter out responses that aren't successful since the pinging of the process only needs to happen when the invocation was successful. filter((rd) => rd.hasSucceeded && hasValue(rd.payload)), - switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)), - getAllCompletedRemoteData(), - filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)), - map((rd) => rd.payload), - hasValueOperator(), + switchMap((rd) => { + this.autoRefreshIDs.push(rd.payload.processId); + return this.processDataService.autoRefreshUntilCompletion(rd.payload.processId); + }), + map((rd) => rd.payload) ).subscribe((process: Process) => { - if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() && - process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) { - // Ping the current process state every 5s - setTimeout(() => { - this.requestService.setStaleByHrefSubstring(process._links.self.href); - }, 5000); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { - this.notificationsService.error(this.translateService.get('collection.source.controls.test.failed')); - this.testConfigRunning$.next(false); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { - this.bitstreamService.findByHref(process._links.output.href).pipe(getFirstSucceededRemoteDataPayload()).subscribe((bitstream) => { - this.httpClient.get(bitstream._links.content.href, {responseType: 'text'}).subscribe((data: any) => { - const output = data.replaceAll(new RegExp('.*\\@(.*)', 'g'), '$1') - .replaceAll('The script has started', '') - .replaceAll('The script has completed', ''); - this.notificationsService.info(this.translateService.get('collection.source.controls.test.completed'), output); - }); - }); - this.testConfigRunning$.next(false); - } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { + this.notificationsService.error(this.translateService.get('collection.source.controls.test.failed')); + this.testConfigRunning$.next(false); } - )); + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { + this.bitstreamService.findByHref(process._links.output.href).pipe(getFirstSucceededRemoteDataPayload()).subscribe((bitstream) => { + this.httpClient.get(bitstream._links.content.href, {responseType: 'text'}).subscribe((data: any) => { + const output = data.replaceAll(new RegExp('.*\\@(.*)', 'g'), '$1') + .replaceAll('The script has started', '') + .replaceAll('The script has completed', ''); + this.notificationsService.info(this.translateService.get('collection.source.controls.test.completed'), output); + }); + }); + this.testConfigRunning$.next(false); + } + })); } /** @@ -147,31 +139,22 @@ export class CollectionSourceControlsComponent implements OnDestroy { } }), filter((rd) => rd.hasSucceeded && hasValue(rd.payload)), - switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)), - getAllCompletedRemoteData(), - filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)), - map((rd) => rd.payload), - hasValueOperator(), + switchMap((rd) => { + this.autoRefreshIDs.push(rd.payload.processId); + return this.processDataService.autoRefreshUntilCompletion(rd.payload.processId); + }), + map((rd) => rd.payload) ).subscribe((process) => { - if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() && - process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) { - // Ping the current process state every 5s - setTimeout(() => { - this.requestService.setStaleByHrefSubstring(process._links.self.href); - this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); - }, 5000); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { - this.notificationsService.error(this.translateService.get('collection.source.controls.import.failed')); - this.importRunning$.next(false); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { - this.notificationsService.success(this.translateService.get('collection.source.controls.import.completed')); - this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); - this.importRunning$.next(false); - } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { + this.notificationsService.error(this.translateService.get('collection.source.controls.import.failed')); + this.importRunning$.next(false); } - )); + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { + this.notificationsService.success(this.translateService.get('collection.source.controls.import.completed')); + this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); + this.importRunning$.next(false); + } + })); } /** @@ -194,31 +177,22 @@ export class CollectionSourceControlsComponent implements OnDestroy { } }), filter((rd) => rd.hasSucceeded && hasValue(rd.payload)), - switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)), - getAllCompletedRemoteData(), - filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)), - map((rd) => rd.payload), - hasValueOperator(), + switchMap((rd) => { + this.autoRefreshIDs.push(rd.payload.processId); + return this.processDataService.autoRefreshUntilCompletion(rd.payload.processId); + }), + map((rd) => rd.payload) ).subscribe((process) => { - if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() && - process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) { - // Ping the current process state every 5s - setTimeout(() => { - this.requestService.setStaleByHrefSubstring(process._links.self.href); - this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); - }, 5000); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { - this.notificationsService.error(this.translateService.get('collection.source.controls.reset.failed')); - this.reImportRunning$.next(false); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { - this.notificationsService.success(this.translateService.get('collection.source.controls.reset.completed')); - this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); - this.reImportRunning$.next(false); - } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { + this.notificationsService.error(this.translateService.get('collection.source.controls.reset.failed')); + this.reImportRunning$.next(false); } - )); + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { + this.notificationsService.success(this.translateService.get('collection.source.controls.reset.completed')); + this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); + this.reImportRunning$.next(false); + } + })); } ngOnDestroy(): void { @@ -227,5 +201,9 @@ export class CollectionSourceControlsComponent implements OnDestroy { sub.unsubscribe(); } }); + + this.autoRefreshIDs.forEach((id) => { + this.processDataService.stopAutoRefreshing(id); + }); } } diff --git a/src/app/community-page/community-page.component.ts b/src/app/community-page/community-page.component.ts index 206aa54cb0..148df02509 100644 --- a/src/app/community-page/community-page.component.ts +++ b/src/app/community-page/community-page.component.ts @@ -7,7 +7,7 @@ import { Bitstream } from '../core/shared/bitstream.model'; import { Community } from '../core/shared/community.model'; import { fadeInOut } from '../shared/animations/fade'; import { hasValue } from '../shared/empty.util'; -import { getAllSucceededRemoteDataPayload} from '../core/shared/operators'; +import { getAllSucceededRemoteDataPayload } from '../core/shared/operators'; import { AuthService } from '../core/auth/auth.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../core/data/feature-authorization/feature-id'; diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 5b5e362406..cd63ff6436 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -273,12 +273,13 @@ export class RemoteDataBuildService { return isStale(r2.state) ? r1 : r2; } }), - distinctUntilKeyChanged('lastUpdated') ); const payload$ = this.buildPayload(requestEntry$, href$, ...linksToFollow); - return this.toRemoteDataObservable(requestEntry$, payload$); + return this.toRemoteDataObservable(requestEntry$, payload$).pipe( + distinctUntilKeyChanged('lastUpdated'), + ); } /** diff --git a/src/app/core/data/base/base-data.service.spec.ts b/src/app/core/data/base/base-data.service.spec.ts index 75662a691f..3366209179 100644 --- a/src/app/core/data/base/base-data.service.spec.ts +++ b/src/app/core/data/base/base-data.service.spec.ts @@ -21,6 +21,10 @@ import { RequestEntryState } from '../request-entry-state.model'; import { fakeAsync, tick } from '@angular/core/testing'; import { BaseDataService } from './base-data.service'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub'; +import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; +import { HALLink } from '../../shared/hal-link.model'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; const endpoint = 'https://rest.api/core'; @@ -46,34 +50,18 @@ describe('BaseDataService', () => { let requestService; let halService; let rdbService; - let objectCache; + let objectCache: ObjectCacheServiceStub; let selfLink; let linksToFollow; let testScheduler; - let remoteDataMocks; + let remoteDataMocks: { [responseType: string]: RemoteData }; + let remoteDataPageMocks: { [responseType: string]: RemoteData }; function initTestService(): TestService { requestService = getMockRequestService(); halService = new HALEndpointServiceStub('url') as any; rdbService = getMockRemoteDataBuildService(); - objectCache = { - - addPatch: () => { - /* empty */ - }, - getObjectBySelfLink: () => { - /* empty */ - }, - getByHref: () => { - /* empty */ - }, - addDependency: () => { - /* empty */ - }, - removeDependents: () => { - /* empty */ - }, - } as any; + objectCache = new ObjectCacheServiceStub(); selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; linksToFollow = [ followLink('a'), @@ -88,7 +76,27 @@ describe('BaseDataService', () => { const timeStamp = new Date().getTime(); const msToLive = 15 * 60 * 1000; - const payload = { foo: 'bar' }; + const payload = { + foo: 'bar', + followLink1: {}, + followLink2: {}, + _links: { + self: Object.assign(new HALLink(), { + href: 'self-test-link', + }), + followLink1: Object.assign(new HALLink(), { + href: 'follow-link-1', + }), + followLink2: [ + Object.assign(new HALLink(), { + href: 'follow-link-2-1', + }), + Object.assign(new HALLink(), { + href: 'follow-link-2-2', + }), + ], + } + }; const statusCodeSuccess = 200; const statusCodeError = 404; const errorMessage = 'not found'; @@ -101,11 +109,20 @@ describe('BaseDataService', () => { Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), }; + remoteDataPageMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + ResponsePendingStale: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, createPaginatedList([payload]), statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, createPaginatedList([payload]), statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; return new TestService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, ); } @@ -380,6 +397,27 @@ describe('BaseDataService', () => { }); + it('should link all the followLinks of a cached object by calling addDependency', () => { + spyOn(objectCache, 'addDependency').and.callThrough(); + testScheduler.run(({ cold, expectObservable, flush }) => { + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d', { + a: remoteDataMocks.Success, + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + })); + const expected = '--b-c-d'; + const values = { + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + }; + + expectObservable(service.findByHref(selfLink, false, false, ...linksToFollow)).toBe(expected, values); + flush(); + expect(objectCache.addDependency).toHaveBeenCalledTimes(3); + }); + }); }); describe(`findListByHref`, () => { @@ -392,8 +430,8 @@ describe('BaseDataService', () => { it(`should call buildHrefFromFindOptions with href and linksToFollow`, () => { testScheduler.run(({ cold }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith(selfLink, findListOptions, [], ...linksToFollow); @@ -403,8 +441,8 @@ describe('BaseDataService', () => { it(`should call createAndSendGetRequest with the result from buildHrefFromFindOptions and useCachedVersionIfAvailable`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), true); @@ -419,8 +457,8 @@ describe('BaseDataService', () => { it(`should call rdbService.buildList with the result from buildHrefFromFindOptions and linksToFollow`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect(rdbService.buildList).toHaveBeenCalledWith(jasmine.anything() as any, ...linksToFollow); @@ -431,12 +469,12 @@ describe('BaseDataService', () => { it(`should call reRequestStaleRemoteData with reRequestOnStale and the exact same findListByHref call as a callback`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.SuccessStale })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.SuccessStale })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.SuccessStale })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect((service as any).reRequestStaleRemoteData.calls.argsFor(0)[0]).toBeTrue(); - spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); + spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataPageMocks.SuccessStale })); // prove that the spy we just added hasn't been called yet expect(service.findListByHref).not.toHaveBeenCalled(); // call the callback passed to reRequestStaleRemoteData @@ -451,7 +489,7 @@ describe('BaseDataService', () => { it(`should return a the output from reRequestStaleRemoteData`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: 'bingo!' })); const expected = 'a'; const values = { @@ -471,19 +509,19 @@ describe('BaseDataService', () => { it(`should emit a cached completed RemoteData immediately, and keep emitting if it gets rerequested`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.Success, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, })); const expected = 'a-b-c-d-e'; const values = { - a: remoteDataMocks.Success, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); @@ -493,20 +531,20 @@ describe('BaseDataService', () => { it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', { - a: remoteDataMocks.ResponsePendingStale, - b: remoteDataMocks.SuccessStale, - c: remoteDataMocks.ErrorStale, - d: remoteDataMocks.RequestPending, - e: remoteDataMocks.ResponsePending, - f: remoteDataMocks.Success, - g: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.ResponsePendingStale, + b: remoteDataPageMocks.SuccessStale, + c: remoteDataPageMocks.ErrorStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, })); const expected = '------d-e-f-g'; const values = { - d: remoteDataMocks.RequestPending, - e: remoteDataMocks.ResponsePending, - f: remoteDataMocks.Success, - g: remoteDataMocks.SuccessStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); @@ -525,18 +563,18 @@ describe('BaseDataService', () => { it(`should not emit a cached completed RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.Success, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, })); const expected = '--b-c-d-e'; const values = { - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); @@ -546,20 +584,20 @@ describe('BaseDataService', () => { it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', { - a: remoteDataMocks.ResponsePendingStale, - b: remoteDataMocks.SuccessStale, - c: remoteDataMocks.ErrorStale, - d: remoteDataMocks.RequestPending, - e: remoteDataMocks.ResponsePending, - f: remoteDataMocks.Success, - g: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.ResponsePendingStale, + b: remoteDataPageMocks.SuccessStale, + c: remoteDataPageMocks.ErrorStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, })); const expected = '------d-e-f-g'; const values = { - d: remoteDataMocks.RequestPending, - e: remoteDataMocks.ResponsePending, - f: remoteDataMocks.Success, - g: remoteDataMocks.SuccessStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, }; @@ -567,6 +605,27 @@ describe('BaseDataService', () => { }); }); + it('should link all the followLinks of the cached objects by calling addDependency', () => { + spyOn(objectCache, 'addDependency').and.callThrough(); + testScheduler.run(({ cold, expectObservable, flush }) => { + spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d', { + a: remoteDataPageMocks.SuccessStale, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + })); + const expected = '--b-c-d'; + const values = { + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + }; + + expectObservable(service.findListByHref(selfLink, findListOptions, false, false, ...linksToFollow)).toBe(expected, values); + flush(); + expect(objectCache.addDependency).toHaveBeenCalledTimes(3); + }); + }); }); }); @@ -577,7 +636,7 @@ describe('BaseDataService', () => { getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ requestUUIDs: ['request1', 'request2', 'request3'], dependentRequestUUIDs: ['request4', 'request5'] - })); + } as ObjectCacheEntry)); }); diff --git a/src/app/core/data/base/base-data.service.ts b/src/app/core/data/base/base-data.service.ts index 84b1686024..5694cd7791 100644 --- a/src/app/core/data/base/base-data.service.ts +++ b/src/app/core/data/base/base-data.service.ts @@ -24,6 +24,7 @@ import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALDataService } from './hal-data-service.interface'; import { getFirstCompletedRemoteData } from '../../shared/operators'; +import { HALLink } from '../../shared/hal-link.model'; export const EMBED_SEPARATOR = '%2F'; /** @@ -268,7 +269,7 @@ export class BaseDataService implements HALDataServic this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); - return this.rdbService.buildSingle(requestHref$, ...linksToFollow).pipe( + const response$: Observable> = this.rdbService.buildSingle(requestHref$, ...linksToFollow).pipe( // This skip ensures that if a stale object is present in the cache when you do a // call it isn't immediately returned, but we wait until the remote data for the new request // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a @@ -277,6 +278,25 @@ export class BaseDataService implements HALDataServic this.reRequestStaleRemoteData(reRequestOnStale, () => this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), ); + return response$.pipe( + // Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object + tap((remoteDataObject: RemoteData) => { + if (hasValue(remoteDataObject?.payload?._links)) { + for (const followLinkName of Object.keys(remoteDataObject.payload._links)) { + // only add the followLinks if they are embedded + if (hasValue(remoteDataObject.payload[followLinkName]) && followLinkName !== 'self') { + // followLink can be either an individual HALLink or a HALLink[] + const followLinksList: HALLink[] = [].concat(remoteDataObject.payload._links[followLinkName]); + for (const individualFollowLink of followLinksList) { + if (hasValue(individualFollowLink?.href)) { + this.addDependency(response$, individualFollowLink.href); + } + } + } + } + } + }), + ); } /** @@ -302,7 +322,7 @@ export class BaseDataService implements HALDataServic this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); - return this.rdbService.buildList(requestHref$, ...linksToFollow).pipe( + const response$: Observable>> = this.rdbService.buildList(requestHref$, ...linksToFollow).pipe( // This skip ensures that if a stale object is present in the cache when you do a // call it isn't immediately returned, but we wait until the remote data for the new request // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a @@ -311,6 +331,29 @@ export class BaseDataService implements HALDataServic this.reRequestStaleRemoteData(reRequestOnStale, () => this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), ); + return response$.pipe( + // Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object + tap((remoteDataObject: RemoteData>) => { + if (hasValue(remoteDataObject?.payload?.page)) { + for (const object of remoteDataObject.payload.page) { + if (hasValue(object?._links)) { + for (const followLinkName of Object.keys(object._links)) { + // only add the followLinks if they are embedded + if (hasValue(object[followLinkName]) && followLinkName !== 'self') { + // followLink can be either an individual HALLink or a HALLink[] + const followLinksList: HALLink[] = [].concat(object._links[followLinkName]); + for (const individualFollowLink of followLinksList) { + if (hasValue(individualFollowLink?.href)) { + this.addDependency(response$, individualFollowLink.href); + } + } + } + } + } + } + } + }), + ); } /** diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index 65f8b3ab2c..c1a7ac64c2 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -24,6 +24,7 @@ import { testFindAllDataImplementation } from './base/find-all-data.spec'; import { testSearchDataImplementation } from './base/search-data.spec'; import { testPatchDataImplementation } from './base/patch-data.spec'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; const url = 'fake-url'; const collectionId = 'fake-collection-id'; @@ -35,7 +36,7 @@ describe('CollectionDataService', () => { let translate: TranslateService; let notificationsService: any; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: any; const mockCollection1: Collection = Object.assign(new Collection(), { @@ -205,14 +206,12 @@ describe('CollectionDataService', () => { buildFromRequestUUID: buildResponse$, buildSingle: buildResponse$ }); - objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') - }); + objectCache = new ObjectCacheServiceStub(); halService = new HALEndpointServiceStub(url); notificationsService = new NotificationsServiceStub(); translate = getMockTranslateService(); - service = new CollectionDataService(requestService, rdbService, objectCache, halService, null, notificationsService, null, null, translate); + service = new CollectionDataService(requestService, rdbService, objectCache as ObjectCacheService, halService, null, notificationsService, null, null, translate); } }); diff --git a/src/app/core/data/processes/process-data.service.spec.ts b/src/app/core/data/processes/process-data.service.spec.ts index 88e5bd5791..99cd317cdb 100644 --- a/src/app/core/data/processes/process-data.service.spec.ts +++ b/src/app/core/data/processes/process-data.service.spec.ts @@ -7,13 +7,187 @@ */ import { testFindAllDataImplementation } from '../base/find-all-data.spec'; -import { ProcessDataService } from './process-data.service'; +import { ProcessDataService, TIMER_FACTORY } from './process-data.service'; import { testDeleteDataImplementation } from '../base/delete-data.spec'; +import { waitForAsync, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { RequestService } from '../request.service'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; +import { Process } from '../../../process-page/processes/process.model'; +import { ProcessStatus } from '../../../process-page/processes/process-status.model'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { ReducerManager } from '@ngrx/store'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { DSOChangeAnalyzer } from '../dso-change-analyzer.service'; +import { BitstreamFormatDataService } from '../bitstream-format-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TestScheduler } from 'rxjs/testing'; +import { testSearchDataImplementation } from '../base/search-data.spec'; +import { PaginatedList } from '../paginated-list.model'; +import { FindListOptions } from '../find-list-options.model'; +import { of } from 'rxjs'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; describe('ProcessDataService', () => { + let testScheduler; + + const mockTimer = (fn: () => {}, interval: number) => { + fn(); + return 555; + }; + describe('composition', () => { - const initService = () => new ProcessDataService(null, null, null, null, null, null); + const initService = () => new ProcessDataService(null, null, null, null, null, null, null, null); testFindAllDataImplementation(initService); testDeleteDataImplementation(initService); + testSearchDataImplementation(initService); + }); + + let requestService = getMockRequestService(); + let processDataService; + let remoteDataBuildService; + + describe('autoRefreshUntilCompletion', () => { + beforeEach(waitForAsync(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + TestBed.configureTestingModule({ + imports: [], + providers: [ + ProcessDataService, + { provide: RequestService, useValue: null }, + { provide: RemoteDataBuildService, useValue: null }, + { provide: ObjectCacheService, useValue: null }, + { provide: ReducerManager, useValue: null }, + { provide: HALEndpointService, useValue: null }, + { provide: DSOChangeAnalyzer, useValue: null }, + { provide: BitstreamFormatDataService, useValue: null }, + { provide: NotificationsService, useValue: null }, + { provide: TIMER_FACTORY, useValue: mockTimer }, + ] + }); + + processDataService = TestBed.inject(ProcessDataService); + spyOn(processDataService, 'invalidateByHref'); + })); + + it('should not do any polling when the process is already completed', () => { + testScheduler.run(({ cold, expectObservable }) => { + let completedProcess = new Process(); + completedProcess.processStatus = ProcessStatus.COMPLETED; + + const completedProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, completedProcess); + + spyOn(processDataService, 'findById').and.returnValue( + cold('c', { + 'c': completedProcessRD + }) + ); + + let process$ = processDataService.autoRefreshUntilCompletion('instantly'); + expectObservable(process$).toBe('c', { + c: completedProcessRD + }); + }); + + expect(processDataService.findById).toHaveBeenCalledTimes(1); + expect(processDataService.invalidateByHref).not.toHaveBeenCalled(); + }); + + it('should poll until a process completes', () => { + testScheduler.run(({ cold, expectObservable }) => { + const runningProcess = Object.assign(new Process(), { + _links: { + self: { + href: 'https://rest.api/processes/123' + } + } + }); + runningProcess.processStatus = ProcessStatus.RUNNING; + const completedProcess = new Process(); + completedProcess.processStatus = ProcessStatus.COMPLETED; + const runningProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, runningProcess); + const completedProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, completedProcess); + + spyOn(processDataService, 'findById').and.returnValue( + cold('r 150ms c', { + 'r': runningProcessRD, + 'c': completedProcessRD + }) + ); + + let process$ = processDataService.autoRefreshUntilCompletion('foo', 100); + expectObservable(process$).toBe('r 150ms c', { + 'r': runningProcessRD, + 'c': completedProcessRD + }); + }); + + expect(processDataService.findById).toHaveBeenCalledTimes(1); + expect(processDataService.invalidateByHref).toHaveBeenCalledTimes(1); + }); + }); + + describe('autoRefreshingSearchBy', () => { + beforeEach(waitForAsync(() => { + + TestBed.configureTestingModule({ + imports: [], + providers: [ + ProcessDataService, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: null }, + { provide: ObjectCacheService, useValue: null }, + { provide: ReducerManager, useValue: null }, + { provide: HALEndpointService, useValue: null }, + { provide: DSOChangeAnalyzer, useValue: null }, + { provide: BitstreamFormatDataService, useValue: null }, + { provide: NotificationsService, useValue: null }, + { provide: TIMER_FACTORY, useValue: mockTimer }, + ] + }); + + processDataService = TestBed.inject(ProcessDataService); + })); + + it('should refresh after the specified interval', fakeAsync(() => { + const runningProcess = Object.assign(new Process(), { + _links: { + self: { + href: 'https://rest.api/processes/123' + } + } + }); + runningProcess.processStatus = ProcessStatus.RUNNING; + + const runningProcessPagination: PaginatedList = Object.assign(new PaginatedList(), { + page: [runningProcess], + _links: { + self: { + href: 'https://rest.api/processesList/456' + } + } + }); + + const runningProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, runningProcessPagination); + + spyOn(processDataService, 'searchBy').and.returnValue( + of(runningProcessRD) + ); + + expect(processDataService.searchBy).toHaveBeenCalledTimes(0); + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledTimes(0); + + let sub = processDataService.autoRefreshingSearchBy('id', 'byProperty', new FindListOptions(), 200).subscribe(); + expect(processDataService.searchBy).toHaveBeenCalledTimes(1); + + tick(250); + + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledTimes(1); + + sub.unsubscribe(); + })); }); }); diff --git a/src/app/core/data/processes/process-data.service.ts b/src/app/core/data/processes/process-data.service.ts index 3bf34eb650..080a4a4c09 100644 --- a/src/app/core/data/processes/process-data.service.ts +++ b/src/app/core/data/processes/process-data.service.ts @@ -1,12 +1,12 @@ -import { Injectable } from '@angular/core'; +import { Injectable, NgZone, Inject, InjectionToken } from '@angular/core'; import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { Process } from '../../../process-page/processes/process.model'; import { PROCESS } from '../../../process-page/processes/process.resource-type'; -import { Observable } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { Observable, Subscription } from 'rxjs'; +import { switchMap, filter, distinctUntilChanged, find } from 'rxjs/operators'; import { PaginatedList } from '../paginated-list.model'; import { Bitstream } from '../../shared/bitstream.model'; import { RemoteData } from '../remote-data'; @@ -19,12 +19,29 @@ import { dataService } from '../base/data-service.decorator'; import { DeleteData, DeleteDataImpl } from '../base/delete-data'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NoContent } from '../../shared/NoContent.model'; +import { getAllCompletedRemoteData } from '../../shared/operators'; +import { ProcessStatus } from 'src/app/process-page/processes/process-status.model'; +import { hasValue } from '../../../shared/empty.util'; +import { SearchData, SearchDataImpl } from '../base/search-data'; + +/** + * Create an InjectionToken for the default JS setTimeout function, purely so we can mock it during + * testing. (fakeAsync isn't working for this case) + */ +export const TIMER_FACTORY = new InjectionToken<(callback: (...args: any[]) => void, ms?: number, ...args: any[]) => NodeJS.Timeout>('timer', { + providedIn: 'root', + factory: () => setTimeout +}); @Injectable() @dataService(PROCESS) -export class ProcessDataService extends IdentifiableDataService implements FindAllData, DeleteData { +export class ProcessDataService extends IdentifiableDataService implements FindAllData, DeleteData, SearchData { + private findAllData: FindAllData; private deleteData: DeleteData; + private searchData: SearchData; + protected activelyBeingPolled: Map = new Map(); + protected subs: Map = new Map(); constructor( protected requestService: RequestService, @@ -33,11 +50,30 @@ export class ProcessDataService extends IdentifiableDataService impleme protected halService: HALEndpointService, protected bitstreamDataService: BitstreamDataService, protected notificationsService: NotificationsService, + protected zone: NgZone, + @Inject(TIMER_FACTORY) protected timer: (callback: (...args: any[]) => void, ms?: number, ...args: any[]) => NodeJS.Timeout ) { super('processes', requestService, rdbService, objectCache, halService); this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Return true if the given process has the given status + * @protected + */ + protected static statusIs(process: Process, status: ProcessStatus): boolean { + return hasValue(process) && process.processStatus === status; + } + + /** + * Return true if the given process has the status COMPLETED or FAILED + */ + public static hasCompletedOrFailed(process: Process): boolean { + return ProcessDataService.statusIs(process, ProcessStatus.COMPLETED) || + ProcessDataService.statusIs(process, ProcessStatus.FAILED); } /** @@ -77,6 +113,71 @@ export class ProcessDataService extends IdentifiableDataService impleme return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + /** + * @param searchMethod The search method for the Process + * @param options The FindListOptions object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true. + * @param reRequestOnStale Whether the request should automatically be re- + * requested after the response becomes stale. + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should automatically be resolved. + * @return {Observable>>} + * Return an observable that emits a paginated list of processes + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * @param id The id for this auto-refreshing search. Used to stop + * auto-refreshing afterwards, and ensure we're not + * auto-refreshing the same thing multiple times. + * @param searchMethod The search method for the Process + * @param options The FindListOptions object + * @param pollingIntervalInMs The interval by which the search will be repeated + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should automatically be resolved. + * @return {Observable>>} + * Return an observable that emits a paginated list of processes every interval + */ + autoRefreshingSearchBy(id: string, searchMethod: string, options?: FindListOptions, pollingIntervalInMs: number = 5000, ...linksToFollow: FollowLinkConfig[]): Observable>> { + + const result$ = this.searchBy(searchMethod, options, true, true, ...linksToFollow).pipe( + getAllCompletedRemoteData() + ); + + const sub = result$.pipe( + filter(() => + !this.activelyBeingPolled.has(id) + ) + ).subscribe((processListRd: RemoteData>) => { + this.clearCurrentTimeout(id); + const nextTimeout = this.timer(() => { + this.activelyBeingPolled.delete(id); + this.requestService.setStaleByHrefSubstring(processListRd.payload._links.self.href); + }, pollingIntervalInMs); + + this.activelyBeingPolled.set(id, nextTimeout); + }); + + this.subs.set(id, sub); + + return result$; + } + + /** + * Stop auto-refreshing the request with the given id + * @param id the id of the request to stop automatically refreshing + */ + stopAutoRefreshing(id: string) { + this.clearCurrentTimeout(id); + if (hasValue(this.subs.get(id))) { + this.subs.get(id).unsubscribe(); + this.subs.delete(id); + } + } + /** * Delete an existing object on the server * @param objectId The id of the object to be removed @@ -101,4 +202,74 @@ export class ProcessDataService extends IdentifiableDataService impleme public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { return this.deleteData.deleteByHref(href, copyVirtualMetadata); } + + /** + * Clear the timeout for the given id, if that timeout exists + * @protected + */ + protected clearCurrentTimeout(id: string): void { + const timeout = this.activelyBeingPolled.get(id); + if (hasValue(timeout)) { + clearTimeout(timeout); + } + this.activelyBeingPolled.delete(id); + } + + /** + * Poll the process with the given ID, using the given interval, until that process either + * completes successfully or fails + * + * Return an Observable for the Process. Note that this will also emit while the + * process is still running. It will only emit again when the process (not the RemoteData!) changes + * status. That makes it more convenient to retrieve that process for a component: you can replace + * a findByID call with this method, rather than having to do a separate findById, and then call + * this method + * + * @param processId The ID of the {@link Process} to poll + * @param pollingIntervalInMs The interval for how often the request needs to be polled + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be + * automatically resolved + */ + public autoRefreshUntilCompletion(processId: string, pollingIntervalInMs = 5000, ...linksToFollow: FollowLinkConfig[]): Observable> { + const process$: Observable> = this.findById(processId, true, true, ...linksToFollow) + .pipe( + getAllCompletedRemoteData(), + ); + + // Create a subscription that marks the data as stale if the process hasn't been completed and + // the polling interval time has been exceeded. + const sub = process$.pipe( + filter((processRD: RemoteData) => + !ProcessDataService.hasCompletedOrFailed(processRD.payload) && + !this.activelyBeingPolled.has(processId) + ) + ).subscribe((processRD: RemoteData) => { + this.clearCurrentTimeout(processId); + if (processRD.hasSucceeded) { + const nextTimeout = this.timer(() => { + this.activelyBeingPolled.delete(processId); + this.invalidateByHref(processRD.payload._links.self.href); + }, pollingIntervalInMs); + + this.activelyBeingPolled.set(processId, nextTimeout); + } + }); + + this.subs.set(processId, sub); + + // When the process completes create a one off subscription (the `find` completes the + // observable) that unsubscribes the previous one, removes the processId from the list of + // processes being polled and clears any running timeouts + process$.pipe( + find((processRD: RemoteData) => ProcessDataService.hasCompletedOrFailed(processRD.payload)) + ).subscribe(() => { + this.stopAutoRefreshing(processId); + }); + + return process$.pipe( + distinctUntilChanged((previous: RemoteData, current: RemoteData) => + previous.payload?.processStatus === current.payload?.processStatus, + ) + ); + } } diff --git a/src/app/core/data/relationship-data.service.spec.ts b/src/app/core/data/relationship-data.service.spec.ts index 4432d5213a..8ce67d19e0 100644 --- a/src/app/core/data/relationship-data.service.spec.ts +++ b/src/app/core/data/relationship-data.service.spec.ts @@ -23,6 +23,7 @@ import { FindListOptions } from './find-list-options.model'; import { testSearchDataImplementation } from './base/search-data.spec'; import { MetadataValue } from '../shared/metadata.models'; import { MetadataRepresentationType } from '../shared/metadata-representation/metadata-representation.model'; +import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; describe('RelationshipDataService', () => { let service: RelationshipDataService; @@ -114,14 +115,7 @@ describe('RelationshipDataService', () => { 'href': buildList$, 'https://rest.api/core/publication/relationships': relationships$ }); - const objectCache = Object.assign({ - /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ - remove: () => { - }, - hasBySelfLinkObservable: () => observableOf(false), - hasByHref$: () => observableOf(false) - /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ - }) as ObjectCacheService; + const objectCache = new ObjectCacheServiceStub(); const itemService = jasmine.createSpyObj('itemService', { findById: (uuid) => createSuccessfulRemoteDataObject(relatedItems.find((relatedItem) => relatedItem.id === uuid)), @@ -133,7 +127,7 @@ describe('RelationshipDataService', () => { requestService, rdbService, halService, - objectCache, + objectCache as ObjectCacheService, itemService, null, jasmine.createSpy('paginatedRelationsToItems').and.returnValue((v) => v), diff --git a/src/app/core/data/relationship-type-data.service.spec.ts b/src/app/core/data/relationship-type-data.service.spec.ts index 6a788446d8..ecd84f8288 100644 --- a/src/app/core/data/relationship-type-data.service.spec.ts +++ b/src/app/core/data/relationship-type-data.service.spec.ts @@ -10,6 +10,7 @@ import { RequestService } from './request.service'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { hasValueOperator } from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; describe('RelationshipTypeDataService', () => { let service: RelationshipTypeDataService; @@ -28,7 +29,7 @@ describe('RelationshipTypeDataService', () => { let buildList; let rdbService; - let objectCache; + let objectCache: ObjectCacheServiceStub; function init() { restEndpointURL = 'https://rest.api/relationshiptypes'; @@ -60,21 +61,14 @@ describe('RelationshipTypeDataService', () => { buildList = createSuccessfulRemoteDataObject(createPaginatedList([relationshipType1, relationshipType2])); rdbService = getMockRemoteDataBuildService(undefined, observableOf(buildList)); - objectCache = Object.assign({ - /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ - remove: () => { - }, - hasBySelfLinkObservable: () => observableOf(false) - /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ - }) as ObjectCacheService; - + objectCache = new ObjectCacheServiceStub(); } function initTestService() { return new RelationshipTypeDataService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, ); } diff --git a/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts index 50d0e43a99..6ab60ef2de 100644 --- a/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts +++ b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts @@ -22,6 +22,7 @@ import { import { ReplaceOperation } from 'fast-json-patch'; import { RequestEntry } from '../../../data/request-entry.model'; import { FindListOptions } from '../../../data/find-list-options.model'; +import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub'; describe('QualityAssuranceEventDataService', () => { let scheduler: TestScheduler; @@ -32,7 +33,7 @@ describe('QualityAssuranceEventDataService', () => { let responseCacheEntryC: RequestEntry; let requestService: RequestService; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: HALEndpointService; let notificationsService: NotificationsService; let http: HttpClient; @@ -91,7 +92,7 @@ describe('QualityAssuranceEventDataService', () => { buildFromRequestUUIDAndAwait: jasmine.createSpy('buildFromRequestUUIDAndAwait') }); - objectCache = {} as ObjectCacheService; + objectCache = new ObjectCacheServiceStub(); halService = jasmine.createSpyObj('halService', { getEndpoint: cold('a|', { a: endpointURL }) }); @@ -103,7 +104,7 @@ describe('QualityAssuranceEventDataService', () => { service = new QualityAssuranceEventDataService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, notificationsService, comparator diff --git a/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts index 50d9251bb8..105303d1f9 100644 --- a/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts +++ b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts @@ -19,6 +19,7 @@ import { } from '../../../../shared/mocks/notifications.mock'; import { RequestEntry } from '../../../data/request-entry.model'; import { QualityAssuranceSourceDataService } from './quality-assurance-source-data.service'; +import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub'; describe('QualityAssuranceSourceDataService', () => { let scheduler: TestScheduler; @@ -26,7 +27,7 @@ describe('QualityAssuranceSourceDataService', () => { let responseCacheEntry: RequestEntry; let requestService: RequestService; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: HALEndpointService; let notificationsService: NotificationsService; let http: HttpClient; @@ -63,7 +64,7 @@ describe('QualityAssuranceSourceDataService', () => { }), }); - objectCache = {} as ObjectCacheService; + objectCache = new ObjectCacheServiceStub(); halService = jasmine.createSpyObj('halService', { getEndpoint: cold('a|', { a: endpointURL }) }); @@ -75,7 +76,7 @@ describe('QualityAssuranceSourceDataService', () => { service = new QualityAssuranceSourceDataService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, notificationsService ); diff --git a/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts index 277da6e4ba..183d0a42f2 100644 --- a/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts +++ b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts @@ -19,6 +19,7 @@ import { qualityAssuranceTopicObjectMorePid } from '../../../../shared/mocks/notifications.mock'; import { RequestEntry } from '../../../data/request-entry.model'; +import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub'; describe('QualityAssuranceTopicDataService', () => { let scheduler: TestScheduler; @@ -26,7 +27,7 @@ describe('QualityAssuranceTopicDataService', () => { let responseCacheEntry: RequestEntry; let requestService: RequestService; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: HALEndpointService; let notificationsService: NotificationsService; let http: HttpClient; @@ -63,7 +64,7 @@ describe('QualityAssuranceTopicDataService', () => { }), }); - objectCache = {} as ObjectCacheService; + objectCache = new ObjectCacheServiceStub(); halService = jasmine.createSpyObj('halService', { getEndpoint: cold('a|', { a: endpointURL }) }); @@ -75,7 +76,7 @@ describe('QualityAssuranceTopicDataService', () => { service = new QualityAssuranceTopicDataService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, notificationsService ); diff --git a/src/app/core/resource-policy/resource-policy-data.service.spec.ts b/src/app/core/resource-policy/resource-policy-data.service.spec.ts index 7cfcaabb5d..e4c54d862c 100644 --- a/src/app/core/resource-policy/resource-policy-data.service.spec.ts +++ b/src/app/core/resource-policy/resource-policy-data.service.spec.ts @@ -20,13 +20,14 @@ import { FindListOptions } from '../data/find-list-options.model'; import { EPersonDataService } from '../eperson/eperson-data.service'; import { GroupDataService } from '../eperson/group-data.service'; import { RestRequestMethod } from '../data/rest-request-method'; +import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; describe('ResourcePolicyService', () => { let scheduler: TestScheduler; let service: ResourcePolicyDataService; let requestService: RequestService; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: HALEndpointService; let responseCacheEntry: RequestEntry; let ePersonService: EPersonDataService; @@ -139,14 +140,14 @@ describe('ResourcePolicyService', () => { a: 'https://rest.api/rest/api/eperson/groups/' + groupUUID }), }); - objectCache = {} as ObjectCacheService; + objectCache = new ObjectCacheServiceStub(); const notificationsService = {} as NotificationsService; const comparator = {} as any; service = new ResourcePolicyDataService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, notificationsService, comparator, diff --git a/src/app/core/submission/vocabularies/vocabulary.data.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.data.service.spec.ts index 4b35871418..eecf86a211 100644 --- a/src/app/core/submission/vocabularies/vocabulary.data.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.data.service.spec.ts @@ -7,8 +7,16 @@ */ import { VocabularyDataService } from './vocabulary.data.service'; import { testFindAllDataImplementation } from '../../data/base/find-all-data.spec'; +import { FindListOptions } from '../../data/find-list-options.model'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { createSuccessfulRemoteDataObject$ } from 'src/app/shared/remote-data.utils'; describe('VocabularyDataService', () => { + let service: VocabularyDataService; + service = initTestService(); + let restEndpointURL = 'https://rest.api/server/api/submission/vocabularies'; + let vocabularyByMetadataAndCollectionEndpoint = `${restEndpointURL}/search/byMetadataAndCollection?metadata=dc.contributor.author&collection=1234-1234`; + function initTestService() { return new VocabularyDataService(null, null, null, null); } @@ -17,4 +25,18 @@ describe('VocabularyDataService', () => { const initService = () => new VocabularyDataService(null, null, null, null); testFindAllDataImplementation(initService); }); + + describe('getVocabularyByMetadataAndCollection', () => { + it('search vocabulary by metadata and collection calls expected methods', () => { + spyOn((service as any).searchData, 'getSearchByHref').and.returnValue(vocabularyByMetadataAndCollectionEndpoint); + spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); + service.getVocabularyByMetadataAndCollection('dc.contributor.author', '1234-1234'); + const options = Object.assign(new FindListOptions(), { + searchParams: [Object.assign(new RequestParam('metadata', encodeURIComponent('dc.contributor.author'))), + Object.assign(new RequestParam('collection', encodeURIComponent('1234-1234')))] + }); + expect((service as any).searchData.getSearchByHref).toHaveBeenCalledWith('byMetadataAndCollection', options); + expect(service.findByHref).toHaveBeenCalledWith(vocabularyByMetadataAndCollectionEndpoint, true, true); + }); + }); }); diff --git a/src/app/core/submission/vocabularies/vocabulary.data.service.ts b/src/app/core/submission/vocabularies/vocabulary.data.service.ts index a67b67ced7..9215990dec 100644 --- a/src/app/core/submission/vocabularies/vocabulary.data.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.data.service.ts @@ -20,6 +20,8 @@ import { PaginatedList } from '../../data/paginated-list.model'; import { Injectable } from '@angular/core'; import { VOCABULARY } from './models/vocabularies.resource-type'; import { dataService } from '../../data/base/data-service.decorator'; +import { SearchDataImpl } from '../../data/base/search-data'; +import { RequestParam } from '../../cache/models/request-param.model'; /** * Data service to retrieve vocabularies from the REST server. @@ -27,7 +29,10 @@ import { dataService } from '../../data/base/data-service.decorator'; @Injectable() @dataService(VOCABULARY) export class VocabularyDataService extends IdentifiableDataService implements FindAllData { + protected searchByMetadataAndCollectionPath = 'byMetadataAndCollection'; + private findAllData: FindAllData; + private searchData: SearchDataImpl; constructor( protected requestService: RequestService, @@ -38,6 +43,7 @@ export class VocabularyDataService extends IdentifiableDataService i super('vocabularies', requestService, rdbService, objectCache, halService); this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** @@ -57,4 +63,23 @@ export class VocabularyDataService extends IdentifiableDataService i public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + + /** + * Return the controlled vocabulary configured for the specified metadata and collection if any (/submission/vocabularies/search/{@link searchByMetadataAndCollectionPath}?metadata=<>&collection=<>) + * @param metadataField metadata field to search + * @param collectionUUID collection UUID where is configured the vocabulary + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + public getVocabularyByMetadataAndCollection(metadataField: string, collectionUUID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + const findListOptions = new FindListOptions(); + findListOptions.searchParams = [new RequestParam('metadata', encodeURIComponent(metadataField)), + new RequestParam('collection', encodeURIComponent(collectionUUID))]; + const href$ = this.searchData.getSearchByHref(this.searchByMetadataAndCollectionPath, findListOptions, ...linksToFollow); + return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } } diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts index faa5823520..38824b3fac 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -25,6 +25,7 @@ import { createPaginatedList } from '../../../shared/testing/utils.test'; import { RequestEntry } from '../../data/request-entry.model'; import { VocabularyDataService } from './vocabulary.data.service'; import { VocabularyEntryDetailsDataService } from './vocabulary-entry-details.data.service'; +import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub'; describe('VocabularyService', () => { let scheduler: TestScheduler; @@ -205,6 +206,7 @@ describe('VocabularyService', () => { function initTestService() { hrefOnlyDataService = getMockHrefOnlyDataService(); + objectCache = new ObjectCacheServiceStub() as ObjectCacheService; return new VocabularyService( requestService, @@ -253,7 +255,9 @@ describe('VocabularyService', () => { spyOn((service as any).vocabularyDataService, 'findById').and.callThrough(); spyOn((service as any).vocabularyDataService, 'findAll').and.callThrough(); spyOn((service as any).vocabularyDataService, 'findByHref').and.callThrough(); + spyOn((service as any).vocabularyDataService, 'getVocabularyByMetadataAndCollection').and.callThrough(); spyOn((service as any).vocabularyDataService.findAllData, 'getFindAllHref').and.returnValue(observableOf(entriesRequestURL)); + spyOn((service as any).vocabularyDataService.searchData, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL)); }); afterEach(() => { @@ -310,6 +314,23 @@ describe('VocabularyService', () => { expect(result).toBeObservable(expected); }); }); + + describe('getVocabularyByMetadataAndCollection', () => { + it('should proxy the call to vocabularyDataService.getVocabularyByMetadataAndCollection', () => { + scheduler.schedule(() => service.getVocabularyByMetadataAndCollection(metadata, collectionUUID)); + scheduler.flush(); + + expect((service as any).vocabularyDataService.getVocabularyByMetadataAndCollection).toHaveBeenCalledWith(metadata, collectionUUID, true, true); + }); + + it('should return a RemoteData for the object with the given metadata and collection', () => { + const result = service.getVocabularyByMetadataAndCollection(metadata, collectionUUID); + const expected = cold('a|', { + a: vocabularyRD + }); + expect(result).toBeObservable(expected); + }); + }); }); describe('vocabulary entries', () => { diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index 1ff5b30ee0..2dd2cc3792 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -87,6 +87,23 @@ export class VocabularyService { return this.vocabularyDataService.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + /** + * Return the controlled vocabulary configured for the specified metadata and collection if any + * @param metadataField metadata field to search + * @param collectionUUID collection UUID where is configured the vocabulary + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits vocabulary object + */ + getVocabularyByMetadataAndCollection(metadataField: string, collectionUUID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.vocabularyDataService.getVocabularyByMetadataAndCollection(metadataField, collectionUUID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + /** * Return the {@link VocabularyEntry} list for a given {@link Vocabulary} * diff --git a/src/app/core/supervision-order/supervision-order-data.service.spec.ts b/src/app/core/supervision-order/supervision-order-data.service.spec.ts index b12817fa1a..b25d440fa2 100644 --- a/src/app/core/supervision-order/supervision-order-data.service.spec.ts +++ b/src/app/core/supervision-order/supervision-order-data.service.spec.ts @@ -17,13 +17,14 @@ import { RestResponse } from '../cache/response.models'; import { RequestEntry } from '../data/request-entry.model'; import { FindListOptions } from '../data/find-list-options.model'; import { GroupDataService } from '../eperson/group-data.service'; +import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; describe('SupervisionOrderService', () => { let scheduler: TestScheduler; let service: SupervisionOrderDataService; let requestService: RequestService; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: HALEndpointService; let responseCacheEntry: RequestEntry; let groupService: GroupDataService; @@ -127,14 +128,14 @@ describe('SupervisionOrderService', () => { a: 'https://rest.api/rest/api/group/groups/' + groupUUID }), }); - objectCache = {} as ObjectCacheService; + objectCache = new ObjectCacheServiceStub(); const notificationsService = {} as NotificationsService; const comparator = {} as any; service = new SupervisionOrderDataService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, notificationsService, comparator, diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html index 9f74216d54..aee9fb980c 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html @@ -3,6 +3,7 @@ -
+
{{ mdValue.newValue.value }}
- + + + + +
+ + + {{ dsoType + '.edit.metadata.authority.label' | translate }} {{ mdValue.newValue.authority }} + +
+
+
+ + + + +
+
{{ mdRepresentationName$ | async }} @@ -45,14 +92,14 @@ [disabled]="isVirtual || (!mdValue.change && mdValue.reordered) || (!mdValue.change && !mdValue.editing) || (saving$ | async)" (click)="undo.emit()"> +
-
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts index 459095ea67..5d7d04b690 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts @@ -11,6 +11,23 @@ import { ItemMetadataRepresentation } from '../../../core/shared/metadata-repres import { MetadataValue, VIRTUAL_METADATA_PREFIX } from '../../../core/shared/metadata.models'; import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form'; import { By } from '@angular/platform-browser'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { Item } from '../../../core/shared/item.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { Collection } from '../../../core/shared/collection.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { Vocabulary } from 'src/app/core/submission/vocabularies/models/vocabulary.model'; +import { VocabularyServiceStub } from 'src/app/shared/testing/vocabulary-service.stub'; +import { VocabularyService } from 'src/app/core/submission/vocabularies/vocabulary.service'; +import { ConfidenceType } from 'src/app/core/shared/confidence-type'; +import { DynamicOneboxModel } from 'src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; +import { Observable } from 'rxjs'; +import { DynamicScrollableDropdownModel } from 'src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; +import { RegistryService } from 'src/app/core/registry/registry.service'; +import { NotificationsService } from 'src/app/shared/notifications/notifications.service'; +import { createPaginatedList } from 'src/app/shared/testing/utils.test'; +import { MetadataField } from 'src/app/core/metadata/metadata-field.model'; +import { MetadataSchema } from 'src/app/core/metadata/metadata-schema.model'; const EDIT_BTN = 'edit'; const CONFIRM_BTN = 'confirm'; @@ -24,17 +41,111 @@ describe('DsoEditMetadataValueComponent', () => { let relationshipService: RelationshipDataService; let dsoNameService: DSONameService; + let vocabularyServiceStub: any; + let itemService: ItemDataService; + let registryService: RegistryService; + let notificationsService: NotificationsService; let editMetadataValue: DsoEditMetadataValue; let metadataValue: MetadataValue; + let dso: DSpaceObject; + + const collection = Object.assign(new Collection(), { + uuid: 'fake-uuid' + }); + + const item = Object.assign(new Item(), { + _links: { + self: { href: 'fake-item-url/item' } + }, + id: 'item', + uuid: 'item', + owningCollection: createSuccessfulRemoteDataObject$(collection) + }); + + const mockVocabularyScrollable: Vocabulary = { + id: 'scrollable', + name: 'scrollable', + scrollable: true, + hierarchical: false, + preloadLevel: 0, + type: 'vocabulary', + _links: { + self: { + href: 'self' + }, + entries: { + href: 'entries' + } + } + }; + + const mockVocabularyHierarchical: Vocabulary = { + id: 'hierarchical', + name: 'hierarchical', + scrollable: false, + hierarchical: true, + preloadLevel: 2, + type: 'vocabulary', + _links: { + self: { + href: 'self' + }, + entries: { + href: 'entries' + } + } + }; + + const mockVocabularySuggester: Vocabulary = { + id: 'suggester', + name: 'suggester', + scrollable: false, + hierarchical: false, + preloadLevel: 0, + type: 'vocabulary', + _links: { + self: { + href: 'self' + }, + entries: { + href: 'entries' + } + } + }; + + let metadataSchema: MetadataSchema; + let metadataFields: MetadataField[]; function initServices(): void { + metadataSchema = Object.assign(new MetadataSchema(), { + id: 0, + prefix: 'metadata', + namespace: 'http://example.com/', + }); + metadataFields = [ + Object.assign(new MetadataField(), { + id: 0, + element: 'regular', + qualifier: null, + schema: createSuccessfulRemoteDataObject$(metadataSchema), + }), + ]; + relationshipService = jasmine.createSpyObj('relationshipService', { resolveMetadataRepresentation: of(new ItemMetadataRepresentation(metadataValue)), }); dsoNameService = jasmine.createSpyObj('dsoNameService', { getName: 'Related Name', }); + itemService = jasmine.createSpyObj('itemService', { + findByHref: createSuccessfulRemoteDataObject$(item) + }); + vocabularyServiceStub = new VocabularyServiceStub(); + registryService = jasmine.createSpyObj('registryService', { + queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']); } beforeEach(waitForAsync(() => { @@ -45,6 +156,11 @@ describe('DsoEditMetadataValueComponent', () => { authority: undefined, }); editMetadataValue = new DsoEditMetadataValue(metadataValue); + dso = Object.assign(new DSpaceObject(), { + _links: { + self: { href: 'fake-dso-url/dso' } + }, + }); initServices(); @@ -54,6 +170,10 @@ describe('DsoEditMetadataValueComponent', () => { providers: [ { provide: RelationshipDataService, useValue: relationshipService }, { provide: DSONameService, useValue: dsoNameService }, + { provide: VocabularyService, useValue: vocabularyServiceStub }, + { provide: ItemDataService, useValue: itemService }, + { provide: RegistryService, useValue: registryService }, + { provide: NotificationsService, useValue: notificationsService }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -63,6 +183,7 @@ describe('DsoEditMetadataValueComponent', () => { fixture = TestBed.createComponent(DsoEditMetadataValueComponent); component = fixture.componentInstance; component.mdValue = editMetadataValue; + component.dso = dso; component.saving$ = of(false); fixture.detectChanges(); }); @@ -144,6 +265,222 @@ describe('DsoEditMetadataValueComponent', () => { assertButton(DRAG_BTN, true, false); }); + describe('when the metadata field not uses a vocabulary and is editing', () => { + beforeEach(waitForAsync(() => { + spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(null, 204)); + metadataValue = Object.assign(new MetadataValue(), { + value: 'Regular value', + language: 'en', + place: 0, + authority: null, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + editMetadataValue.editing = true; + component.mdValue = editMetadataValue; + component.mdField = 'metadata.regular'; + component.ngOnInit(); + fixture.detectChanges(); + })); + + it('should render a textarea', () => { + expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('textarea'))).toBeTruthy(); + }); + }); + + describe('when the metadata field uses a scrollable vocabulary and is editing', () => { + beforeEach(waitForAsync(() => { + spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyScrollable)); + metadataValue = Object.assign(new MetadataValue(), { + value: 'Authority Controlled value', + language: 'en', + place: 0, + authority: null, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + editMetadataValue.editing = true; + component.mdValue = editMetadataValue; + component.mdField = 'metadata.scrollable'; + component.ngOnInit(); + fixture.detectChanges(); + })); + + it('should render the DsDynamicScrollableDropdownComponent', () => { + expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('ds-dynamic-scrollable-dropdown'))).toBeTruthy(); + }); + + it('getModel should return a DynamicScrollableDropdownModel', () => { + const result = component.getModel(); + + expect(result instanceof Observable).toBe(true); + + result.subscribe((model) => { + expect(model instanceof DynamicScrollableDropdownModel).toBe(true); + expect(model.vocabularyOptions.name).toBe(mockVocabularyScrollable.name); + }); + }); + }); + + describe('when the metadata field uses a hierarchical vocabulary and is editing', () => { + beforeEach(waitForAsync(() => { + spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyHierarchical)); + metadataValue = Object.assign(new MetadataValue(), { + value: 'Authority Controlled value', + language: 'en', + place: 0, + authority: null, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + editMetadataValue.editing = true; + component.mdValue = editMetadataValue; + component.mdField = 'metadata.hierarchical'; + component.ngOnInit(); + fixture.detectChanges(); + })); + + it('should render the DsDynamicOneboxComponent', () => { + expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy(); + }); + + it('getModel should return a DynamicOneboxModel', () => { + const result = component.getModel(); + + expect(result instanceof Observable).toBe(true); + + result.subscribe((model) => { + expect(model instanceof DynamicOneboxModel).toBe(true); + expect(model.vocabularyOptions.name).toBe(mockVocabularyHierarchical.name); + }); + }); + }); + + describe('when the metadata field uses a suggester vocabulary and is editing', () => { + beforeEach(waitForAsync(() => { + spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularySuggester)); + spyOn(component.confirm, 'emit'); + metadataValue = Object.assign(new MetadataValue(), { + value: 'Authority Controlled value', + language: 'en', + place: 0, + authority: 'authority-key', + confidence: ConfidenceType.CF_UNCERTAIN + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + editMetadataValue.editing = true; + component.mdValue = editMetadataValue; + component.mdField = 'metadata.suggester'; + component.ngOnInit(); + fixture.detectChanges(); + })); + + it('should render the DsDynamicOneboxComponent', () => { + expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy(); + }); + + it('getModel should return a DynamicOneboxModel', () => { + const result = component.getModel(); + + expect(result instanceof Observable).toBe(true); + + result.subscribe((model) => { + expect(model instanceof DynamicOneboxModel).toBe(true); + expect(model.vocabularyOptions.name).toBe(mockVocabularySuggester.name); + }); + }); + + describe('authority key edition', () => { + + it('should update confidence to CF_NOVALUE when authority is cleared', () => { + component.mdValue.newValue.authority = ''; + + component.onChangeAuthorityKey(); + + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_NOVALUE); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + it('should update confidence to CF_ACCEPTED when authority key is edited', () => { + component.mdValue.newValue.authority = 'newAuthority'; + component.mdValue.originalValue.authority = 'oldAuthority'; + + component.onChangeAuthorityKey(); + + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + it('should not update confidence when authority key remains the same', () => { + component.mdValue.newValue.authority = 'sameAuthority'; + component.mdValue.originalValue.authority = 'sameAuthority'; + + component.onChangeAuthorityKey(); + + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNCERTAIN); + expect(component.confirm.emit).not.toHaveBeenCalled(); + }); + + it('should call onChangeEditingAuthorityStatus with true when clicking the lock button', () => { + spyOn(component, 'onChangeEditingAuthorityStatus'); + const lockButton = fixture.nativeElement.querySelector('#metadata-confirm-btn'); + + lockButton.click(); + + expect(component.onChangeEditingAuthorityStatus).toHaveBeenCalledWith(true); + }); + + it('should disable the input when editingAuthority is false', () => { + component.editingAuthority = false; + + fixture.detectChanges(); + + const inputElement = fixture.nativeElement.querySelector('input'); + expect(inputElement.disabled).toBe(true); + }); + + it('should enable the input when editingAuthority is true', () => { + component.editingAuthority = true; + + fixture.detectChanges(); + + const inputElement = fixture.nativeElement.querySelector('input'); + expect(inputElement.disabled).toBe(false); + }); + + it('should update mdValue.newValue properties when authority is present', () => { + const event = { + value: 'Some value', + authority: 'Some authority', + }; + + component.onChangeAuthorityField(event); + + expect(component.mdValue.newValue.value).toBe(event.value); + expect(component.mdValue.newValue.authority).toBe(event.authority); + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + it('should update mdValue.newValue properties when authority is not present', () => { + const event = { + value: 'Some value', + authority: null, + }; + + component.onChangeAuthorityField(event); + + expect(component.mdValue.newValue.value).toBe(event.value); + expect(component.mdValue.newValue.authority).toBeNull(); + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNSET); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + }); + + }); + function assertButton(name: string, exists: boolean, disabled: boolean = false): void { describe(`${name} button`, () => { let btn: DebugElement; diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts index 3fdcd381ab..29429ab3a0 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form'; import { Observable } from 'rxjs/internal/Observable'; import { @@ -8,10 +8,28 @@ import { import { RelationshipDataService } from '../../../core/data/relationship-data.service'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; -import { map } from 'rxjs/operators'; +import { map, switchMap, take } from 'rxjs/operators'; import { getItemPageRoute } from '../../../item-page/item-page-routing-paths'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { EMPTY } from 'rxjs/internal/observable/empty'; +import { VocabularyService } from 'src/app/core/submission/vocabularies/vocabulary.service'; +import { Vocabulary } from '../../../core/submission/vocabularies/models/vocabulary.model'; +import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model'; +import { ConfidenceType } from '../../../core/shared/confidence-type'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, getRemoteDataPayload, metadataFieldsToString } from '../../../core/shared/operators'; +import { DsDynamicOneboxModelConfig, DynamicOneboxModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; +import { DynamicScrollableDropdownModel, DynamicScrollableDropdownModelConfig } from '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { Item } from '../../../core/shared/item.model'; +import { Collection } from '../../../core/shared/collection.model'; +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { isNotEmpty } from '../../../shared/empty.util'; +import { of as observableOf } from 'rxjs'; +import { RegistryService } from 'src/app/core/registry/registry.service'; +import { TranslateService } from '@ngx-translate/core'; +import { NotificationsService } from 'src/app/shared/notifications/notifications.service'; @Component({ selector: 'ds-dso-edit-metadata-value', @@ -21,7 +39,7 @@ import { EMPTY } from 'rxjs/internal/observable/empty'; /** * Component displaying a single editable row for a metadata value */ -export class DsoEditMetadataValueComponent implements OnInit { +export class DsoEditMetadataValueComponent implements OnInit, OnChanges { /** * The parent {@link DSpaceObject} to display a metadata form for * Also used to determine metadata-representations in case of virtual metadata @@ -51,6 +69,11 @@ export class DsoEditMetadataValueComponent implements OnInit { */ @Input() isOnlyValue = false; + /** + * MetadataField to edit + */ + @Input() mdField?: string; + /** * Emits when the user clicked edit */ @@ -82,6 +105,12 @@ export class DsoEditMetadataValueComponent implements OnInit { */ public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType; + /** + * The ConfidenceType enumeration for access in the component's template + * @type {ConfidenceType} + */ + public ConfidenceTypeEnum = ConfidenceType; + /** * The item this metadata value represents in case it's virtual (if any, otherwise null) */ @@ -97,12 +126,48 @@ export class DsoEditMetadataValueComponent implements OnInit { */ mdRepresentationName$: Observable; + /** + * Whether or not the authority field is currently being edited + */ + public editingAuthority = false; + + /** + * Field group used by authority field + * @type {UntypedFormGroup} + */ + group = new UntypedFormGroup({ authorityField : new UntypedFormControl()}); + + /** + * Observable property of the model to use for editinf authorities values + */ + private model$: Observable; + + /** + * Observable with information about the authority vocabulary used + */ + private vocabulary$: Observable; + + /** + * Observables with information about the authority vocabulary type used + */ + private isAuthorityControlled$: Observable; + private isHierarchicalVocabulary$: Observable; + private isScrollableVocabulary$: Observable; + private isSuggesterVocabulary$: Observable; + constructor(protected relationshipService: RelationshipDataService, - protected dsoNameService: DSONameService) { + protected dsoNameService: DSONameService, + protected vocabularyService: VocabularyService, + protected itemService: ItemDataService, + protected cdr: ChangeDetectorRef, + protected registryService: RegistryService, + protected notificationsService: NotificationsService, + protected translate: TranslateService) { } ngOnInit(): void { this.initVirtualProperties(); + this.initAuthorityProperties(); } /** @@ -123,4 +188,223 @@ export class DsoEditMetadataValueComponent implements OnInit { map((mdRepresentation: ItemMetadataRepresentation) => mdRepresentation ? this.dsoNameService.getName(mdRepresentation) : null), ); } + + /** + * Initialise potential properties of a authority controlled metadata field + */ + initAuthorityProperties(): void { + + if (isNotEmpty(this.mdField)) { + + const owningCollection$: Observable = this.itemService.findByHref(this.dso._links.self.href, true, true, followLink('owningCollection')) + .pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((item: Item) => item.owningCollection), + getFirstSucceededRemoteData(), + getRemoteDataPayload() + ); + + this.vocabulary$ = owningCollection$.pipe( + switchMap((c: Collection) => this.vocabularyService + .getVocabularyByMetadataAndCollection(this.mdField, c.uuid) + .pipe( + getFirstSucceededRemoteDataPayload() + )) + ); + } else { + this.vocabulary$ = observableOf(undefined); + } + + this.isAuthorityControlled$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result)) + ); + + this.isHierarchicalVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result) && result.hierarchical) + ); + + this.isScrollableVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result) && result.scrollable) + ); + + this.isSuggesterVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result) && !result.hierarchical && !result.scrollable) + ); + + this.model$ = this.vocabulary$.pipe( + map((vocabulary: Vocabulary) => { + let formFieldValue; + if (isNotEmpty(this.mdValue.newValue.value)) { + formFieldValue = new FormFieldMetadataValueObject(); + formFieldValue.value = this.mdValue.newValue.value; + formFieldValue.display = this.mdValue.newValue.value; + if (this.mdValue.newValue.authority) { + formFieldValue.authority = this.mdValue.newValue.authority; + formFieldValue.confidence = this.mdValue.newValue.confidence; + } + } else { + formFieldValue = this.mdValue.newValue.value; + } + + let vocabularyOptions = vocabulary ? { + closed: false, + name: vocabulary.name + } as VocabularyOptions : null; + + if (!vocabulary.scrollable) { + let model: DsDynamicOneboxModelConfig = { + id: 'authorityField', + label: `${this.dsoType}.edit.metadata.edit.value`, + vocabularyOptions: vocabularyOptions, + metadataFields: [this.mdField], + value: formFieldValue, + repeatable: false, + submissionId: 'edit-metadata', + hasSelectableMetadata: false, + }; + return new DynamicOneboxModel(model); + } else { + let model: DynamicScrollableDropdownModelConfig = { + id: 'authorityField', + label: `${this.dsoType}.edit.metadata.edit.value`, + placeholder: `${this.dsoType}.edit.metadata.edit.value`, + vocabularyOptions: vocabularyOptions, + metadataFields: [this.mdField], + value: formFieldValue, + repeatable: false, + submissionId: 'edit-metadata', + hasSelectableMetadata: false, + maxOptions: 10 + }; + return new DynamicScrollableDropdownModel(model); + } + })); + } + + /** + * Change callback for the component. Check if the mdField has changed to retrieve whether it is metadata + * that uses a controlled vocabulary and update the related properties + * + * @param {SimpleChanges} changes + */ + ngOnChanges(changes: SimpleChanges): void { + if (isNotEmpty(changes.mdField) && !changes.mdField.firstChange) { + if (isNotEmpty(changes.mdField.currentValue) ) { + if (isNotEmpty(changes.mdField.previousValue) && + changes.mdField.previousValue !== changes.mdField.currentValue) { + // Clear authority value in case it has been assigned with the previous metadataField used + this.mdValue.newValue.authority = null; + this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; + } + + // Only ask if the current mdField have a period character to reduce request + if (changes.mdField.currentValue.includes('.')) { + this.validateMetadataField().subscribe((isValid: boolean) => { + if (isValid) { + this.initAuthorityProperties(); + this.cdr.detectChanges(); + } + }); + } + } + } + } + + /** + * Validate the metadata field to check if it exists on the server and return an observable boolean for success/error + */ + validateMetadataField(): Observable { + return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe( + getFirstCompletedRemoteData(), + switchMap((rd) => { + if (rd.hasSucceeded) { + return observableOf(rd).pipe( + metadataFieldsToString(), + take(1), + map((fields: string[]) => fields.indexOf(this.mdField) > -1) + ); + } else { + this.notificationsService.error(this.translate.instant(`${this.dsoType}.edit.metadata.metadatafield.error`), rd.errorMessage); + return [false]; + } + }), + ); + } + + /** + * Checks if this field use a authority vocabulary + */ + isAuthorityControlled(): Observable { + return this.isAuthorityControlled$; + } + + /** + * Checks if configured vocabulary is Hierarchical or not + */ + isHierarchicalVocabulary(): Observable { + return this.isHierarchicalVocabulary$; + } + + /** + * Checks if configured vocabulary is Scrollable or not + */ + isScrollableVocabulary(): Observable { + return this.isScrollableVocabulary$; + } + + /** + * Checks if configured vocabulary is Suggester or not + * (a vocabulary not Scrollable and not Hierarchical that uses an autocomplete field) + */ + isSuggesterVocabulary(): Observable { + return this.isSuggesterVocabulary$; + } + + /** + * Process the change of authority field value updating the authority key and confidence as necessary + */ + onChangeAuthorityField(event): void { + this.mdValue.newValue.value = event.value; + if (event.authority) { + this.mdValue.newValue.authority = event.authority; + this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; + } else { + this.mdValue.newValue.authority = null; + this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; + } + this.confirm.emit(false); + } + + /** + * Returns an observable with the {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model used + * for the authority field + */ + getModel(): Observable { + return this.model$; + } + + /** + * Change the status of the editingAuthority property + * @param status + */ + onChangeEditingAuthorityStatus(status: boolean) { + this.editingAuthority = status; + } + + /** + * Processes the change in authority value, updating the confidence as necessary. + * If the authority key is cleared, the confidence is set to {@link ConfidenceType.CF_NOVALUE}. + * If the authority key is edited and differs from the original, the confidence is set to {@link ConfidenceType.CF_ACCEPTED}. + */ + onChangeAuthorityKey() { + if (this.mdValue.newValue.authority === '') { + this.mdValue.newValue.confidence = ConfidenceType.CF_NOVALUE; + this.confirm.emit(false); + } else if (this.mdValue.newValue.authority !== this.mdValue.originalValue.authority) { + this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; + this.confirm.emit(false); + } + } + } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html index 8fb676a724..d6c72abdb9 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html @@ -40,6 +40,7 @@ [dsoType]="dsoType" [saving$]="savingOrLoadingFieldValidation$" [isOnlyValue]="true" + [mdField]="newMdField" (confirm)="confirmNewValue($event)" (remove)="form.newValue = undefined" (undo)="form.newValue = undefined"> diff --git a/src/app/dso-shared/dso-shared.module.ts b/src/app/dso-shared/dso-shared.module.ts index 7d44d6a920..47a94c3de8 100644 --- a/src/app/dso-shared/dso-shared.module.ts +++ b/src/app/dso-shared/dso-shared.module.ts @@ -7,10 +7,12 @@ import { DsoEditMetadataValueComponent } from './dso-edit-metadata/dso-edit-meta import { DsoEditMetadataHeadersComponent } from './dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component'; import { DsoEditMetadataValueHeadersComponent } from './dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component'; import { ThemedDsoEditMetadataComponent } from './dso-edit-metadata/themed-dso-edit-metadata.component'; +import { FormModule } from '../shared/form/form.module'; @NgModule({ imports: [ SharedModule, + FormModule ], declarations: [ DsoEditMetadataComponent, diff --git a/src/app/home-page/home-page.component.html b/src/app/home-page/home-page.component.html index 49329b3f04..c1d4b8a98c 100644 --- a/src/app/home-page/home-page.component.html +++ b/src/app/home-page/home-page.component.html @@ -1,10 +1,19 @@ -
- - - - - - +
+
+
+ +
+
+ + + + + + +
+
diff --git a/src/app/home-page/home-page.component.ts b/src/app/home-page/home-page.component.ts index 585fd06fe6..b4dfd06c33 100644 --- a/src/app/home-page/home-page.component.ts +++ b/src/app/home-page/home-page.component.ts @@ -10,6 +10,7 @@ import { NotifyInfoService } from '../core/coar-notify/notify-info/notify-info.s import { LinkDefinition, LinkHeadService } from '../core/services/link-head.service'; import { isNotEmpty } from '../shared/empty.util'; +import { APP_CONFIG, AppConfig } from 'src/config/app-config.interface'; @Component({ selector: 'ds-home-page', styleUrls: ['./home-page.component.scss'], @@ -25,6 +26,7 @@ export class HomePageComponent implements OnInit, OnDestroy { inboxLinks: LinkDefinition[] = []; constructor( + @Inject(APP_CONFIG) protected appConfig: AppConfig, private route: ActivatedRoute, private responseService: ServerResponseService, private notifyInfoService: NotifyInfoService, diff --git a/src/app/home-page/home-page.module.ts b/src/app/home-page/home-page.module.ts index 7b656abd73..e3debef5ca 100644 --- a/src/app/home-page/home-page.module.ts +++ b/src/app/home-page/home-page.module.ts @@ -3,7 +3,6 @@ import { NgModule } from '@angular/core'; import { SharedModule } from '../shared/shared.module'; import { HomeNewsComponent } from './home-news/home-news.component'; import { HomePageRoutingModule } from './home-page-routing.module'; - import { HomePageComponent } from './home-page.component'; import { TopLevelCommunityListComponent } from './top-level-community-list/top-level-community-list.component'; import { StatisticsModule } from '../statistics/statistics.module'; @@ -13,6 +12,7 @@ import { RecentItemListComponent } from './recent-item-list/recent-item-list.com import { JournalEntitiesModule } from '../entity-groups/journal-entities/journal-entities.module'; import { ResearchEntitiesModule } from '../entity-groups/research-entities/research-entities.module'; import { ThemedTopLevelCommunityListComponent } from './top-level-community-list/themed-top-level-community-list.component'; +import { SearchModule } from '../shared/search/search.module'; import { NotificationsModule } from '../notifications/notifications.module'; const DECLARATIONS = [ @@ -29,6 +29,7 @@ const DECLARATIONS = [ imports: [ CommonModule, SharedModule.withEntryComponents(), + SearchModule, JournalEntitiesModule.withEntryComponents(), ResearchEntitiesModule.withEntryComponents(), HomePageRoutingModule, diff --git a/src/app/process-page/detail/process-detail.component.html b/src/app/process-page/detail/process-detail.component.html index 4daee064df..1e9b4d054f 100644 --- a/src/app/process-page/detail/process-detail.component.html +++ b/src/app/process-page/detail/process-detail.component.html @@ -5,8 +5,8 @@ {{ 'process.detail.title' | translate:{ id: process?.processId, name: process?.scriptName } }}
-
- Refreshing in {{ seconds }}s +
+ {{ 'process.detail.refreshing' | translate }}
diff --git a/src/app/process-page/detail/process-detail.component.spec.ts b/src/app/process-page/detail/process-detail.component.spec.ts index 9a0d89a882..9ba5d6e94d 100644 --- a/src/app/process-page/detail/process-detail.component.spec.ts +++ b/src/app/process-page/detail/process-detail.component.spec.ts @@ -27,7 +27,6 @@ import { ProcessDataService } from '../../core/data/processes/process-data.servi import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { createFailedRemoteDataObject$, - createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; @@ -35,7 +34,10 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { getProcessListRoute } from '../process-page-routing.paths'; -import {ProcessStatus} from '../processes/process-status.model'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { RouterTestingModule } from '@angular/router/testing'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; describe('ProcessDetailComponent', () => { let component: ProcessDetailComponent; @@ -45,44 +47,18 @@ describe('ProcessDetailComponent', () => { let nameService: DSONameService; let bitstreamDataService: BitstreamDataService; let httpClient: HttpClient; - let route: ActivatedRoute; + let route: ActivatedRouteStub; + let router: RouterStub; + let modalService; + let notificationsService: NotificationsServiceStub; let process: Process; let fileName: string; let files: Bitstream[]; - let processOutput; - - let modalService; - let notificationsService; - - let router; + let processOutput: string; function init() { - processOutput = 'Process Started'; - process = Object.assign(new Process(), { - processId: 1, - scriptName: 'script-name', - processStatus: 'COMPLETED', - parameters: [ - { - name: '-f', - value: 'file.xml' - }, - { - name: '-i', - value: 'identifier' - } - ], - _links: { - self: { - href: 'https://rest.api/processes/1' - }, - output: { - href: 'https://rest.api/processes/1/output' - } - } - }); fileName = 'fake-file-name'; files = [ Object.assign(new Bitstream(), { @@ -100,6 +76,33 @@ describe('ProcessDetailComponent', () => { } }) ]; + processOutput = 'Process Started'; + process = Object.assign(new Process(), { + processId: 1, + scriptName: 'script-name', + processStatus: 'COMPLETED', + parameters: [ + { + name: '-f', + value: 'file.xml' + }, + { + name: '-i', + value: 'identifier' + } + ], + files: createSuccessfulRemoteDataObject$(Object.assign(new PaginatedList(), { + page: files, + })), + _links: { + self: { + href: 'https://rest.api/processes/1' + }, + output: { + href: 'https://rest.api/processes/1/output' + } + }, + }); const logBitstream = Object.assign(new Bitstream(), { id: 'output.log', _links: { @@ -110,6 +113,7 @@ describe('ProcessDetailComponent', () => { getFiles: createSuccessfulRemoteDataObject$(createPaginatedList(files)), delete: createSuccessfulRemoteDataObject$(null), findById: createSuccessfulRemoteDataObject$(process), + autoRefreshUntilCompletion: createSuccessfulRemoteDataObject$(process) }); bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { findByHref: createSuccessfulRemoteDataObject$(logBitstream) @@ -127,28 +131,22 @@ describe('ProcessDetailComponent', () => { notificationsService = new NotificationsServiceStub(); - router = jasmine.createSpyObj('router', { - navigateByUrl:{} - }); + router = new RouterStub(); - route = jasmine.createSpyObj('route', { - data: observableOf({ process: createSuccessfulRemoteDataObject(process) }), - snapshot: { - params: { id: process.processId } - } + route = new ActivatedRouteStub({ + id: process.processId, + }, { + process: createSuccessfulRemoteDataObject$(process), }); } beforeEach(waitForAsync(() => { init(); - TestBed.configureTestingModule({ + void TestBed.configureTestingModule({ declarations: [ProcessDetailComponent, ProcessDetailFieldComponent, VarDirective, FileSizePipe], - imports: [TranslateModule.forRoot()], + imports: [TranslateModule.forRoot(), RouterTestingModule], providers: [ - { - provide: ActivatedRoute, - useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }), snapshot: { params: { id: 1 } } }, - }, + { provide: ActivatedRoute, useValue: route }, { provide: ProcessDataService, useValue: processService }, { provide: BitstreamDataService, useValue: bitstreamDataService }, { provide: DSONameService, useValue: nameService }, @@ -253,6 +251,8 @@ describe('ProcessDetailComponent', () => { describe('deleteProcess', () => { it('should delete the process and navigate back to the overview page on success', () => { spyOn(component, 'closeModal'); + spyOn(router, 'navigateByUrl').and.callThrough(); + component.deleteProcess(process); expect(processService.delete).toHaveBeenCalledWith(process.processId); @@ -263,6 +263,7 @@ describe('ProcessDetailComponent', () => { it('should delete the process and not navigate on error', () => { (processService.delete as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); spyOn(component, 'closeModal'); + spyOn(router, 'navigateByUrl').and.callThrough(); component.deleteProcess(process); @@ -272,98 +273,4 @@ describe('ProcessDetailComponent', () => { expect(router.navigateByUrl).not.toHaveBeenCalled(); }); }); - - describe('refresh counter', () => { - const queryRefreshCounter = () => fixture.debugElement.query(By.css('.refresh-counter')); - - describe('if process is completed', () => { - beforeEach(() => { - process.processStatus = ProcessStatus.COMPLETED; - route.data = observableOf({process: createSuccessfulRemoteDataObject(process)}); - }); - - it('should not show', () => { - spyOn(component, 'startRefreshTimer'); - - const refreshCounter = queryRefreshCounter(); - expect(refreshCounter).toBeNull(); - - expect(component.startRefreshTimer).not.toHaveBeenCalled(); - }); - }); - - describe('if process is not finished', () => { - beforeEach(() => { - process.processStatus = ProcessStatus.RUNNING; - route.data = observableOf({process: createSuccessfulRemoteDataObject(process)}); - fixture.detectChanges(); - component.stopRefreshTimer(); - }); - - it('should call startRefreshTimer', () => { - spyOn(component, 'startRefreshTimer'); - - component.ngOnInit(); - fixture.detectChanges(); // subscribe to process observable with async pipe - - expect(component.startRefreshTimer).toHaveBeenCalled(); - }); - - it('should call refresh method every 5 seconds, until process is completed', fakeAsync(() => { - spyOn(component, 'refresh').and.callThrough(); - spyOn(component, 'stopRefreshTimer').and.callThrough(); - - // start off with a running process in order for the refresh counter starts counting up - process.processStatus = ProcessStatus.RUNNING; - // set findbyId to return a completed process - (processService.findById as jasmine.Spy).and.returnValue(observableOf(createSuccessfulRemoteDataObject(process))); - - component.ngOnInit(); - fixture.detectChanges(); // subscribe to process observable with async pipe - - expect(component.refresh).not.toHaveBeenCalled(); - - expect(component.refreshCounter$.value).toBe(0); - - tick(1001); // 1 second + 1 ms by the setTimeout - expect(component.refreshCounter$.value).toBe(5); // 5 - 0 - - tick(2001); // 2 seconds + 1 ms by the setTimeout - expect(component.refreshCounter$.value).toBe(3); // 5 - 2 - - tick(2001); // 2 seconds + 1 ms by the setTimeout - expect(component.refreshCounter$.value).toBe(1); // 3 - 2 - - tick(1001); // 1 second + 1 ms by the setTimeout - expect(component.refreshCounter$.value).toBe(0); // 1 - 1 - - // set the process to completed right before the counter checks the process - process.processStatus = ProcessStatus.COMPLETED; - (processService.findById as jasmine.Spy).and.returnValue(observableOf(createSuccessfulRemoteDataObject(process))); - - tick(1000); // 1 second - - expect(component.refresh).toHaveBeenCalledTimes(1); - expect(component.stopRefreshTimer).toHaveBeenCalled(); - - expect(component.refreshCounter$.value).toBe(0); - - tick(1001); // 1 second + 1 ms by the setTimeout - // startRefreshTimer not called again - expect(component.refreshCounter$.value).toBe(0); - - discardPeriodicTasks(); // discard any periodic tasks that have not yet executed - })); - - it('should show if refreshCounter is different from 0', () => { - component.refreshCounter$.next(1); - fixture.detectChanges(); - - const refreshCounter = queryRefreshCounter(); - expect(refreshCounter).not.toBeNull(); - }); - - }); - - }); }); diff --git a/src/app/process-page/detail/process-detail.component.ts b/src/app/process-page/detail/process-detail.component.ts index be0b6ad0f6..793721dbd8 100644 --- a/src/app/process-page/detail/process-detail.component.ts +++ b/src/app/process-page/detail/process-detail.component.ts @@ -1,8 +1,8 @@ import { HttpClient } from '@angular/common/http'; -import { Component, Inject, NgZone, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; +import { Component, Inject, NgZone, OnInit, PLATFORM_ID, OnDestroy } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { BehaviorSubject, interval, Observable, shareReplay, Subscription } from 'rxjs'; -import { finalize, map, switchMap, take, tap } from 'rxjs/operators'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { finalize, map, switchMap, take, tap, find, startWith, filter } from 'rxjs/operators'; import { AuthService } from '../../core/auth/auth.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { BitstreamDataService } from '../../core/data/bitstream-data.service'; @@ -14,7 +14,7 @@ import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, - getFirstSucceededRemoteDataPayload + getFirstSucceededRemoteDataPayload, getAllSucceededRemoteDataPayload } from '../../core/shared/operators'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { AlertType } from '../../shared/alert/alert-type'; @@ -26,8 +26,8 @@ import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { getProcessListRoute } from '../process-page-routing.paths'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; -import { followLink } from '../../shared/utils/follow-link-config.model'; import { isPlatformBrowser } from '@angular/common'; +import { PROCESS_PAGE_FOLLOW_LINKS } from '../process-page.resolver'; @Component({ selector: 'ds-process-detail', @@ -78,15 +78,17 @@ export class ProcessDetailComponent implements OnInit, OnDestroy { */ dateFormat = 'yyyy-MM-dd HH:mm:ss ZZZZ'; - refreshCounter$ = new BehaviorSubject(0); + isRefreshing$: Observable; + + isDeleting: boolean; + + protected autoRefreshingID: string; /** * Reference to NgbModal */ protected modalRef: NgbModalRef; - private refreshTimerSub?: Subscription; - constructor( @Inject(PLATFORM_ID) protected platformId: object, protected route: ActivatedRoute, @@ -108,69 +110,36 @@ export class ProcessDetailComponent implements OnInit, OnDestroy { */ ngOnInit(): void { this.processRD$ = this.route.data.pipe( - map((data) => { + switchMap((data) => { if (isPlatformBrowser(this.platformId)) { - if (!this.isProcessFinished(data.process.payload)) { - this.startRefreshTimer(); - } - } - - return data.process as RemoteData; - }), - redirectOn4xx(this.router, this.authService), - shareReplay(1) - ); - - this.filesRD$ = this.processRD$.pipe( - getFirstSucceededRemoteDataPayload(), - switchMap((process: Process) => this.processService.getFiles(process.processId)) - ); - } - - refresh() { - this.processRD$ = this.processService.findById( - this.route.snapshot.params.id, - false, - true, - followLink('script') - ).pipe( - getFirstSucceededRemoteData(), - redirectOn4xx(this.router, this.authService), - tap((processRemoteData: RemoteData) => { - if (!this.isProcessFinished(processRemoteData.payload)) { - this.startRefreshTimer(); - } - }), - shareReplay(1) - ); - - this.filesRD$ = this.processRD$.pipe( - getFirstSucceededRemoteDataPayload(), - switchMap((process: Process) => this.processService.getFiles(process.processId)) - ); - } - - startRefreshTimer() { - this.refreshCounter$.next(0); - - this.refreshTimerSub = interval(1000).subscribe( - value => { - if (value > 5) { - setTimeout(() => { - this.refresh(); - this.stopRefreshTimer(); - this.refreshCounter$.next(0); - }, 1); + this.autoRefreshingID = this.route.snapshot.params.id; + return this.processService.autoRefreshUntilCompletion(this.autoRefreshingID, 5000, ...PROCESS_PAGE_FOLLOW_LINKS); } else { - this.refreshCounter$.next(5 - value); + return [data.process as RemoteData]; } - }); + }), + filter(() => !this.isDeleting), + redirectOn4xx(this.router, this.authService), + ); + + this.isRefreshing$ = this.processRD$.pipe( + find((processRD: RemoteData) => ProcessDataService.hasCompletedOrFailed(processRD.payload)), + map(() => false), + startWith(true) + ); + + this.filesRD$ = this.processRD$.pipe( + getAllSucceededRemoteDataPayload(), + switchMap((process: Process) => process.files), + ); } - stopRefreshTimer() { - if (hasValue(this.refreshTimerSub)) { - this.refreshTimerSub.unsubscribe(); - this.refreshTimerSub = undefined; + /** + * Make sure the autoRefreshUntilCompletion is cleaned up properly + */ + ngOnDestroy() { + if (hasValue(this.autoRefreshingID)) { + this.processService.stopAutoRefreshing(this.autoRefreshingID); } } @@ -249,15 +218,17 @@ export class ProcessDetailComponent implements OnInit, OnDestroy { * @param process */ deleteProcess(process: Process) { + this.isDeleting = true; this.processService.delete(process.processId).pipe( getFirstCompletedRemoteData() ).subscribe((rd) => { if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get('process.detail.delete.success')); this.closeModal(); - this.router.navigateByUrl(getProcessListRoute()); + void this.router.navigateByUrl(getProcessListRoute()); } else { this.notificationsService.error(this.translateService.get('process.detail.delete.error')); + this.isDeleting = false; } }); } @@ -276,8 +247,4 @@ export class ProcessDetailComponent implements OnInit, OnDestroy { closeModal() { this.modalRef.close(); } - - ngOnDestroy(): void { - this.stopRefreshTimer(); - } } diff --git a/src/app/process-page/form/process-form.component.ts b/src/app/process-page/form/process-form.component.ts index 70eb3160a8..1c5d6b9516 100644 --- a/src/app/process-page/form/process-form.component.ts +++ b/src/app/process-page/form/process-form.component.ts @@ -7,8 +7,7 @@ import { ControlContainer, NgForm } from '@angular/forms'; import { ScriptParameter } from '../scripts/script-parameter.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; -import { RequestService } from '../../core/data/request.service'; -import { Router } from '@angular/router'; +import { Router, NavigationExtras } from '@angular/router'; import { getFirstCompletedRemoteData } from '../../core/shared/operators'; import { RemoteData } from '../../core/data/remote-data'; import { getProcessListRoute } from '../process-page-routing.paths'; @@ -57,7 +56,6 @@ export class ProcessFormComponent implements OnInit { private scriptService: ScriptDataService, private notificationsService: NotificationsService, private translationService: TranslateService, - private requestService: RequestService, private router: Router) { } @@ -91,7 +89,7 @@ export class ProcessFormComponent implements OnInit { const title = this.translationService.get('process.new.notification.success.title'); const content = this.translationService.get('process.new.notification.success.content'); this.notificationsService.success(title, content); - this.sendBack(); + this.sendBack(rd.payload); } else { const title = this.translationService.get('process.new.notification.error.title'); const content = this.translationService.get('process.new.notification.error.content'); @@ -143,11 +141,17 @@ export class ProcessFormComponent implements OnInit { return this.missingParameters.length > 0; } - private sendBack() { - this.requestService.removeByHrefSubstring('/processes'); - /* should subscribe on the previous method to know the action is finished and then navigate, - will fix this when the removeByHrefSubstring changes are merged */ - this.router.navigateByUrl(getProcessListRoute()); + /** + * Redirect the user to the processes overview page with the new process' ID, + * so it can be highlighted in the overview table. + * @param newProcess The newly created process + * @private + */ + private sendBack(newProcess: Process) { + const extras: NavigationExtras = { + queryParams: { new_process_id: newProcess.processId }, + }; + void this.router.navigate([getProcessListRoute()], extras); } } diff --git a/src/app/process-page/overview/process-overview.component.html b/src/app/process-page/overview/process-overview.component.html index 3f0e1e841f..8217cd8994 100644 --- a/src/app/process-page/overview/process-overview.component.html +++ b/src/app/process-page/overview/process-overview.component.html @@ -2,60 +2,46 @@

{{'process.overview.title' | translate}}

-
+ + +
+ + + + +
+ + +
+ + +
- + class="fas fa-plus pr-2">{{'process.overview.new' | translate}}
- -
- - - - - - - - - - - - - - - - - - - - - - - -
{{'process.overview.table.id' | translate}}{{'process.overview.table.name' | translate}}{{'process.overview.table.user' | translate}}{{'process.overview.table.start' | translate}}{{'process.overview.table.finish' | translate}}{{'process.overview.table.status' | translate}}{{'process.overview.table.actions' | translate}}
{{process.processId}}{{process.scriptName}}{{ePersonName}}{{process.startTime | date:dateFormat:'UTC'}}{{process.endTime | date:dateFormat:'UTC'}}{{process.processStatus}} - -
-
-
- +
@@ -90,4 +76,3 @@ - diff --git a/src/app/process-page/overview/process-overview.component.spec.ts b/src/app/process-page/overview/process-overview.component.spec.ts index 94071c0e59..39f50bb1a9 100644 --- a/src/app/process-page/overview/process-overview.component.spec.ts +++ b/src/app/process-page/overview/process-overview.component.spec.ts @@ -3,86 +3,27 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { VarDirective } from '../../shared/utils/var.directive'; import { TranslateModule } from '@ngx-translate/core'; import { RouterTestingModule } from '@angular/router/testing'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { NO_ERRORS_SCHEMA, TemplateRef } from '@angular/core'; import { ProcessDataService } from '../../core/data/processes/process-data.service'; -import { Process } from '../processes/process.model'; -import { EPersonDataService } from '../../core/eperson/eperson-data.service'; -import { EPerson } from '../../core/eperson/models/eperson.model'; import { By } from '@angular/platform-browser'; -import { ProcessStatus } from '../processes/process-status.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { createPaginatedList } from '../../shared/testing/utils.test'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; -import { DatePipe } from '@angular/common'; import { BehaviorSubject } from 'rxjs'; import { ProcessBulkDeleteService } from './process-bulk-delete.service'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ProcessOverviewService } from './process-overview.service'; describe('ProcessOverviewComponent', () => { let component: ProcessOverviewComponent; let fixture: ComponentFixture; let processService: ProcessDataService; - let ePersonService: EPersonDataService; - let paginationService; - - let processes: Process[]; - let ePerson: EPerson; let processBulkDeleteService; let modalService; - const pipe = new DatePipe('en-US'); - function init() { - processes = [ - Object.assign(new Process(), { - processId: 1, - scriptName: 'script-name', - startTime: '2020-03-19 00:30:00', - endTime: '2020-03-19 23:30:00', - processStatus: ProcessStatus.COMPLETED - }), - Object.assign(new Process(), { - processId: 2, - scriptName: 'script-name', - startTime: '2020-03-20 00:30:00', - endTime: '2020-03-20 23:30:00', - processStatus: ProcessStatus.FAILED - }), - Object.assign(new Process(), { - processId: 3, - scriptName: 'another-script-name', - startTime: '2020-03-21 00:30:00', - endTime: '2020-03-21 23:30:00', - processStatus: ProcessStatus.RUNNING - }) - ]; - ePerson = Object.assign(new EPerson(), { - metadata: { - 'eperson.firstname': [ - { - value: 'John', - language: null - } - ], - 'eperson.lastname': [ - { - value: 'Doe', - language: null - } - ] - } + processService = jasmine.createSpyObj('processOverviewService', { + timeStarted: '2024-02-05 16:43:32', }); - processService = jasmine.createSpyObj('processService', { - findAll: createSuccessfulRemoteDataObject$(createPaginatedList(processes)) - }); - ePersonService = jasmine.createSpyObj('ePersonService', { - findById: createSuccessfulRemoteDataObject$(ePerson) - }); - - paginationService = new PaginationServiceStub(); processBulkDeleteService = jasmine.createSpyObj('processBulkDeleteService', { clearAllProcesses: {}, @@ -96,11 +37,7 @@ describe('ProcessOverviewComponent', () => { }); (processBulkDeleteService.isToBeDeleted as jasmine.Spy).and.callFake((id) => { - if (id === 2) { - return true; - } else { - return false; - } + return id === 2; }); modalService = jasmine.createSpyObj('modalService', { @@ -114,9 +51,7 @@ describe('ProcessOverviewComponent', () => { declarations: [ProcessOverviewComponent, VarDirective], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], providers: [ - { provide: ProcessDataService, useValue: processService }, - { provide: EPersonDataService, useValue: ePersonService }, - { provide: PaginationService, useValue: paginationService }, + { provide: ProcessOverviewService, useValue: processService }, { provide: ProcessBulkDeleteService, useValue: processBulkDeleteService }, { provide: NgbModal, useValue: modalService }, ], @@ -130,73 +65,6 @@ describe('ProcessOverviewComponent', () => { fixture.detectChanges(); }); - describe('table structure', () => { - let rowElements; - - beforeEach(() => { - rowElements = fixture.debugElement.queryAll(By.css('tbody tr')); - }); - - it(`should contain 3 rows`, () => { - expect(rowElements.length).toEqual(3); - }); - - it('should display the process IDs in the first column', () => { - rowElements.forEach((rowElement, index) => { - const el = rowElement.query(By.css('td:nth-child(1)')).nativeElement; - expect(el.textContent).toContain(processes[index].processId); - }); - }); - - it('should display the script names in the second column', () => { - rowElements.forEach((rowElement, index) => { - const el = rowElement.query(By.css('td:nth-child(2)')).nativeElement; - expect(el.textContent).toContain(processes[index].scriptName); - }); - }); - - it('should display the eperson\'s name in the third column', () => { - rowElements.forEach((rowElement, index) => { - const el = rowElement.query(By.css('td:nth-child(3)')).nativeElement; - expect(el.textContent).toContain(ePerson.name); - }); - }); - - it('should display the start time in the fourth column', () => { - rowElements.forEach((rowElement, index) => { - const el = rowElement.query(By.css('td:nth-child(4)')).nativeElement; - expect(el.textContent).toContain(pipe.transform(processes[index].startTime, component.dateFormat, 'UTC')); - }); - }); - - it('should display the end time in the fifth column', () => { - rowElements.forEach((rowElement, index) => { - const el = rowElement.query(By.css('td:nth-child(5)')).nativeElement; - expect(el.textContent).toContain(pipe.transform(processes[index].endTime, component.dateFormat, 'UTC')); - }); - }); - - it('should display the status in the sixth column', () => { - rowElements.forEach((rowElement, index) => { - const el = rowElement.query(By.css('td:nth-child(6)')).nativeElement; - expect(el.textContent).toContain(processes[index].processStatus); - }); - }); - it('should display a delete button in the seventh column', () => { - rowElements.forEach((rowElement, index) => { - const el = rowElement.query(By.css('td:nth-child(7)')); - expect(el.nativeElement.innerHTML).toContain('fas fa-trash'); - - el.query(By.css('button')).triggerEventHandler('click', null); - expect(processBulkDeleteService.toggleDelete).toHaveBeenCalledWith(processes[index].processId); - }); - }); - it('should indicate a row that has been selected for deletion', () => { - const deleteRow = fixture.debugElement.query(By.css('.table-danger')); - expect(deleteRow.nativeElement.innerHTML).toContain('/processes/' + processes[1].processId); - }); - }); - describe('overview buttons', () => { it('should show a button to clear selected processes when there are selected processes', () => { const clearButton = fixture.debugElement.query(By.css('.btn-primary')); @@ -232,7 +100,7 @@ describe('ProcessOverviewComponent', () => { describe('openDeleteModal', () => { it('should open the modal', () => { - component.openDeleteModal({}); + component.openDeleteModal({} as TemplateRef); expect(modalService.open).toHaveBeenCalledWith({}); }); }); @@ -240,13 +108,11 @@ describe('ProcessOverviewComponent', () => { describe('deleteSelected', () => { it('should call the deleteSelectedProcesses method on the processBulkDeleteService and close the modal when processing is done', () => { spyOn(component, 'closeModal'); - spyOn(component, 'setProcesses'); component.deleteSelected(); expect(processBulkDeleteService.deleteSelectedProcesses).toHaveBeenCalled(); expect(component.closeModal).toHaveBeenCalled(); - expect(component.setProcesses).toHaveBeenCalled(); }); }); }); diff --git a/src/app/process-page/overview/process-overview.component.ts b/src/app/process-page/overview/process-overview.component.ts index 7fa3b12dac..3f8c2b4bfb 100644 --- a/src/app/process-page/overview/process-overview.component.ts +++ b/src/app/process-page/overview/process-overview.component.ts @@ -1,20 +1,10 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { Observable, Subscription } from 'rxjs'; -import { RemoteData } from '../../core/data/remote-data'; -import { PaginatedList } from '../../core/data/paginated-list.model'; -import { Process } from '../processes/process.model'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { EPersonDataService } from '../../core/eperson/eperson-data.service'; -import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; -import { EPerson } from '../../core/eperson/models/eperson.model'; -import { map, switchMap } from 'rxjs/operators'; -import { ProcessDataService } from '../../core/data/processes/process-data.service'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { FindListOptions } from '../../core/data/find-list-options.model'; +import { Component, OnDestroy, OnInit, TemplateRef } from '@angular/core'; +import { Subscription } from 'rxjs'; import { ProcessBulkDeleteService } from './process-bulk-delete.service'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { hasValue } from '../../shared/empty.util'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { ProcessOverviewService, ProcessSortField } from './process-overview.service'; +import { ProcessStatus } from '../processes/process-status.model'; @Component({ selector: 'ds-process-overview', @@ -25,72 +15,25 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; */ export class ProcessOverviewComponent implements OnInit, OnDestroy { - /** - * List of all processes - */ - processesRD$: Observable>>; + // Enums are redeclared here so they can be used in the template + protected readonly ProcessStatus = ProcessStatus; + protected readonly ProcessSortField = ProcessSortField; - /** - * The current pagination configuration for the page used by the FindAll method - */ - config: FindListOptions = Object.assign(new FindListOptions(), { - elementsPerPage: 20 - }); - - /** - * The current pagination configuration for the page - */ - pageConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { - id: 'po', - pageSize: 20 - }); - - /** - * Date format to use for start and end time of processes - */ - dateFormat = 'yyyy-MM-dd HH:mm:ss'; - - processesToDelete: string[] = []; private modalRef: any; isProcessingSub: Subscription; - constructor(protected processService: ProcessDataService, - protected paginationService: PaginationService, - protected ePersonService: EPersonDataService, + constructor(protected processOverviewService: ProcessOverviewService, protected modalService: NgbModal, public processBulkDeleteService: ProcessBulkDeleteService, - protected dsoNameService: DSONameService, ) { } ngOnInit(): void { - this.setProcesses(); this.processBulkDeleteService.clearAllProcesses(); } - /** - * Send a request to fetch all processes for the current page - */ - setProcesses() { - this.processesRD$ = this.paginationService.getFindListOptions(this.pageConfig.id, this.config).pipe( - switchMap((config) => this.processService.findAll(config, true, false)) - ); - } - - /** - * Get the name of an EPerson by ID - * @param id ID of the EPerson - */ - getEpersonName(id: string): Observable { - return this.ePersonService.findById(id).pipe( - getFirstSucceededRemoteDataPayload(), - map((eperson: EPerson) => this.dsoNameService.getName(eperson)), - ); - } - ngOnDestroy(): void { - this.paginationService.clearPagination(this.pageConfig.id); if (hasValue(this.isProcessingSub)) { this.isProcessingSub.unsubscribe(); } @@ -100,7 +43,7 @@ export class ProcessOverviewComponent implements OnInit, OnDestroy { * Open a given modal. * @param content - the modal content. */ - openDeleteModal(content) { + openDeleteModal(content: TemplateRef) { this.modalRef = this.modalService.open(content); } @@ -126,7 +69,6 @@ export class ProcessOverviewComponent implements OnInit, OnDestroy { .subscribe((isProcessing) => { if (!isProcessing) { this.closeModal(); - this.setProcesses(); } }); } diff --git a/src/app/process-page/overview/process-overview.service.ts b/src/app/process-page/overview/process-overview.service.ts new file mode 100644 index 0000000000..78287ca182 --- /dev/null +++ b/src/app/process-page/overview/process-overview.service.ts @@ -0,0 +1,97 @@ +import { Injectable } from '@angular/core'; +import { ProcessDataService } from '../../core/data/processes/process-data.service'; +import { FindListOptions } from '../../core/data/find-list-options.model'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { Process } from '../processes/process.model'; +import { RequestParam } from '../../core/cache/models/request-param.model'; +import { ProcessStatus } from '../processes/process-status.model'; +import { DatePipe } from '@angular/common'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { SortOptions, SortDirection } from '../../core/cache/models/sort-options.model'; +import { hasValue } from '../../shared/empty.util'; + +/** + * The sortable fields for processes + * See [the endpoint documentation]{@link https://github.com/DSpace/RestContract/blob/main/processes-endpoint.md#search-processes-by-property} + * for details. + */ +export enum ProcessSortField { + creationTime = 'creationTime', + startTime = 'startTime', + endTime = 'endTime', +} + +/** + * Service to manage the processes displayed in the + * {@Link ProcessOverviewComponent} and the {@Link ProcessOverviewTableComponent} + */ +@Injectable({ + providedIn: 'root', +}) +export class ProcessOverviewService { + + constructor(protected processDataService: ProcessDataService) { + } + + /** + * Date format to use for start and end time of processes + */ + dateFormat = 'yyyy-MM-dd HH:mm:ss'; + + datePipe = new DatePipe('en-US'); + + + timeCreated = (process: Process) => this.datePipe.transform(process.creationTime, this.dateFormat, 'UTC'); + timeCompleted = (process: Process) => this.datePipe.transform(process.endTime, this.dateFormat, 'UTC'); + timeStarted = (process: Process) => this.datePipe.transform(process.startTime, this.dateFormat, 'UTC'); + + /** + * Retrieve processes by their status + * @param processStatus The status for which to retrieve processes + * @param findListOptions The FindListOptions object + * @param autoRefreshingIntervalInMs Optional: The interval by which to automatically refresh the retrieved processes. + * Leave empty or set to null to only retrieve the processes once. + */ + getProcessesByProcessStatus(processStatus: ProcessStatus, findListOptions?: FindListOptions, autoRefreshingIntervalInMs: number = null): Observable>> { + let requestParam = new RequestParam('processStatus', processStatus); + let options: FindListOptions = Object.assign(new FindListOptions(), { + searchParams: [requestParam], + elementsPerPage: 5, + }, findListOptions); + + if (hasValue(autoRefreshingIntervalInMs) && autoRefreshingIntervalInMs > 0) { + this.processDataService.stopAutoRefreshing(processStatus); + return this.processDataService.autoRefreshingSearchBy(processStatus, 'byProperty', options, autoRefreshingIntervalInMs); + } else { + return this.processDataService.searchBy('byProperty', options); + } + } + + /** + * Stop auto-refreshing the process with the given status + * @param processStatus the processStatus of the request to stop automatically refreshing + */ + stopAutoRefreshing(processStatus: ProcessStatus) { + this.processDataService.stopAutoRefreshing(processStatus); + } + + /** + * Map the provided paginationOptions to FindListOptions + * @param paginationOptions the PaginationComponentOptions to map + * @param sortField the field on which the processes are sorted + */ + getFindListOptions(paginationOptions: PaginationComponentOptions, sortField: ProcessSortField): FindListOptions { + let sortOptions = new SortOptions(sortField, SortDirection.DESC); + return Object.assign( + new FindListOptions(), + { + currentPage: paginationOptions.currentPage, + elementsPerPage: paginationOptions.pageSize, + sort: sortOptions, + } + ); + } + +} diff --git a/src/app/process-page/overview/table/process-overview-table.component.html b/src/app/process-page/overview/table/process-overview-table.component.html new file mode 100644 index 0000000000..c59c624b0f --- /dev/null +++ b/src/app/process-page/overview/table/process-overview-table.component.html @@ -0,0 +1,66 @@ +
+
+

+ {{'process.overview.table.' + processStatus.toLowerCase() + '.title' | translate}} + + {{processesRD?.payload?.totalElements}} + + + + +

+
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
{{'process.overview.table.id' | translate}}{{'process.overview.table.name' | translate}}{{'process.overview.table.user' | translate}}{{'process.overview.table.' + processStatus.toLowerCase() + '.info' | translate}}{{'process.overview.table.actions' | translate}}
{{tableEntry.process.processId}}{{tableEntry.process.scriptName}}{{tableEntry.user}}{{tableEntry.info}} + +
+
+ +
+ +
+

{{'process.overview.table.empty' | translate}}

+
+
+ +
+
diff --git a/src/app/process-page/overview/table/process-overview-table.component.scss b/src/app/process-page/overview/table/process-overview-table.component.scss new file mode 100644 index 0000000000..a9567be4c7 --- /dev/null +++ b/src/app/process-page/overview/table/process-overview-table.component.scss @@ -0,0 +1,28 @@ +.toggle-icon { + font-size: calc(var(--bs-small-font-size) * 0.6); +} + +.badge-nb-processes { + font-size: var(--ds-process-overview-table-nb-processes-badge-size); + vertical-align: middle; +} + +.id-header { + width: var(--ds-process-overview-table-id-column-width); +} + +.name-header { + width: var(--ds-process-overview-table-name-column-width); +} + +.user-header { + width: var(--ds-process-overview-table-user-column-width); +} + +.info-header { + width: var(--ds-process-overview-table-info-column-width); +} + +.actions-header { + width: var(--ds-process-overview-table-actions-column-width); +} diff --git a/src/app/process-page/overview/table/process-overview-table.component.spec.ts b/src/app/process-page/overview/table/process-overview-table.component.spec.ts new file mode 100644 index 0000000000..39520fa923 --- /dev/null +++ b/src/app/process-page/overview/table/process-overview-table.component.spec.ts @@ -0,0 +1,205 @@ +import { ProcessOverviewTableComponent } from './process-overview-table.component'; +import { ComponentFixture, waitForAsync, TestBed } from '@angular/core/testing'; +import { ProcessDataService } from '../../../core/data/processes/process-data.service'; +import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; +import { Process } from '../../processes/process.model'; +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { ProcessBulkDeleteService } from '../process-bulk-delete.service'; +import { ProcessStatus } from '../../processes/process-status.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { BehaviorSubject } from 'rxjs'; +import { NgbModal, NgbCollapse } from '@ng-bootstrap/ng-bootstrap'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { By } from '@angular/platform-browser'; +import { AuthService } from '../../../core/auth/auth.service'; +import { AuthServiceMock } from '../../../shared/mocks/auth.service.mock'; +import { RouteService } from '../../../core/services/route.service'; +import { routeServiceStub } from '../../../shared/testing/route-service.stub'; +import { ProcessOverviewService } from '../process-overview.service'; +import { take } from 'rxjs/operators'; + + +describe('ProcessOverviewTableComponent', () => { + let component: ProcessOverviewTableComponent; + let fixture: ComponentFixture; + + let processOverviewService: ProcessOverviewService; + let processService: ProcessDataService; + let ePersonService: EPersonDataService; + let paginationService; // : PaginationService; Not typed as the stub does not fully implement PaginationService + let processBulkDeleteService: ProcessBulkDeleteService; + let modalService: NgbModal; + let authService; // : AuthService; Not typed as the mock does not fully implement AuthService + let routeService: RouteService; + + let processes: Process[]; + let ePerson: EPerson; + + function init() { + processes = [ + Object.assign(new Process(), { + processId: 1, + scriptName: 'script-a', + startTime: '2020-03-19 00:30:00', + endTime: '2020-03-19 23:30:00', + processStatus: ProcessStatus.COMPLETED + }), + Object.assign(new Process(), { + processId: 2, + scriptName: 'script-b', + startTime: '2020-03-20 00:30:00', + endTime: '2020-03-20 23:30:00', + processStatus: ProcessStatus.FAILED + }), + Object.assign(new Process(), { + processId: 3, + scriptName: 'script-c', + startTime: '2020-03-21 00:30:00', + endTime: '2020-03-21 23:30:00', + processStatus: ProcessStatus.RUNNING + }), + ]; + ePerson = Object.assign(new EPerson(), { + metadata: { + 'eperson.firstname': [ + { + value: 'John', + language: null + } + ], + 'eperson.lastname': [ + { + value: 'Doe', + language: null + } + ] + } + }); + processOverviewService = jasmine.createSpyObj('processOverviewService', { + getFindListOptions: { + currentPage: 1, + elementsPerPage: 5, + sort: 'creationTime' + }, + getProcessesByProcessStatus: createSuccessfulRemoteDataObject$(createPaginatedList(processes)).pipe(take(1)) + }); + processService = jasmine.createSpyObj('processService', { + searchBy: createSuccessfulRemoteDataObject$(createPaginatedList(processes)).pipe(take(1)) + }); + ePersonService = jasmine.createSpyObj('ePersonService', { + findById: createSuccessfulRemoteDataObject$(ePerson) + }); + + paginationService = new PaginationServiceStub(); + + processBulkDeleteService = jasmine.createSpyObj('processBulkDeleteService', { + clearAllProcesses: {}, + deleteSelectedProcesses: {}, + isProcessing$: new BehaviorSubject(false), + hasSelected: true, + isToBeDeleted: true, + toggleDelete: {}, + getAmountOfSelectedProcesses: 5 + + }); + + (processBulkDeleteService.isToBeDeleted as jasmine.Spy).and.callFake((id) => { + return id === 2; + }); + + modalService = jasmine.createSpyObj('modalService', { + open: {} + }); + + authService = new AuthServiceMock(); + routeService = routeServiceStub; + } + + beforeEach(waitForAsync(() => { + init(); + + void TestBed.configureTestingModule({ + declarations: [ProcessOverviewTableComponent, VarDirective, NgbCollapse], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: ProcessOverviewService, useValue: processOverviewService }, + { provide: ProcessDataService, useValue: processService }, + { provide: EPersonDataService, useValue: ePersonService }, + { provide: PaginationService, useValue: paginationService }, + { provide: ProcessBulkDeleteService, useValue: processBulkDeleteService }, + { provide: NgbModal, useValue: modalService }, + { provide: AuthService, useValue: authService }, + { provide: RouteService, useValue: routeService }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProcessOverviewTableComponent); + component = fixture.componentInstance; + component.getInfoValueMethod = (_process: Process) => 'process info'; + component.processStatus = ProcessStatus.COMPLETED; + fixture.detectChanges(); + }); + + describe('table structure', () => { + let rowElements; + + beforeEach(() => { + rowElements = fixture.debugElement.queryAll(By.css('tbody tr')); + }); + + it('should contain 3 rows', () => { + expect(rowElements.length).toEqual(3); + }); + + it('should display the process\' ID in the first column', () => { + rowElements.forEach((rowElement, index) => { + const el = rowElement.query(By.css('td:nth-child(1)')).nativeElement; + expect(el.textContent).toContain(processes[index].processId); + }); + }); + + it('should display the scripts name in the second column', () => { + rowElements.forEach((rowElement, index) => { + const el = rowElement.query(By.css('td:nth-child(2)')).nativeElement; + expect(el.textContent).toContain(processes[index].scriptName); + }); + }); + + it('should display the eperson\'s name in the third column', () => { + rowElements.forEach((rowElement, _index) => { + const el = rowElement.query(By.css('td:nth-child(3)')).nativeElement; + expect(el.textContent).toContain(ePerson.name); + }); + }); + + it('should display the requested info in the fourth column', () => { + rowElements.forEach((rowElement, _index) => { + const el = rowElement.query(By.css('td:nth-child(4)')).nativeElement; + expect(el.textContent).toContain('process info'); + }); + }); + + it('should display a delete button in the fifth column', () => { + rowElements.forEach((rowElement, index) => { + const el = rowElement.query(By.css('td:nth-child(5)')); + expect(el.nativeElement.innerHTML).toContain('fas fa-trash'); + + el.query(By.css('button')).triggerEventHandler('click', null); + expect(processBulkDeleteService.toggleDelete).toHaveBeenCalledWith(processes[index].processId); + }); + }); + + it('should indicate a row that has been selected for deletion', () => { + const deleteRow = fixture.debugElement.query(By.css('.table-danger')); + expect(deleteRow.nativeElement.innerHTML).toContain('/processes/' + processes[1].processId); + }); + + }); +}); diff --git a/src/app/process-page/overview/table/process-overview-table.component.ts b/src/app/process-page/overview/table/process-overview-table.component.ts new file mode 100644 index 0000000000..7bd5c02b43 --- /dev/null +++ b/src/app/process-page/overview/table/process-overview-table.component.ts @@ -0,0 +1,249 @@ +import { Component, Input, OnInit, Inject, PLATFORM_ID, OnDestroy } from '@angular/core'; +import { ProcessStatus } from '../../processes/process-status.model'; +import { Observable, mergeMap, from as observableFrom, BehaviorSubject, Subscription } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { Process } from '../../processes/process.model'; +import { + PaginationComponentOptions +} from '../../../shared/pagination/pagination-component-options.model'; +import { ProcessOverviewService, ProcessSortField } from '../process-overview.service'; +import { ProcessBulkDeleteService } from '../process-bulk-delete.service'; +import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { + getFirstSucceededRemoteDataPayload, + getAllCompletedRemoteData +} from '../../../core/shared/operators'; +import { map, switchMap, toArray, take, filter } from 'rxjs/operators'; +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { PaginationService } from 'src/app/core/pagination/pagination.service'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { redirectOn4xx } from '../../../core/shared/authorized.operators'; +import { Router } from '@angular/router'; +import { AuthService } from '../../../core/auth/auth.service'; +import { isPlatformBrowser } from '@angular/common'; +import { RouteService } from '../../../core/services/route.service'; +import { hasValue } from '../../../shared/empty.util'; + +const NEW_PROCESS_PARAM = 'new_process_id'; + +/** + * An interface to store a process and extra information related to the process + * that is displayed in the overview table. + */ +export interface ProcessOverviewTableEntry { + process: Process, + user: string, + info: string, +} + +@Component({ + selector: 'ds-process-overview-table', + styleUrls: ['./process-overview-table.component.scss'], + templateUrl: './process-overview-table.component.html' +}) +export class ProcessOverviewTableComponent implements OnInit, OnDestroy { + + /** + * The status of the processes this sections should show + */ + @Input() processStatus: ProcessStatus; + + /** + * The field on which the processes in this table are sorted + * {@link ProcessSortField.creationTime} by default as every single process has a creation time, + * but not every process has a start or end time + */ + @Input() sortField: ProcessSortField = ProcessSortField.creationTime; + + /** + * Whether to use auto refresh for the processes shown in this table. + */ + @Input() useAutoRefreshingSearchBy = false; + + /** + * The interval by which to refresh if autoRefreshing is enabled + */ + @Input() autoRefreshInterval = 5000; + + /** + * The function used to retrieve the value that will be shown in the 'info' column of the table. + * {@Link ProcessOverviewService} contains some predefined functions. + */ + @Input() getInfoValueMethod: (process: Process) => string; + + /** + * List of processes and their info to be shown in this table + */ + processesRD$: BehaviorSubject>>; + + /** + * The pagination ID for this overview section + */ + paginationId: string; + + /** + * The current pagination options for the overview section + */ + paginationOptions$: Observable; + + /** + * Whether the table is collapsed + */ + isCollapsed = false; + + /** + * The id of the process to highlight + */ + newProcessId: string; + + /** + * List of subscriptions + */ + subs: Subscription[] = []; + + constructor(protected processOverviewService: ProcessOverviewService, + protected processBulkDeleteService: ProcessBulkDeleteService, + protected ePersonDataService: EPersonDataService, + protected dsoNameService: DSONameService, + protected paginationService: PaginationService, + protected routeService: RouteService, + protected router: Router, + protected auth: AuthService, + @Inject(PLATFORM_ID) protected platformId: object, + ) { + } + + ngOnInit() { + // Only auto refresh on browsers + if (!isPlatformBrowser(this.platformId)) { + this.useAutoRefreshingSearchBy = false; + } + + this.routeService.getQueryParameterValue(NEW_PROCESS_PARAM).pipe(take(1)).subscribe((id) => { + this.newProcessId = id; + }); + + // Creates an ID from the first 2 characters of the process status. + // Should two process status values ever start with the same substring, + // increase the number of characters until the ids are distinct. + this.paginationId = this.processStatus.toLowerCase().substring(0,2); + + let defaultPaginationOptions = Object.assign(new PaginationComponentOptions(), { + id: this.paginationId, + pageSize: 5, + }); + + // Get the current pagination from the route + this.paginationOptions$ = this.paginationService.getCurrentPagination(this.paginationId, defaultPaginationOptions); + + this.processesRD$ = new BehaviorSubject(undefined); + + // Once we have the pagination, retrieve the processes matching the process type and the pagination + // + // Reasoning why this monstrosity is the way it is: + // To avoid having to recalculate the names of the submitters every time the page reloads, these have to be + // retrieved beforehand and stored with the process. This is where the ProcessOverviewTableEntry interface comes in. + // By storing the process together with the submitters name and the additional information to be shown in the table, + // the template can be as dumb as possible. As the retrieval of the name also is done through an observable, this + // complicates the construction of the data a bit though. + // The reason why we store these as RemoteData> and not simply as + // ProcessOverviewTableEntry[] is as follows: + // When storing the PaginatedList and ProcessOverviewTableEntry[] separately, there is a small delay + // between the update of the paginatedList and the entryArray. This results in the processOverviewPage showing + // no processes for a split second every time the processes are updated which in turn causes the different + // sections of the page to jump around. By combining these and causing the page to update only once this is avoided. + this.subs.push(this.paginationOptions$ + .pipe( + // Map the paginationOptions to findListOptions + map((paginationOptions: PaginationComponentOptions) => + this.processOverviewService.getFindListOptions(paginationOptions, this.sortField)), + // Use the findListOptions to retrieve the relevant processes every interval + switchMap((findListOptions: FindListOptions) => + this.processOverviewService.getProcessesByProcessStatus( + this.processStatus, findListOptions, this.useAutoRefreshingSearchBy ? this.autoRefreshInterval : null) + ), + // Redirect the user when he is logged out + redirectOn4xx(this.router, this.auth), + getAllCompletedRemoteData(), + // Map RemoteData> to RemoteData> + switchMap((processesRD: RemoteData>) => { + // Create observable emitting all processes one by one + return observableFrom(processesRD.payload.page).pipe( + // Map every Process to ProcessOverviewTableEntry + mergeMap((process: Process) => { + return this.getEPersonName(process.userId).pipe( + map((name) => { + return { + process: process, + user: name, + info: this.getInfoValueMethod(process), + }; + }), + ); + }), + // Collect processOverviewTableEntries into array + toArray(), + // Create RemoteData> + map((entries: ProcessOverviewTableEntry[]) => { + const entriesPL: PaginatedList = + Object.assign(new PaginatedList(), processesRD.payload, { page: entries }); + const entriesRD: RemoteData> = + Object.assign({}, processesRD, { payload: entriesPL }); + return entriesRD; + }), + ); + }), + + ).subscribe((next: RemoteData>) => { + this.processesRD$.next(next); + })); + + // Collapse this section when the number of processes is zero the first time processes are retrieved + this.subs.push(this.processesRD$.pipe( + filter((processListRd: RemoteData>) => hasValue(processListRd)), + take(1), + ).subscribe( + (processesRD: RemoteData>) => { + if (!(processesRD.payload.totalElements > 0)) { + this.isCollapsed = true; + } + } + )); + + } + + /** + * Get the name of an EPerson by ID + * @param id ID of the EPerson + */ + getEPersonName(id: string): Observable { + return this.ePersonDataService.findById(id).pipe( + getFirstSucceededRemoteDataPayload(), + map((eperson: EPerson) => this.dsoNameService.getName(eperson)), + ); + } + + /** + * Get the css class for a row depending on the state of the process + * @param process + */ + getRowClass(process: Process): string { + if (this.processBulkDeleteService.isToBeDeleted(process.processId)) { + return 'table-danger'; + } else if (this.newProcessId === process.processId) { + return 'table-info'; + } else { + return ''; + } + } + + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + this.processOverviewService.stopAutoRefreshing(this.processStatus); + } + + } diff --git a/src/app/process-page/process-breadcrumb.resolver.ts b/src/app/process-page/process-breadcrumb.resolver.ts index 23e3dca0a8..fd0c1ad735 100644 --- a/src/app/process-page/process-breadcrumb.resolver.ts +++ b/src/app/process-page/process-breadcrumb.resolver.ts @@ -6,8 +6,9 @@ import { Process } from './processes/process.model'; import { followLink } from '../shared/utils/follow-link-config.model'; import { ProcessDataService } from '../core/data/processes/process-data.service'; import { BreadcrumbConfig } from '../breadcrumbs/breadcrumb/breadcrumb-config.model'; -import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../core/shared/operators'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; import { ProcessBreadcrumbsService } from './process-breadcrumbs.service'; +import { RemoteData } from '../core/data/remote-data'; /** * This class represents a resolver that requests a specific process before the route is activated @@ -28,12 +29,11 @@ export class ProcessBreadcrumbResolver implements Resolve { + getFirstCompletedRemoteData(), + map((object: RemoteData) => { const fullPath = state.url; const url = fullPath.substr(0, fullPath.indexOf(id)) + id; - return { provider: this.breadcrumbService, key: object, url: url }; + return { provider: this.breadcrumbService, key: object.payload, url: url }; }) ); } diff --git a/src/app/process-page/process-breadcrumbs.service.ts b/src/app/process-page/process-breadcrumbs.service.ts index 26b0787a53..ac490138b8 100644 --- a/src/app/process-page/process-breadcrumbs.service.ts +++ b/src/app/process-page/process-breadcrumbs.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@angular/core'; import { BreadcrumbsProviderService } from '../core/breadcrumbs/breadcrumbsProviderService'; import { Breadcrumb } from '../breadcrumbs/breadcrumb/breadcrumb.model'; import { Process } from './processes/process.model'; +import { hasValue } from '../shared/empty.util'; /** * Service to calculate process breadcrumbs for a single part of the route @@ -16,6 +17,10 @@ export class ProcessBreadcrumbsService implements BreadcrumbsProviderService { - return observableOf([new Breadcrumb(key.processId + ' - ' + key.scriptName, url)]); + if (hasValue(key)) { + return observableOf([new Breadcrumb(key.processId + ' - ' + key.scriptName, url)]); + } else { + return observableOf([]); + } } } diff --git a/src/app/process-page/process-page-shared.module.ts b/src/app/process-page/process-page-shared.module.ts index e666283e03..2e7266fcf2 100644 --- a/src/app/process-page/process-page-shared.module.ts +++ b/src/app/process-page/process-page-shared.module.ts @@ -16,10 +16,14 @@ import { ProcessDetailFieldComponent } from './detail/process-detail-field/proce import { ProcessBreadcrumbsService } from './process-breadcrumbs.service'; import { ProcessBreadcrumbResolver } from './process-breadcrumb.resolver'; import { ProcessFormComponent } from './form/process-form.component'; +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; +import { ProcessOverviewTableComponent } from './overview/table/process-overview-table.component'; +import { DatePipe } from '@angular/common'; @NgModule({ imports: [ SharedModule, + NgbCollapseModule, ], declarations: [ NewProcessComponent, @@ -33,13 +37,15 @@ import { ProcessFormComponent } from './form/process-form.component'; BooleanValueInputComponent, DateValueInputComponent, ProcessOverviewComponent, + ProcessOverviewTableComponent, ProcessDetailComponent, ProcessDetailFieldComponent, ProcessFormComponent ], providers: [ ProcessBreadcrumbResolver, - ProcessBreadcrumbsService + ProcessBreadcrumbsService, + DatePipe, ] }) diff --git a/src/app/process-page/process-page.resolver.ts b/src/app/process-page/process-page.resolver.ts index ba872302b3..2e4843646b 100644 --- a/src/app/process-page/process-page.resolver.ts +++ b/src/app/process-page/process-page.resolver.ts @@ -7,6 +7,10 @@ import { followLink } from '../shared/utils/follow-link-config.model'; import { ProcessDataService } from '../core/data/processes/process-data.service'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; +export const PROCESS_PAGE_FOLLOW_LINKS = [ + followLink('files'), +]; + /** * This class represents a resolver that requests a specific process before the route is activated */ @@ -23,7 +27,7 @@ export class ProcessPageResolver implements Resolve> { * or an error if something went wrong */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - return this.processService.findById(route.params.id, false, true, followLink('script')).pipe( + return this.processService.findById(route.params.id, false, true, ...PROCESS_PAGE_FOLLOW_LINKS).pipe( getFirstCompletedRemoteData(), ); } diff --git a/src/app/process-page/processes/filetypes.model.ts b/src/app/process-page/processes/filetypes.model.ts new file mode 100644 index 0000000000..28e9df71cd --- /dev/null +++ b/src/app/process-page/processes/filetypes.model.ts @@ -0,0 +1,34 @@ +import { typedObject } from '../../core/cache/builders/build-decorators'; +import { excludeFromEquals } from '../../core/utilities/equals.decorators'; +import { autoserialize } from 'cerialize'; +import { ResourceType } from '../../core/shared/resource-type'; +import { FILETYPES } from './filetypes.resource-type'; + +/** + * Object representing the file types of the {@link Bitstream}s of a {@link Process} + */ +@typedObject +export class Filetypes { + + static type = FILETYPES; + + /** + * The id of this {@link Filetypes} + */ + @autoserialize + id: string; + + /** + * The values of this {@link Filetypes} + */ + @autoserialize + values: string[]; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + +} diff --git a/src/app/process-page/processes/filetypes.resource-type.ts b/src/app/process-page/processes/filetypes.resource-type.ts new file mode 100644 index 0000000000..29f9636208 --- /dev/null +++ b/src/app/process-page/processes/filetypes.resource-type.ts @@ -0,0 +1,8 @@ +/** + * The resource type for {@link Filetypes} + * + * Needs to be in a separate file to prevent circular dependencies in webpack. + */ +import { ResourceType } from '../../core/shared/resource-type'; + +export const FILETYPES = new ResourceType('filetypes'); diff --git a/src/app/process-page/processes/process-status.model.ts b/src/app/process-page/processes/process-status.model.ts index b43340bffb..1ff42789d8 100644 --- a/src/app/process-page/processes/process-status.model.ts +++ b/src/app/process-page/processes/process-status.model.ts @@ -2,8 +2,8 @@ * List of process statuses */ export enum ProcessStatus { - SCHEDULED, - RUNNING, - COMPLETED, - FAILED + SCHEDULED = 'SCHEDULED', + RUNNING = 'RUNNING', + COMPLETED = 'COMPLETED', + FAILED = 'FAILED' } diff --git a/src/app/process-page/processes/process.model.ts b/src/app/process-page/processes/process.model.ts index d5f6e77d32..8468b4e43d 100644 --- a/src/app/process-page/processes/process.model.ts +++ b/src/app/process-page/processes/process.model.ts @@ -3,7 +3,7 @@ import { PROCESS_OUTPUT_TYPE } from '../../core/shared/process-output.resource-t import { ProcessStatus } from './process-status.model'; import { ProcessParameter } from './process-parameter.model'; import { HALLink } from '../../core/shared/hal-link.model'; -import { autoserialize, deserialize } from 'cerialize'; +import { autoserialize, deserialize, autoserializeAs } from 'cerialize'; import { PROCESS } from './process.resource-type'; import { excludeFromEquals } from '../../core/utilities/equals.decorators'; import { ResourceType } from '../../core/shared/resource-type'; @@ -13,6 +13,10 @@ import { RemoteData } from '../../core/data/remote-data'; import { SCRIPT } from '../scripts/script.resource-type'; import { Script } from '../scripts/script.model'; import { CacheableObject } from '../../core/cache/cacheable-object.model'; +import { BITSTREAM } from '../../core/shared/bitstream.resource-type'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { Filetypes } from './filetypes.model'; +import { FILETYPES } from './filetypes.resource-type'; /** * Object representing a process @@ -31,7 +35,7 @@ export class Process implements CacheableObject { /** * The identifier for this process */ - @autoserialize + @autoserializeAs(String) processId: string; /** @@ -40,6 +44,12 @@ export class Process implements CacheableObject { @autoserialize userId: string; + /** + * The creation time for this process + */ + @autoserialize + creationTime: string; + /** * The start time for this process */ @@ -78,7 +88,8 @@ export class Process implements CacheableObject { self: HALLink, script: HALLink, output: HALLink, - files: HALLink + files: HALLink, + filetypes: HALLink, }; /** @@ -94,4 +105,19 @@ export class Process implements CacheableObject { */ @link(PROCESS_OUTPUT_TYPE) output?: Observable>; + + /** + * The files created by this Process + * Will be undefined unless the output {@link HALLink} has been resolved. + */ + @link(BITSTREAM, true) + files?: Observable>>; + + /** + * The filetypes present in this Process + * Will be undefined unless the output {@link HALLink} has been resolved. + */ + @link(FILETYPES) + filetypes?: Observable>; + } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts index f19b660295..b0c42ffefd 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts @@ -50,8 +50,9 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom /** * Retrieves the init form value from model + * @param preserveConfidence if the original model confidence value should be used after retrieving the vocabulary's entry */ - getInitValueFromModel(): Observable { + getInitValueFromModel(preserveConfidence = false): Observable { let initValue$: Observable; if (isNotEmpty(this.model.value) && (this.model.value instanceof FormFieldMetadataValueObject) && !this.model.value.hasAuthorityToGenerate()) { let initEntry$: Observable; @@ -63,7 +64,7 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom initValue$ = initEntry$.pipe(map((initEntry: VocabularyEntry) => { if (isNotEmpty(initEntry)) { // Integrate FormFieldMetadataValueObject with retrieved information - return new FormFieldMetadataValueObject( + let formField = new FormFieldMetadataValueObject( initEntry.value, null, initEntry.authority, @@ -72,6 +73,11 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom null, initEntry.otherInformation || null ); + // Preserve the original confidence + if (preserveConfidence) { + formField.confidence = (this.model.value as any).confidence; + } + return formField; } else { return this.model.value as any; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html index 3c19ecda13..8681f13433 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html @@ -21,8 +21,8 @@
- - +