Merge branch 'DSpace:main' into main

This commit is contained in:
jeffmorin
2024-02-23 16:20:48 -05:00
committed by GitHub
37 changed files with 1886 additions and 326 deletions

View File

@@ -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

View File

@@ -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<ContentSource>;
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);
});
}
}

View File

@@ -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<Process> = 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();
}));
});
});

View File

@@ -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<Process> implements FindAllData<Process>, DeleteData<Process> {
export class ProcessDataService extends IdentifiableDataService<Process> implements FindAllData<Process>, DeleteData<Process>, SearchData<Process> {
private findAllData: FindAllData<Process>;
private deleteData: DeleteData<Process>;
private searchData: SearchData<Process>;
protected activelyBeingPolled: Map<string, NodeJS.Timeout> = new Map();
protected subs: Map<string, Subscription> = new Map();
constructor(
protected requestService: RequestService,
@@ -54,6 +57,7 @@ export class ProcessDataService extends IdentifiableDataService<Process> 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<Process> 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<RemoteData<PaginatedList<Process>>>}
* Return an observable that emits a paginated list of processes
*/
searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<Process>[]): Observable<RemoteData<PaginatedList<Process>>> {
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<RemoteData<PaginatedList<Process>>>}
* Return an observable that emits a paginated list of processes every interval
*/
autoRefreshingSearchBy(id: string, searchMethod: string, options?: FindListOptions, pollingIntervalInMs: number = 5000, ...linksToFollow: FollowLinkConfig<Process>[]): Observable<RemoteData<PaginatedList<Process>>> {
const result$ = this.searchBy(searchMethod, options, true, true, ...linksToFollow).pipe(
getAllCompletedRemoteData()
);
const sub = result$.pipe(
filter(() =>
!this.activelyBeingPolled.has(id)
)
).subscribe((processListRd: RemoteData<PaginatedList<Process>>) => {
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<Process> 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<Process> 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<Process>) => ProcessDataService.hasCompletedOrFailed(processRD.payload))
).subscribe(() => {
this.clearCurrentTimeout(processId);
this.activelyBeingPolled.delete(processId);
sub.unsubscribe();
this.stopAutoRefreshing(processId);
});
return process$.pipe(

View File

@@ -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);
});
});
});

View File

@@ -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<Vocabulary> implements FindAllData<Vocabulary> {
protected searchByMetadataAndCollectionPath = 'byMetadataAndCollection';
private findAllData: FindAllData<Vocabulary>;
private searchData: SearchDataImpl<Vocabulary>;
constructor(
protected requestService: RequestService,
@@ -38,6 +43,7 @@ export class VocabularyDataService extends IdentifiableDataService<Vocabulary> 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<Vocabulary> i
public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<Vocabulary>[]): Observable<RemoteData<PaginatedList<Vocabulary>>> {
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<Vocabulary>[]): Observable<RemoteData<Vocabulary>> {
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);
}
}

View File

@@ -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<Vocabulary> 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', () => {

View File

@@ -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<RemoteData<Vocabulary>>}
* Return an observable that emits vocabulary object
*/
getVocabularyByMetadataAndCollection(metadataField: string, collectionUUID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Vocabulary>[]): Observable<RemoteData<Vocabulary>> {
return this.vocabularyDataService.getVocabularyByMetadataAndCollection(metadataField, collectionUUID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Return the {@link VocabularyEntry} list for a given {@link Vocabulary}
*

View File

@@ -3,6 +3,7 @@
<ds-dso-edit-metadata-value *ngFor="let mdValue of form.fields[mdField]; let idx = index" role="presentation"
[dso]="dso"
[mdValue]="mdValue"
[mdField]="mdField"
[dsoType]="dsoType"
[saving$]="saving$"
[isOnlyValue]="form.fields[mdField].length === 1"

View File

@@ -75,7 +75,8 @@ export class DsoEditMetadataValue {
confirmChanges(finishEditing = false) {
this.reordered = this.originalValue.place !== this.newValue.place;
if (hasNoValue(this.change) || this.change === DsoEditMetadataChangeType.UPDATE) {
if ((this.originalValue.value !== this.newValue.value || this.originalValue.language !== this.newValue.language)) {
if (this.originalValue.value !== this.newValue.value || this.originalValue.language !== this.newValue.language
|| this.originalValue.authority !== this.newValue.authority || this.originalValue.confidence !== this.newValue.confidence) {
this.change = DsoEditMetadataChangeType.UPDATE;
} else {
this.change = undefined;
@@ -404,10 +405,13 @@ export class DsoEditMetadataForm {
if (hasValue(value.change)) {
if (value.change === DsoEditMetadataChangeType.UPDATE) {
// Only changes to value or language are considered "replace" operations. Changes to place are considered "move", which is processed below.
if (value.originalValue.value !== value.newValue.value || value.originalValue.language !== value.newValue.language) {
if (value.originalValue.value !== value.newValue.value || value.originalValue.language !== value.newValue.language
|| value.originalValue.authority !== value.newValue.authority || value.originalValue.confidence !== value.newValue.confidence) {
replaceOperations.push(new MetadataPatchReplaceOperation(field, value.originalValue.place, {
value: value.newValue.value,
language: value.newValue.language,
authority: value.newValue.authority,
confidence: value.newValue.confidence
}));
}
} else if (value.change === DsoEditMetadataChangeType.REMOVE) {
@@ -416,6 +420,8 @@ export class DsoEditMetadataForm {
addOperations.push(new MetadataPatchAddOperation(field, {
value: value.newValue.value,
language: value.newValue.language,
authority: value.newValue.authority,
confidence: value.newValue.confidence
}));
} else {
console.warn('Illegal metadata change state detected for', value);

View File

@@ -1,11 +1,58 @@
<div class="d-flex flex-row ds-value-row" *ngVar="mdValue.newValue.isVirtual as isVirtual" role="row"
cdkDrag (cdkDragStarted)="dragging.emit(true)" (cdkDragEnded)="dragging.emit(false)"
[ngClass]="{ 'ds-warning': mdValue.reordered || mdValue.change === DsoEditMetadataChangeTypeEnum.UPDATE, 'ds-danger': mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE, 'ds-success': mdValue.change === DsoEditMetadataChangeTypeEnum.ADD, 'h-100': isOnlyValue }">
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex align-items-center" *ngVar="(mdRepresentation$ | async) as mdRepresentation" role="cell">
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex flex-column" *ngVar="(mdRepresentation$ | async) as mdRepresentation" role="cell">
<div class="dont-break-out preserve-line-breaks" *ngIf="!mdValue.editing && !mdRepresentation">{{ mdValue.newValue.value }}</div>
<textarea class="form-control" rows="5" *ngIf="mdValue.editing && !mdRepresentation" [(ngModel)]="mdValue.newValue.value"
<textarea class="form-control" rows="5" *ngIf="mdValue.editing && !mdRepresentation && !(isAuthorityControlled() | async)" [(ngModel)]="mdValue.newValue.value"
[attr.aria-label]="(dsoType + '.edit.metadata.edit.value') | translate"
[dsDebounce]="300" (onDebounce)="confirm.emit(false)"></textarea>
<ds-dynamic-scrollable-dropdown *ngIf="mdValue.editing && (isScrollableVocabulary() | async)"
[bindId]="mdField"
[group]="group"
[model]="getModel() | async"
(change)="onChangeAuthorityField($event)">
</ds-dynamic-scrollable-dropdown>
<ds-dynamic-onebox *ngIf="mdValue.editing && ((isHierarchicalVocabulary() | async) || (isSuggesterVocabulary() | async))"
[group]="group"
[model]="getModel() | async"
(change)="onChangeAuthorityField($event)">
</ds-dynamic-onebox>
<div *ngIf="!isVirtual && !mdValue.editing && mdValue.newValue.authority && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_UNSET && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_NOVALUE">
<span class="badge badge-light border" >
<i dsAuthorityConfidenceState
class="fas fa-fw p-0"
aria-hidden="true"
[authorityValue]="mdValue.newValue"
[iconMode]="true"
></i>
{{ dsoType + '.edit.metadata.authority.label' | translate }} {{ mdValue.newValue.authority }}
</span>
</div>
<div class="mt-2" *ngIf=" mdValue.editing && (isAuthorityControlled() | async) && (isSuggesterVocabulary() | async)">
<div class="btn-group w-75">
<i dsAuthorityConfidenceState
class="fas fa-fw p-0 mr-1 mt-auto mb-auto"
aria-hidden="true"
[authorityValue]="mdValue.newValue.confidence"
[iconMode]="true"
></i>
<input class="form-control form-outline" [(ngModel)]="mdValue.newValue.authority" [disabled]="!editingAuthority"
[attr.aria-label]="(dsoType + '.edit.metadata.edit.authority.key') | translate"
(change)="onChangeAuthorityKey()" />
<button class="btn btn-outline-secondary btn-sm ng-star-inserted" id="metadata-confirm-btn" *ngIf="!editingAuthority"
[title]="dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate }}"
(click)="onChangeEditingAuthorityStatus(true)">
<i class="fas fa-lock fa-fw"></i>
</button>
<button class="btn btn-outline-success btn-sm ng-star-inserted" id="metadata-confirm-btn" *ngIf="editingAuthority"
[title]="dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate }}"
(click)="onChangeEditingAuthorityStatus(false)">
<i class="fas fa-lock-open fa-fw"></i>
</button>
</div>
</div>
<div class="d-flex" *ngIf="mdRepresentation">
<a class="mr-2" target="_blank" [routerLink]="mdRepresentationItemRoute$ | async">{{ mdRepresentationName$ | async }}</a>
<ds-themed-type-badge [object]="mdRepresentation"></ds-themed-type-badge>
@@ -45,14 +92,14 @@
[disabled]="isVirtual || (!mdValue.change && mdValue.reordered) || (!mdValue.change && !mdValue.editing) || (saving$ | async)" (click)="undo.emit()">
<i class="fas fa-undo-alt fa-fw"></i>
</button>
<button class="btn btn-outline-secondary ds-drag-handle btn-sm" data-test="metadata-drag-btn" *ngVar="(isOnlyValue || (saving$ | async)) as disabled"
cdkDragHandle [cdkDragHandleDisabled]="disabled" [ngClass]="{'disabled': disabled}" [disabled]="disabled"
[title]="dsoType + '.edit.metadata.edit.buttons.drag' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.drag' | translate }}">
<i class="fas fa-grip-vertical fa-fw"></i>
</button>
</div>
</div>
<button class="btn btn-outline-secondary ds-drag-handle btn-sm" data-test="metadata-drag-btn" *ngVar="(isOnlyValue || (saving$ | async)) as disabled"
cdkDragHandle [cdkDragHandleDisabled]="disabled" [ngClass]="{'disabled': disabled}" [disabled]="disabled"
[title]="dsoType + '.edit.metadata.edit.buttons.drag' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.drag' | translate }}">
<i class="fas fa-grip-vertical fa-fw"></i>
</button>
</div>
</div>
</div>

View File

@@ -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;

View File

@@ -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<string | null>;
/**
* 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<DynamicOneboxModel | DynamicScrollableDropdownModel>;
/**
* Observable with information about the authority vocabulary used
*/
private vocabulary$: Observable<Vocabulary>;
/**
* Observables with information about the authority vocabulary type used
*/
private isAuthorityControlled$: Observable<boolean>;
private isHierarchicalVocabulary$: Observable<boolean>;
private isScrollableVocabulary$: Observable<boolean>;
private isSuggesterVocabulary$: Observable<boolean>;
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<Collection> = 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<boolean> {
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<boolean> {
return this.isAuthorityControlled$;
}
/**
* Checks if configured vocabulary is Hierarchical or not
*/
isHierarchicalVocabulary(): Observable<boolean> {
return this.isHierarchicalVocabulary$;
}
/**
* Checks if configured vocabulary is Scrollable or not
*/
isScrollableVocabulary(): Observable<boolean> {
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<boolean> {
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<DynamicOneboxModel | DynamicScrollableDropdownModel> {
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);
}
}
}

View File

@@ -40,6 +40,7 @@
[dsoType]="dsoType"
[saving$]="savingOrLoadingFieldValidation$"
[isOnlyValue]="true"
[mdField]="newMdField"
(confirm)="confirmNewValue($event)"
(remove)="form.newValue = undefined"
(undo)="form.newValue = undefined">

View File

@@ -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,

View File

@@ -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<Process>];
}
@@ -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

View File

@@ -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);
}
}

View File

@@ -2,60 +2,46 @@
<div class="d-flex">
<h1 class="flex-grow-1">{{'process.overview.title' | translate}}</h1>
</div>
<div class="d-flex justify-content-end">
<ng-container *ngTemplateOutlet="buttons"></ng-container>
<div class="sections">
<ds-process-overview-table
[processStatus]="ProcessStatus.RUNNING"
[useAutoRefreshingSearchBy]="true"
[getInfoValueMethod]="processOverviewService.timeStarted"/>
<ds-process-overview-table
[processStatus]="ProcessStatus.SCHEDULED"
[useAutoRefreshingSearchBy]="true"
[getInfoValueMethod]="processOverviewService.timeCreated"/>
<ds-process-overview-table
[processStatus]="ProcessStatus.COMPLETED"
[sortField]="ProcessSortField.endTime"
[useAutoRefreshingSearchBy]="true"
[getInfoValueMethod]="processOverviewService.timeCompleted"/>
<ds-process-overview-table
[processStatus]="ProcessStatus.FAILED"
[sortField]="ProcessSortField.endTime"
[useAutoRefreshingSearchBy]="true"
[getInfoValueMethod]="processOverviewService.timeCompleted"/>
</div>
<ng-container *ngTemplateOutlet="buttons"></ng-container>
</div>
<ng-template #buttons>
<div class="d-flex justify-content-end mb-2">
<button *ngIf="processBulkDeleteService.hasSelected()" class="btn btn-primary mr-2"
(click)="processBulkDeleteService.clearAllProcesses()"><i
class="fas fa-undo pr-2"></i>{{'process.overview.delete.clear' | translate }}
class="fas fa-undo pr-2"></i>{{'process.overview.delete.clear' | translate }}
</button>
<button *ngIf="processBulkDeleteService.hasSelected()" class="btn btn-danger mr-2"
(click)="openDeleteModal(deleteModal)"><i
class="fas fa-trash pr-2"></i>{{'process.overview.delete' | translate: {count: processBulkDeleteService.getAmountOfSelectedProcesses()} }}
class="fas fa-trash pr-2"></i>{{'process.overview.delete' | translate: {count: processBulkDeleteService.getAmountOfSelectedProcesses()} }}
</button>
<button class="btn btn-success" routerLink="/processes/new"><i
class="fas fa-plus pr-2"></i>{{'process.overview.new' | translate}}</button>
class="fas fa-plus pr-2"></i>{{'process.overview.new' | translate}}</button>
</div>
<ds-pagination *ngIf="(processesRD$ | async)?.payload?.totalElements > 0"
[paginationOptions]="pageConfig"
[pageInfoState]="(processesRD$ | async)?.payload"
[collectionSize]="(processesRD$ | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">{{'process.overview.table.id' | translate}}</th>
<th scope="col">{{'process.overview.table.name' | translate}}</th>
<th scope="col">{{'process.overview.table.user' | translate}}</th>
<th scope="col">{{'process.overview.table.start' | translate}}</th>
<th scope="col">{{'process.overview.table.finish' | translate}}</th>
<th scope="col">{{'process.overview.table.status' | translate}}</th>
<th scope="col">{{'process.overview.table.actions' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let process of (processesRD$ | async)?.payload?.page"
[class.table-danger]="processBulkDeleteService.isToBeDeleted(process.processId)">
<td><a [routerLink]="['/processes/', process.processId]">{{process.processId}}</a></td>
<td><a [routerLink]="['/processes/', process.processId]">{{process.scriptName}}</a></td>
<td *ngVar="(getEpersonName(process.userId) | async) as ePersonName">{{ePersonName}}</td>
<td>{{process.startTime | date:dateFormat:'UTC'}}</td>
<td>{{process.endTime | date:dateFormat:'UTC'}}</td>
<td>{{process.processStatus}}</td>
<td>
<button [attr.aria-label]="'process.overview.delete-process' | translate"
(click)="processBulkDeleteService.toggleDelete(process.processId)"
class="btn btn-outline-danger">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
</div>
</ng-template>
<ng-template #deleteModal>
@@ -90,4 +76,3 @@
</ng-template>

View File

@@ -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<ProcessOverviewComponent>;
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<any>);
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();
});
});
});

View File

@@ -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<RemoteData<PaginatedList<Process>>>;
// 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<string> {
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<any>) {
this.modalRef = this.modalService.open(content);
}
@@ -126,7 +69,6 @@ export class ProcessOverviewComponent implements OnInit, OnDestroy {
.subscribe((isProcessing) => {
if (!isProcessing) {
this.closeModal();
this.setProcesses();
}
});
}

View File

@@ -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<RemoteData<PaginatedList<Process>>> {
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,
}
);
}
}

View File

@@ -0,0 +1,66 @@
<div class="mb-4">
<div class="d-flex" (click)="collapse.toggle()" [attr.aria-expanded]="!collapse.collapsed" role="button">
<h2 class="flex-grow-1">
{{'process.overview.table.' + processStatus.toLowerCase() + '.title' | translate}}
<span class="badge badge-pill badge-primary badge-nb-processes"
*ngIf="(processesRD$ | async) as processesRD">
{{processesRD?.payload?.totalElements}}
</span>
<span class="ml-2 toggle-icon">
<i class="fas" [ngClass]="collapse.collapsed ? 'fa-angle-right' : 'fa-angle-down'"></i>
</span>
</h2>
</div>
<div ngbCollapse #collapse="ngbCollapse" [ngbCollapse]="isCollapsed">
<ng-container *ngVar="(processesRD$ | async) as processesRD">
<ds-themed-loading *ngIf="!processesRD || processesRD.isLoading"/>
<ds-pagination *ngIf="processesRD?.payload?.totalElements > 0"
[paginationOptions]="(paginationOptions$ | async)"
[collectionSize]="processesRD?.payload?.totalElements"
[retainScrollPosition]="true"
[hideGear]="true">
<div class="table-responsive mt-1">
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col" class="id-header">{{'process.overview.table.id' | translate}}</th>
<th scope="col" class="name-header">{{'process.overview.table.name' | translate}}</th>
<th scope="col" class="user-header">{{'process.overview.table.user' | translate}}</th>
<th scope="col" class="info-header">{{'process.overview.table.' + processStatus.toLowerCase() + '.info' | translate}}</th>
<th scope="col" class="actions-header">{{'process.overview.table.actions' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let tableEntry of processesRD?.payload?.page"
[class]="getRowClass(tableEntry.process)">
<td><a [routerLink]="['/processes/', tableEntry.process.processId]">{{tableEntry.process.processId}}</a></td>
<td><a [routerLink]="['/processes/', tableEntry.process.processId]">{{tableEntry.process.scriptName}}</a></td>
<td>{{tableEntry.user}}</td>
<td>{{tableEntry.info}}</td>
<td>
<button [attr.aria-label]="'process.overview.delete-process' | translate"
aria-hidden="true"
(click)="processBulkDeleteService.toggleDelete(tableEntry.process.processId)"
class="btn btn-outline-danger">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="processesRD?.payload?.totalElements == 0">
<p>{{'process.overview.table.empty' | translate}}</p>
</div>
</ng-container>
</div>
</div>

View File

@@ -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);
}

View File

@@ -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<ProcessOverviewTableComponent>;
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);
});
});
});

View File

@@ -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<RemoteData<PaginatedList<ProcessOverviewTableEntry>>>;
/**
* The pagination ID for this overview section
*/
paginationId: string;
/**
* The current pagination options for the overview section
*/
paginationOptions$: Observable<PaginationComponentOptions>;
/**
* 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<PaginatedList<ProcessOverviewTableEntry>> and not simply as
// ProcessOverviewTableEntry[] is as follows:
// When storing the PaginatedList<Process> 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<PaginatedList<Process>> to RemoteData<PaginatedList<ProcessOverviewTableEntry>>
switchMap((processesRD: RemoteData<PaginatedList<Process>>) => {
// 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<PaginatedList<ProcessOverviewTableEntry>>
map((entries: ProcessOverviewTableEntry[]) => {
const entriesPL: PaginatedList<ProcessOverviewTableEntry> =
Object.assign(new PaginatedList(), processesRD.payload, { page: entries });
const entriesRD: RemoteData<PaginatedList<ProcessOverviewTableEntry>> =
Object.assign({}, processesRD, { payload: entriesPL });
return entriesRD;
}),
);
}),
).subscribe((next: RemoteData<PaginatedList<ProcessOverviewTableEntry>>) => {
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<PaginatedList<ProcessOverviewTableEntry>>) => hasValue(processListRd)),
take(1),
).subscribe(
(processesRD: RemoteData<PaginatedList<ProcessOverviewTableEntry>>) => {
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<string> {
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);
}
}

View File

@@ -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,
]
})

View File

@@ -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
*/

View File

@@ -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<FormFieldMetadataValueObject> {
getInitValueFromModel(preserveConfidence = false): Observable<FormFieldMetadataValueObject> {
let initValue$: Observable<FormFieldMetadataValueObject>;
if (isNotEmpty(this.model.value) && (this.model.value instanceof FormFieldMetadataValueObject) && !this.model.value.hasAuthorityToGenerate()) {
let initEntry$: Observable<VocabularyEntry>;
@@ -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;
}

View File

@@ -21,8 +21,8 @@
</ng-template>
<div *ngIf="!(isHierarchicalVocabulary() | async)" class="position-relative right-addon">
<i *ngIf="searching" class="fas fa-circle-notch fa-spin fa-2x fa-fw text-primary position-absolute mt-1 p-0" aria-hidden="true"></i>
<i *ngIf="!searching"
<i *ngIf="searching || loadingInitialValue" class="fas fa-circle-notch fa-spin fa-2x fa-fw text-primary position-absolute mt-1 p-0" aria-hidden="true"></i>
<i *ngIf="!searching && !loadingInitialValue"
dsAuthorityConfidenceState
class="far fa-circle fa-2x fa-fw position-absolute mt-1 p-0"
aria-hidden="true"
@@ -32,6 +32,7 @@
class="form-control"
[attr.aria-labelledby]="'label_' + model.id"
[attr.autoComplete]="model.autoComplete"
[attr.aria-label]="model.label | translate"
[class.is-invalid]="showErrorMessages"
[id]="model.id"
[inputFormatter]="formatter"
@@ -58,6 +59,7 @@
<input class="form-control"
[attr.aria-labelledby]="'label_' + model.id"
[attr.autoComplete]="model.autoComplete"
[attr.aria-label]="model.label | translate"
[class.is-invalid]="showErrorMessages"
[class.tree-input]="!model.readOnly"
[id]="id"

View File

@@ -55,6 +55,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
pageInfo: PageInfo = new PageInfo();
searching = false;
loadingInitialValue = false;
searchFailed = false;
hideSearchingWhenUnsubscribed$ = new Observable(() => () => this.changeSearchingStatus(false));
click$ = new Subject<string>();
@@ -151,6 +152,15 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
this.cdr.detectChanges();
}
/**
* Changes the loadingInitialValue status
* @param status
*/
changeLoadingInitialValueStatus(status: boolean) {
this.loadingInitialValue = status;
this.cdr.detectChanges();
}
/**
* Checks if configured vocabulary is Hierarchical or not
*/
@@ -185,8 +195,13 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
// prevent on blur propagation if typeahed suggestions are showed
event.preventDefault();
event.stopImmediatePropagation();
// set focus on input again, this is to avoid to lose changes when no suggestion is selected
(event.target as HTMLInputElement).focus();
// update the value with the searched text if the user hasn't selected any suggestion
if (!this.model.vocabularyOptions.closed && isNotEmpty(this.inputValue)) {
if (isNotNull(this.inputValue) && this.model.value !== this.inputValue) {
this.dispatchUpdate(this.inputValue);
}
this.inputValue = null;
}
}
}
@@ -257,8 +272,10 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
setCurrentValue(value: any, init = false): void {
let result: string;
if (init) {
this.getInitValueFromModel()
this.changeLoadingInitialValueStatus(true);
this.getInitValueFromModel(true)
.subscribe((formValue: FormFieldMetadataValueObject) => {
this.changeLoadingInitialValueStatus(false);
this.currentValue = formValue;
this.cdr.detectChanges();
});

View File

@@ -28,6 +28,8 @@ import { isNotEmpty, isNull } from '../../empty.util';
import { ConfidenceIconConfig } from '../../../../config/submission-config.interface';
import { environment } from '../../../../environments/environment';
import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/models/vocabulary-entry-detail.model';
import { MetadataValue } from '../../../core/shared/metadata.models';
import { TranslateService } from '@ngx-translate/core';
/**
* Directive to add to the element a bootstrap utility class based on metadata confidence value
@@ -40,13 +42,19 @@ export class AuthorityConfidenceStateDirective implements OnChanges, AfterViewIn
/**
* The metadata value
*/
@Input() authorityValue: VocabularyEntry | FormFieldMetadataValueObject | string;
@Input() authorityValue: VocabularyEntry | FormFieldMetadataValueObject | MetadataValue | string;
/**
* A boolean representing if to show html icon if authority value is empty
*/
@Input() visibleWhenAuthorityEmpty = true;
/**
* A boolean to configure the display of icons instead of default style configuration
* When true, the class configured in {@link ConfidenceIconConfig.icon} will be used, by default {@link ConfidenceIconConfig.style} is used
*/
@Input() iconMode = false;
/**
* The css class applied before directive changes
*/
@@ -79,7 +87,8 @@ export class AuthorityConfidenceStateDirective implements OnChanges, AfterViewIn
*/
constructor(
private elem: ElementRef,
private renderer: Renderer2
private renderer: Renderer2,
private translate: TranslateService
) {
}
@@ -93,12 +102,19 @@ export class AuthorityConfidenceStateDirective implements OnChanges, AfterViewIn
this.previousClass = this.getClassByConfidence(this.getConfidenceByValue(changes.authorityValue.previousValue));
}
this.newClass = this.getClassByConfidence(this.getConfidenceByValue(changes.authorityValue.currentValue));
let confidenceName = this.getNameByConfidence(this.getConfidenceByValue(changes.authorityValue.currentValue));
if (isNull(this.previousClass)) {
this.renderer.addClass(this.elem.nativeElement, this.newClass);
if (this.iconMode) {
this.renderer.setAttribute(this.elem.nativeElement, 'title', this.translate.instant(`confidence.indicator.help-text.${confidenceName}`));
}
} else if (this.previousClass !== this.newClass) {
this.renderer.removeClass(this.elem.nativeElement, this.previousClass);
this.renderer.addClass(this.elem.nativeElement, this.newClass);
if (this.iconMode) {
this.renderer.setAttribute(this.elem.nativeElement, 'title', this.translate.instant(`confidence.indicator.help-text.${confidenceName}`));
}
}
}
@@ -131,6 +147,14 @@ export class AuthorityConfidenceStateDirective implements OnChanges, AfterViewIn
confidence = value.confidence;
}
if (isNotEmpty(value) && value instanceof MetadataValue) {
confidence = value.confidence;
}
if (isNotEmpty(value) && Object.values(ConfidenceType).includes(value)) {
confidence = value;
}
return confidence;
}
@@ -149,9 +173,29 @@ export class AuthorityConfidenceStateDirective implements OnChanges, AfterViewIn
const confidenceIndex: number = findIndex(confidenceIcons, {value: confidence});
const defaultconfidenceIndex: number = findIndex(confidenceIcons, {value: 'default' as any});
const defaultClass: string = (defaultconfidenceIndex !== -1) ? confidenceIcons[defaultconfidenceIndex].style : '';
return (confidenceIndex !== -1) ? confidenceIcons[confidenceIndex].style : defaultClass;
if (this.iconMode) {
const defaultClass: string = (defaultconfidenceIndex !== -1) ? confidenceIcons[defaultconfidenceIndex].icon : '';
return (confidenceIndex !== -1) ? confidenceIcons[confidenceIndex].icon : defaultClass;
} else {
const defaultClass: string = (defaultconfidenceIndex !== -1) ? confidenceIcons[defaultconfidenceIndex].style : '';
return (confidenceIndex !== -1) ? confidenceIcons[confidenceIndex].style : defaultClass;
}
}
/**
* Return the confidence value name
*
* @param confidence
* @returns
*/
private getNameByConfidence(confidence: any): string {
let confidenceText = ConfidenceType[confidence];
if (isNotEmpty(confidenceText)) {
return confidenceText.replace('CF_', '').toLowerCase();
} else {
return 'unknown';
}
}
}

View File

@@ -42,4 +42,8 @@ export class VocabularyServiceStub {
findVocabularyById(id: string): Observable<RemoteData<Vocabulary>> {
return;
}
getVocabularyByMetadataAndCollection(metadataField: string, collectionUUID: string): Observable<RemoteData<Vocabulary>> {
return createSuccessfulRemoteDataObject$(null);
}
}

View File

@@ -2355,6 +2355,8 @@
"item.edit.metadata.edit.value": "Edit value",
"item.edit.metadata.edit.authority.key": "Edit authority key",
"item.edit.metadata.edit.buttons.confirm": "Confirm",
"item.edit.metadata.edit.buttons.drag": "Drag to reorder",
@@ -2409,6 +2411,12 @@
"item.edit.metadata.save-button": "Save",
"item.edit.metadata.authority.label": "Authority: ",
"item.edit.metadata.edit.buttons.open-authority-edition": "Unlock the authority key value for manual editing",
"item.edit.metadata.edit.buttons.close-authority-edition": "Lock the authority key value for manual editing",
"item.edit.modify.overview.field": "Field",
"item.edit.modify.overview.language": "Language",
@@ -2643,6 +2651,24 @@
"workflow-item.search.result.list.element.supervised.remove-tooltip": "Remove supervision group",
"confidence.indicator.help-text.accepted": "This authority value has been confirmed as accurate by an interactive user",
"confidence.indicator.help-text.uncertain": "Value is singular and valid but has not been seen and accepted by a human so it is still uncertain",
"confidence.indicator.help-text.ambiguous": "There are multiple matching authority values of equal validity",
"confidence.indicator.help-text.notfound": "There are no matching answers in the authority",
"confidence.indicator.help-text.failed": "The authority encountered an internal failure",
"confidence.indicator.help-text.rejected": "The authority recommends this submission be rejected",
"confidence.indicator.help-text.novalue": "No reasonable confidence value was returned from the authority",
"confidence.indicator.help-text.unset": "Confidence was never recorded for this value",
"confidence.indicator.help-text.unknown": "Unknown confidence value",
"item.page.abstract": "Abstract",
"item.page.author": "Authors",
@@ -3691,12 +3717,30 @@
"process.detail.refreshing": "Auto-refreshing…",
"process.overview.table.completed.info": "Finish time (UTC)",
"process.overview.table.completed.title": "Succeeded processes",
"process.overview.table.empty": "No matching processes found.",
"process.overview.table.failed.info": "Finish time (UTC)",
"process.overview.table.failed.title": "Failed processes",
"process.overview.table.finish": "Finish time (UTC)",
"process.overview.table.id": "Process ID",
"process.overview.table.name": "Name",
"process.overview.table.running.info": "Start time (UTC)",
"process.overview.table.running.title": "Running processes",
"process.overview.table.scheduled.info": "Creation time (UTC)",
"process.overview.table.scheduled.title": "Scheduled processes",
"process.overview.table.start": "Start time (UTC)",
"process.overview.table.status": "Status",

View File

@@ -164,7 +164,7 @@ export class DefaultAppConfig implements AppConfig {
* {
* // 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: 'fa-user'
* }
*/
@@ -184,27 +184,59 @@ export class DefaultAppConfig implements AppConfig {
* NOTE: example of configuration
* {
* // NOTE: confidence value
* value: 'dc.author',
* // NOTE: fontawesome (v4.x) icon classes and bootstrap utility classes can be used
* style: 'fa-user'
* value: 100,
* // 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'
style: 'text-success',
icon: 'fa-circle-check'
},
{
value: 500,
style: 'text-info'
style: 'text-info',
icon: 'fa-gear'
},
{
value: 400,
style: 'text-warning'
style: 'text-warning',
icon: 'fa-circle-question'
},
{
value: 300,
style: 'text-muted',
icon: 'fa-circle-question'
},
{
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'
style: 'text-muted',
icon: 'fa-circle-xmark'
}
]

View File

@@ -24,6 +24,7 @@ export interface MetadataIconConfig extends Config {
export interface ConfidenceIconConfig extends Config {
value: any;
style: string;
icon: string;
}
export interface SubmissionConfig extends Config {

View File

@@ -147,19 +147,23 @@ export const environment: BuildConfig = {
confidence: [
{
value: 600,
style: 'text-success'
style: 'text-success',
icon: 'fa-circle-check'
},
{
value: 500,
style: 'text-info'
style: 'text-info',
icon: 'fa-gear'
},
{
value: 400,
style: 'text-warning'
style: 'text-warning',
icon: 'fa-circle-question'
},
{
value: 'default',
style: 'text-muted'
style: 'text-muted',
icon: 'fa-circle-xmark'
},
]
}

View File

@@ -108,4 +108,10 @@
--ds-item-page-img-field-default-inline-height: 24px;
--ds-process-overview-table-nb-processes-badge-size: 0.5em;
--ds-process-overview-table-id-column-width: 120px;
--ds-process-overview-table-name-column-width: auto;
--ds-process-overview-table-user-column-width: 200px;
--ds-process-overview-table-info-column-width: 250px;
--ds-process-overview-table-actions-column-width: 80px;
}