diff --git a/config/config.example.yml b/config/config.example.yml index 0933619e01..feea06f7cb 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 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 185a1f938e..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,4 +1,4 @@ -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'; @@ -29,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. @@ -48,6 +48,7 @@ export class CollectionSourceControlsComponent implements OnDestroy { contentSource$: Observable; private subs: Subscription[] = []; + private autoRefreshIDs: string[] = []; testConfigRunning$ = new BehaviorSubject(false); importRunning$ = new BehaviorSubject(false); @@ -94,7 +95,10 @@ 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.autoRefreshUntilCompletion(rd.payload.processId)), + 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.FAILED].toString()) { @@ -135,7 +139,10 @@ export class CollectionSourceControlsComponent implements OnDestroy { } }), filter((rd) => rd.hasSucceeded && hasValue(rd.payload)), - switchMap((rd) => this.processDataService.autoRefreshUntilCompletion(rd.payload.processId)), + 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.FAILED].toString()) { @@ -170,7 +177,10 @@ export class CollectionSourceControlsComponent implements OnDestroy { } }), filter((rd) => rd.hasSucceeded && hasValue(rd.payload)), - switchMap((rd) => this.processDataService.autoRefreshUntilCompletion(rd.payload.processId)), + 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.FAILED].toString()) { @@ -191,5 +201,9 @@ export class CollectionSourceControlsComponent implements OnDestroy { sub.unsubscribe(); } }); + + this.autoRefreshIDs.forEach((id) => { + this.processDataService.stopAutoRefreshing(id); + }); } } 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 d66560b083..99cd317cdb 100644 --- a/src/app/core/data/processes/process-data.service.spec.ts +++ b/src/app/core/data/processes/process-data.service.spec.ts @@ -9,7 +9,7 @@ import { testFindAllDataImplementation } from '../base/find-all-data.spec'; import { ProcessDataService, TIMER_FACTORY } from './process-data.service'; import { testDeleteDataImplementation } from '../base/delete-data.spec'; -import { waitForAsync, TestBed } from '@angular/core/testing'; +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'; @@ -23,6 +23,11 @@ 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; @@ -36,9 +41,10 @@ describe('ProcessDataService', () => { const initService = () => new ProcessDataService(null, null, null, null, null, null, null, null); testFindAllDataImplementation(initService); testDeleteDataImplementation(initService); + testSearchDataImplementation(initService); }); - let requestService; + let requestService = getMockRequestService(); let processDataService; let remoteDataBuildService; @@ -123,4 +129,65 @@ describe('ProcessDataService', () => { 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 ac459068b1..080a4a4c09 100644 --- a/src/app/core/data/processes/process-data.service.ts +++ b/src/app/core/data/processes/process-data.service.ts @@ -5,7 +5,7 @@ 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 { Observable, Subscription } from 'rxjs'; import { switchMap, filter, distinctUntilChanged, find } from 'rxjs/operators'; import { PaginatedList } from '../paginated-list.model'; import { Bitstream } from '../../shared/bitstream.model'; @@ -22,6 +22,7 @@ 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 @@ -34,11 +35,13 @@ export const TIMER_FACTORY = new InjectionToken<(callback: (...args: any[]) => v @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, @@ -54,6 +57,7 @@ export class ProcessDataService extends IdentifiableDataService impleme 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); } /** @@ -109,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 @@ -135,14 +204,15 @@ export class ProcessDataService extends IdentifiableDataService impleme } /** - * Clear the timeout for the given process, if that timeout exists + * Clear the timeout for the given id, if that timeout exists * @protected */ - protected clearCurrentTimeout(processId: string): void { - const timeout = this.activelyBeingPolled.get(processId); + protected clearCurrentTimeout(id: string): void { + const timeout = this.activelyBeingPolled.get(id); if (hasValue(timeout)) { clearTimeout(timeout); } + this.activelyBeingPolled.delete(id); } /** @@ -185,15 +255,15 @@ export class ProcessDataService extends IdentifiableDataService impleme } }); + 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.clearCurrentTimeout(processId); - this.activelyBeingPolled.delete(processId); - sub.unsubscribe(); + this.stopAutoRefreshing(processId); }); return process$.pipe( 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 e8ff2b479d..38824b3fac 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -255,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(() => { @@ -312,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/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/process-page/detail/process-detail.component.ts b/src/app/process-page/detail/process-detail.component.ts index e64e457788..793721dbd8 100644 --- a/src/app/process-page/detail/process-detail.component.ts +++ b/src/app/process-page/detail/process-detail.component.ts @@ -1,5 +1,5 @@ import { HttpClient } from '@angular/common/http'; -import { Component, Inject, NgZone, 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, Observable } from 'rxjs'; import { finalize, map, switchMap, take, tap, find, startWith, filter } from 'rxjs/operators'; @@ -36,7 +36,7 @@ import { PROCESS_PAGE_FOLLOW_LINKS } from '../process-page.resolver'; /** * A component displaying detailed information about a DSpace Process */ -export class ProcessDetailComponent implements OnInit { +export class ProcessDetailComponent implements OnInit, OnDestroy { /** * The AlertType enumeration @@ -82,6 +82,8 @@ export class ProcessDetailComponent implements OnInit { isDeleting: boolean; + protected autoRefreshingID: string; + /** * Reference to NgbModal */ @@ -110,7 +112,8 @@ export class ProcessDetailComponent implements OnInit { this.processRD$ = this.route.data.pipe( switchMap((data) => { if (isPlatformBrowser(this.platformId)) { - return this.processService.autoRefreshUntilCompletion(this.route.snapshot.params.id, 5000, ...PROCESS_PAGE_FOLLOW_LINKS); + this.autoRefreshingID = this.route.snapshot.params.id; + return this.processService.autoRefreshUntilCompletion(this.autoRefreshingID, 5000, ...PROCESS_PAGE_FOLLOW_LINKS); } else { return [data.process as RemoteData]; } @@ -131,6 +134,15 @@ export class ProcessDetailComponent implements OnInit { ); } + /** + * Make sure the autoRefreshUntilCompletion is cleaned up properly + */ + ngOnDestroy() { + if (hasValue(this.autoRefreshingID)) { + this.processService.stopAutoRefreshing(this.autoRefreshingID); + } + } + /** * Get the name of a bitstream * @param bitstream 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-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/processes/process.model.ts b/src/app/process-page/processes/process.model.ts index 609182d6ca..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'; @@ -35,7 +35,7 @@ export class Process implements CacheableObject { /** * The identifier for this process */ - @autoserialize + @autoserializeAs(String) processId: string; /** @@ -44,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 */ 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 @@
- - +