Merge remote-tracking branch 'origin/main' into coar-notify-7

This commit is contained in:
frabacche
2024-02-26 14:40:17 +01:00
72 changed files with 2598 additions and 765 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
@@ -272,6 +294,8 @@ homePage:
# No. of communities to list per page on the home page
# This will always round to the nearest number from the list of page sizes. e.g. if you set it to 7 it'll use 10
pageSize: 5
# Enable or disable the Discover filters on the homepage
showDiscoverFilters: false
# Item Config
item:

View File

@@ -88,7 +88,7 @@ describe('CollectionSourceControlsComponent', () => {
invoke: createSuccessfulRemoteDataObject$(process),
});
processDataService = jasmine.createSpyObj('processDataService', {
findById: createSuccessfulRemoteDataObject$(process),
autoRefreshUntilCompletion: createSuccessfulRemoteDataObject$(process),
});
bitstreamService = jasmine.createSpyObj('bitstreamService', {
findByHref: createSuccessfulRemoteDataObject$(bitstream),
@@ -137,7 +137,7 @@ describe('CollectionSourceControlsComponent', () => {
{name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)},
], []);
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
expect(processDataService.autoRefreshUntilCompletion).toHaveBeenCalledWith(process.processId);
expect(bitstreamService.findByHref).toHaveBeenCalledWith(process._links.output.href);
expect(notificationsService.info).toHaveBeenCalledWith(jasmine.anything() as any, 'Script text');
});
@@ -151,7 +151,7 @@ describe('CollectionSourceControlsComponent', () => {
{name: '-r', value: null},
{name: '-c', value: collection.uuid},
], []);
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
expect(processDataService.autoRefreshUntilCompletion).toHaveBeenCalledWith(process.processId);
expect(notificationsService.success).toHaveBeenCalled();
});
});
@@ -164,7 +164,7 @@ describe('CollectionSourceControlsComponent', () => {
{name: '-o', value: null},
{name: '-c', value: collection.uuid},
], []);
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
expect(processDataService.autoRefreshUntilCompletion).toHaveBeenCalledWith(process.processId);
expect(notificationsService.success).toHaveBeenCalled();
});
});

View File

@@ -1,15 +1,14 @@
import { Component, Input, OnDestroy } from '@angular/core';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { ScriptDataService } from '../../../../core/data/processes/script-data.service';
import { ContentSource } from '../../../../core/shared/content-source.model';
import { ProcessDataService } from '../../../../core/data/processes/process-data.service';
import {
getAllCompletedRemoteData,
getAllSucceededRemoteDataPayload,
getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload
} from '../../../../core/shared/operators';
import { filter, map, switchMap, tap } from 'rxjs/operators';
import { hasValue, hasValueOperator } from '../../../../shared/empty.util';
import { hasValue } from '../../../../shared/empty.util';
import { ProcessStatus } from '../../../../process-page/processes/process-status.model';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { RequestService } from '../../../../core/data/request.service';
@@ -30,7 +29,7 @@ import { ContentSourceSetSerializer } from '../../../../core/shared/content-sour
styleUrls: ['./collection-source-controls.component.scss'],
templateUrl: './collection-source-controls.component.html',
})
export class CollectionSourceControlsComponent implements OnDestroy {
export class CollectionSourceControlsComponent implements OnInit, OnDestroy {
/**
* Should the controls be enabled.
@@ -49,6 +48,7 @@ export class CollectionSourceControlsComponent implements OnDestroy {
contentSource$: Observable<ContentSource>;
private subs: Subscription[] = [];
private autoRefreshIDs: string[] = [];
testConfigRunning$ = new BehaviorSubject(false);
importRunning$ = new BehaviorSubject(false);
@@ -95,36 +95,28 @@ export class CollectionSourceControlsComponent implements OnDestroy {
}),
// filter out responses that aren't successful since the pinging of the process only needs to happen when the invocation was successful.
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
getAllCompletedRemoteData(),
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
map((rd) => rd.payload),
hasValueOperator(),
switchMap((rd) => {
this.autoRefreshIDs.push(rd.payload.processId);
return this.processDataService.autoRefreshUntilCompletion(rd.payload.processId);
}),
map((rd) => rd.payload)
).subscribe((process: Process) => {
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
// Ping the current process state every 5s
setTimeout(() => {
this.requestService.setStaleByHrefSubstring(process._links.self.href);
}, 5000);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
this.notificationsService.error(this.translateService.get('collection.source.controls.test.failed'));
this.testConfigRunning$.next(false);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
this.bitstreamService.findByHref(process._links.output.href).pipe(getFirstSucceededRemoteDataPayload()).subscribe((bitstream) => {
this.httpClient.get(bitstream._links.content.href, {responseType: 'text'}).subscribe((data: any) => {
const output = data.replaceAll(new RegExp('.*\\@(.*)', 'g'), '$1')
.replaceAll('The script has started', '')
.replaceAll('The script has completed', '');
this.notificationsService.info(this.translateService.get('collection.source.controls.test.completed'), output);
});
});
this.testConfigRunning$.next(false);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
this.notificationsService.error(this.translateService.get('collection.source.controls.test.failed'));
this.testConfigRunning$.next(false);
}
));
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
this.bitstreamService.findByHref(process._links.output.href).pipe(getFirstSucceededRemoteDataPayload()).subscribe((bitstream) => {
this.httpClient.get(bitstream._links.content.href, {responseType: 'text'}).subscribe((data: any) => {
const output = data.replaceAll(new RegExp('.*\\@(.*)', 'g'), '$1')
.replaceAll('The script has started', '')
.replaceAll('The script has completed', '');
this.notificationsService.info(this.translateService.get('collection.source.controls.test.completed'), output);
});
});
this.testConfigRunning$.next(false);
}
}));
}
/**
@@ -147,31 +139,22 @@ export class CollectionSourceControlsComponent implements OnDestroy {
}
}),
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
getAllCompletedRemoteData(),
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
map((rd) => rd.payload),
hasValueOperator(),
switchMap((rd) => {
this.autoRefreshIDs.push(rd.payload.processId);
return this.processDataService.autoRefreshUntilCompletion(rd.payload.processId);
}),
map((rd) => rd.payload)
).subscribe((process) => {
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
// Ping the current process state every 5s
setTimeout(() => {
this.requestService.setStaleByHrefSubstring(process._links.self.href);
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
}, 5000);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
this.notificationsService.error(this.translateService.get('collection.source.controls.import.failed'));
this.importRunning$.next(false);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
this.notificationsService.success(this.translateService.get('collection.source.controls.import.completed'));
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
this.importRunning$.next(false);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
this.notificationsService.error(this.translateService.get('collection.source.controls.import.failed'));
this.importRunning$.next(false);
}
));
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
this.notificationsService.success(this.translateService.get('collection.source.controls.import.completed'));
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
this.importRunning$.next(false);
}
}));
}
/**
@@ -194,31 +177,22 @@ export class CollectionSourceControlsComponent implements OnDestroy {
}
}),
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
getAllCompletedRemoteData(),
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
map((rd) => rd.payload),
hasValueOperator(),
switchMap((rd) => {
this.autoRefreshIDs.push(rd.payload.processId);
return this.processDataService.autoRefreshUntilCompletion(rd.payload.processId);
}),
map((rd) => rd.payload)
).subscribe((process) => {
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
// Ping the current process state every 5s
setTimeout(() => {
this.requestService.setStaleByHrefSubstring(process._links.self.href);
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
}, 5000);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
this.notificationsService.error(this.translateService.get('collection.source.controls.reset.failed'));
this.reImportRunning$.next(false);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
this.notificationsService.success(this.translateService.get('collection.source.controls.reset.completed'));
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
this.reImportRunning$.next(false);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
this.notificationsService.error(this.translateService.get('collection.source.controls.reset.failed'));
this.reImportRunning$.next(false);
}
));
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
this.notificationsService.success(this.translateService.get('collection.source.controls.reset.completed'));
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
this.reImportRunning$.next(false);
}
}));
}
ngOnDestroy(): void {
@@ -227,5 +201,9 @@ export class CollectionSourceControlsComponent implements OnDestroy {
sub.unsubscribe();
}
});
this.autoRefreshIDs.forEach((id) => {
this.processDataService.stopAutoRefreshing(id);
});
}
}

View File

@@ -7,7 +7,7 @@ import { Bitstream } from '../core/shared/bitstream.model';
import { Community } from '../core/shared/community.model';
import { fadeInOut } from '../shared/animations/fade';
import { hasValue } from '../shared/empty.util';
import { getAllSucceededRemoteDataPayload} from '../core/shared/operators';
import { getAllSucceededRemoteDataPayload } from '../core/shared/operators';
import { AuthService } from '../core/auth/auth.service';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../core/data/feature-authorization/feature-id';

View File

@@ -273,12 +273,13 @@ export class RemoteDataBuildService {
return isStale(r2.state) ? r1 : r2;
}
}),
distinctUntilKeyChanged('lastUpdated')
);
const payload$ = this.buildPayload<T>(requestEntry$, href$, ...linksToFollow);
return this.toRemoteDataObservable<T>(requestEntry$, payload$);
return this.toRemoteDataObservable<T>(requestEntry$, payload$).pipe(
distinctUntilKeyChanged('lastUpdated'),
);
}
/**

View File

@@ -21,6 +21,10 @@ import { RequestEntryState } from '../request-entry-state.model';
import { fakeAsync, tick } from '@angular/core/testing';
import { BaseDataService } from './base-data.service';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub';
import { ObjectCacheEntry } from '../../cache/object-cache.reducer';
import { HALLink } from '../../shared/hal-link.model';
import { createPaginatedList } from '../../../shared/testing/utils.test';
const endpoint = 'https://rest.api/core';
@@ -46,34 +50,18 @@ describe('BaseDataService', () => {
let requestService;
let halService;
let rdbService;
let objectCache;
let objectCache: ObjectCacheServiceStub;
let selfLink;
let linksToFollow;
let testScheduler;
let remoteDataMocks;
let remoteDataMocks: { [responseType: string]: RemoteData<any> };
let remoteDataPageMocks: { [responseType: string]: RemoteData<any> };
function initTestService(): TestService {
requestService = getMockRequestService();
halService = new HALEndpointServiceStub('url') as any;
rdbService = getMockRemoteDataBuildService();
objectCache = {
addPatch: () => {
/* empty */
},
getObjectBySelfLink: () => {
/* empty */
},
getByHref: () => {
/* empty */
},
addDependency: () => {
/* empty */
},
removeDependents: () => {
/* empty */
},
} as any;
objectCache = new ObjectCacheServiceStub();
selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
linksToFollow = [
followLink('a'),
@@ -88,7 +76,27 @@ describe('BaseDataService', () => {
const timeStamp = new Date().getTime();
const msToLive = 15 * 60 * 1000;
const payload = { foo: 'bar' };
const payload = {
foo: 'bar',
followLink1: {},
followLink2: {},
_links: {
self: Object.assign(new HALLink(), {
href: 'self-test-link',
}),
followLink1: Object.assign(new HALLink(), {
href: 'follow-link-1',
}),
followLink2: [
Object.assign(new HALLink(), {
href: 'follow-link-2-1',
}),
Object.assign(new HALLink(), {
href: 'follow-link-2-2',
}),
],
}
};
const statusCodeSuccess = 200;
const statusCodeError = 404;
const errorMessage = 'not found';
@@ -101,11 +109,20 @@ describe('BaseDataService', () => {
Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError),
ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError),
};
remoteDataPageMocks = {
RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined),
ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined),
ResponsePendingStale: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined),
Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, createPaginatedList([payload]), statusCodeSuccess),
SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, createPaginatedList([payload]), statusCodeSuccess),
Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError),
ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError),
};
return new TestService(
requestService,
rdbService,
objectCache,
objectCache as ObjectCacheService,
halService,
);
}
@@ -380,6 +397,27 @@ describe('BaseDataService', () => {
});
it('should link all the followLinks of a cached object by calling addDependency', () => {
spyOn(objectCache, 'addDependency').and.callThrough();
testScheduler.run(({ cold, expectObservable, flush }) => {
spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d', {
a: remoteDataMocks.Success,
b: remoteDataMocks.RequestPending,
c: remoteDataMocks.ResponsePending,
d: remoteDataMocks.Success,
}));
const expected = '--b-c-d';
const values = {
b: remoteDataMocks.RequestPending,
c: remoteDataMocks.ResponsePending,
d: remoteDataMocks.Success,
};
expectObservable(service.findByHref(selfLink, false, false, ...linksToFollow)).toBe(expected, values);
flush();
expect(objectCache.addDependency).toHaveBeenCalledTimes(3);
});
});
});
describe(`findListByHref`, () => {
@@ -392,8 +430,8 @@ describe('BaseDataService', () => {
it(`should call buildHrefFromFindOptions with href and linksToFollow`, () => {
testScheduler.run(({ cold }) => {
spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink);
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success }));
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success }));
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success }));
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success }));
service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow);
expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith(selfLink, findListOptions, [], ...linksToFollow);
@@ -403,8 +441,8 @@ describe('BaseDataService', () => {
it(`should call createAndSendGetRequest with the result from buildHrefFromFindOptions and useCachedVersionIfAvailable`, () => {
testScheduler.run(({ cold, expectObservable }) => {
spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!');
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success }));
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success }));
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success }));
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success }));
service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow);
expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), true);
@@ -419,8 +457,8 @@ describe('BaseDataService', () => {
it(`should call rdbService.buildList with the result from buildHrefFromFindOptions and linksToFollow`, () => {
testScheduler.run(({ cold, expectObservable }) => {
spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!');
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success }));
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success }));
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success }));
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success }));
service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow);
expect(rdbService.buildList).toHaveBeenCalledWith(jasmine.anything() as any, ...linksToFollow);
@@ -431,12 +469,12 @@ describe('BaseDataService', () => {
it(`should call reRequestStaleRemoteData with reRequestOnStale and the exact same findListByHref call as a callback`, () => {
testScheduler.run(({ cold, expectObservable }) => {
spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!');
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale }));
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.SuccessStale }));
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.SuccessStale }));
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.SuccessStale }));
service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow);
expect((service as any).reRequestStaleRemoteData.calls.argsFor(0)[0]).toBeTrue();
spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale }));
spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataPageMocks.SuccessStale }));
// prove that the spy we just added hasn't been called yet
expect(service.findListByHref).not.toHaveBeenCalled();
// call the callback passed to reRequestStaleRemoteData
@@ -451,7 +489,7 @@ describe('BaseDataService', () => {
it(`should return a the output from reRequestStaleRemoteData`, () => {
testScheduler.run(({ cold, expectObservable }) => {
spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink);
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success }));
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success }));
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: 'bingo!' }));
const expected = 'a';
const values = {
@@ -471,19 +509,19 @@ describe('BaseDataService', () => {
it(`should emit a cached completed RemoteData immediately, and keep emitting if it gets rerequested`, () => {
testScheduler.run(({ cold, expectObservable }) => {
spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', {
a: remoteDataMocks.Success,
b: remoteDataMocks.RequestPending,
c: remoteDataMocks.ResponsePending,
d: remoteDataMocks.Success,
e: remoteDataMocks.SuccessStale,
a: remoteDataPageMocks.Success,
b: remoteDataPageMocks.RequestPending,
c: remoteDataPageMocks.ResponsePending,
d: remoteDataPageMocks.Success,
e: remoteDataPageMocks.SuccessStale,
}));
const expected = 'a-b-c-d-e';
const values = {
a: remoteDataMocks.Success,
b: remoteDataMocks.RequestPending,
c: remoteDataMocks.ResponsePending,
d: remoteDataMocks.Success,
e: remoteDataMocks.SuccessStale,
a: remoteDataPageMocks.Success,
b: remoteDataPageMocks.RequestPending,
c: remoteDataPageMocks.ResponsePending,
d: remoteDataPageMocks.Success,
e: remoteDataPageMocks.SuccessStale,
};
expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values);
@@ -493,20 +531,20 @@ describe('BaseDataService', () => {
it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => {
testScheduler.run(({ cold, expectObservable }) => {
spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', {
a: remoteDataMocks.ResponsePendingStale,
b: remoteDataMocks.SuccessStale,
c: remoteDataMocks.ErrorStale,
d: remoteDataMocks.RequestPending,
e: remoteDataMocks.ResponsePending,
f: remoteDataMocks.Success,
g: remoteDataMocks.SuccessStale,
a: remoteDataPageMocks.ResponsePendingStale,
b: remoteDataPageMocks.SuccessStale,
c: remoteDataPageMocks.ErrorStale,
d: remoteDataPageMocks.RequestPending,
e: remoteDataPageMocks.ResponsePending,
f: remoteDataPageMocks.Success,
g: remoteDataPageMocks.SuccessStale,
}));
const expected = '------d-e-f-g';
const values = {
d: remoteDataMocks.RequestPending,
e: remoteDataMocks.ResponsePending,
f: remoteDataMocks.Success,
g: remoteDataMocks.SuccessStale,
d: remoteDataPageMocks.RequestPending,
e: remoteDataPageMocks.ResponsePending,
f: remoteDataPageMocks.Success,
g: remoteDataPageMocks.SuccessStale,
};
expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values);
@@ -525,18 +563,18 @@ describe('BaseDataService', () => {
it(`should not emit a cached completed RemoteData, but only start emitting after the state first changes to RequestPending`, () => {
testScheduler.run(({ cold, expectObservable }) => {
spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', {
a: remoteDataMocks.Success,
b: remoteDataMocks.RequestPending,
c: remoteDataMocks.ResponsePending,
d: remoteDataMocks.Success,
e: remoteDataMocks.SuccessStale,
a: remoteDataPageMocks.Success,
b: remoteDataPageMocks.RequestPending,
c: remoteDataPageMocks.ResponsePending,
d: remoteDataPageMocks.Success,
e: remoteDataPageMocks.SuccessStale,
}));
const expected = '--b-c-d-e';
const values = {
b: remoteDataMocks.RequestPending,
c: remoteDataMocks.ResponsePending,
d: remoteDataMocks.Success,
e: remoteDataMocks.SuccessStale,
b: remoteDataPageMocks.RequestPending,
c: remoteDataPageMocks.ResponsePending,
d: remoteDataPageMocks.Success,
e: remoteDataPageMocks.SuccessStale,
};
expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values);
@@ -546,20 +584,20 @@ describe('BaseDataService', () => {
it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => {
testScheduler.run(({ cold, expectObservable }) => {
spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', {
a: remoteDataMocks.ResponsePendingStale,
b: remoteDataMocks.SuccessStale,
c: remoteDataMocks.ErrorStale,
d: remoteDataMocks.RequestPending,
e: remoteDataMocks.ResponsePending,
f: remoteDataMocks.Success,
g: remoteDataMocks.SuccessStale,
a: remoteDataPageMocks.ResponsePendingStale,
b: remoteDataPageMocks.SuccessStale,
c: remoteDataPageMocks.ErrorStale,
d: remoteDataPageMocks.RequestPending,
e: remoteDataPageMocks.ResponsePending,
f: remoteDataPageMocks.Success,
g: remoteDataPageMocks.SuccessStale,
}));
const expected = '------d-e-f-g';
const values = {
d: remoteDataMocks.RequestPending,
e: remoteDataMocks.ResponsePending,
f: remoteDataMocks.Success,
g: remoteDataMocks.SuccessStale,
d: remoteDataPageMocks.RequestPending,
e: remoteDataPageMocks.ResponsePending,
f: remoteDataPageMocks.Success,
g: remoteDataPageMocks.SuccessStale,
};
@@ -567,6 +605,27 @@ describe('BaseDataService', () => {
});
});
it('should link all the followLinks of the cached objects by calling addDependency', () => {
spyOn(objectCache, 'addDependency').and.callThrough();
testScheduler.run(({ cold, expectObservable, flush }) => {
spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d', {
a: remoteDataPageMocks.SuccessStale,
b: remoteDataPageMocks.RequestPending,
c: remoteDataPageMocks.ResponsePending,
d: remoteDataPageMocks.Success,
}));
const expected = '--b-c-d';
const values = {
b: remoteDataPageMocks.RequestPending,
c: remoteDataPageMocks.ResponsePending,
d: remoteDataPageMocks.Success,
};
expectObservable(service.findListByHref(selfLink, findListOptions, false, false, ...linksToFollow)).toBe(expected, values);
flush();
expect(objectCache.addDependency).toHaveBeenCalledTimes(3);
});
});
});
});
@@ -577,7 +636,7 @@ describe('BaseDataService', () => {
getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({
requestUUIDs: ['request1', 'request2', 'request3'],
dependentRequestUUIDs: ['request4', 'request5']
}));
} as ObjectCacheEntry));
});

View File

@@ -24,6 +24,7 @@ import { ObjectCacheEntry } from '../../cache/object-cache.reducer';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALDataService } from './hal-data-service.interface';
import { getFirstCompletedRemoteData } from '../../shared/operators';
import { HALLink } from '../../shared/hal-link.model';
export const EMBED_SEPARATOR = '%2F';
/**
@@ -268,7 +269,7 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable);
return this.rdbService.buildSingle<T>(requestHref$, ...linksToFollow).pipe(
const response$: Observable<RemoteData<T>> = this.rdbService.buildSingle<T>(requestHref$, ...linksToFollow).pipe(
// This skip ensures that if a stale object is present in the cache when you do a
// call it isn't immediately returned, but we wait until the remote data for the new request
// is created. If useCachedVersionIfAvailable is false it also ensures you don't get a
@@ -277,6 +278,25 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
this.reRequestStaleRemoteData(reRequestOnStale, () =>
this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)),
);
return response$.pipe(
// Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object
tap((remoteDataObject: RemoteData<T>) => {
if (hasValue(remoteDataObject?.payload?._links)) {
for (const followLinkName of Object.keys(remoteDataObject.payload._links)) {
// only add the followLinks if they are embedded
if (hasValue(remoteDataObject.payload[followLinkName]) && followLinkName !== 'self') {
// followLink can be either an individual HALLink or a HALLink[]
const followLinksList: HALLink[] = [].concat(remoteDataObject.payload._links[followLinkName]);
for (const individualFollowLink of followLinksList) {
if (hasValue(individualFollowLink?.href)) {
this.addDependency(response$, individualFollowLink.href);
}
}
}
}
}
}),
);
}
/**
@@ -302,7 +322,7 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable);
return this.rdbService.buildList<T>(requestHref$, ...linksToFollow).pipe(
const response$: Observable<RemoteData<PaginatedList<T>>> = this.rdbService.buildList<T>(requestHref$, ...linksToFollow).pipe(
// This skip ensures that if a stale object is present in the cache when you do a
// call it isn't immediately returned, but we wait until the remote data for the new request
// is created. If useCachedVersionIfAvailable is false it also ensures you don't get a
@@ -311,6 +331,29 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
this.reRequestStaleRemoteData(reRequestOnStale, () =>
this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)),
);
return response$.pipe(
// Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object
tap((remoteDataObject: RemoteData<PaginatedList<T>>) => {
if (hasValue(remoteDataObject?.payload?.page)) {
for (const object of remoteDataObject.payload.page) {
if (hasValue(object?._links)) {
for (const followLinkName of Object.keys(object._links)) {
// only add the followLinks if they are embedded
if (hasValue(object[followLinkName]) && followLinkName !== 'self') {
// followLink can be either an individual HALLink or a HALLink[]
const followLinksList: HALLink[] = [].concat(object._links[followLinkName]);
for (const individualFollowLink of followLinksList) {
if (hasValue(individualFollowLink?.href)) {
this.addDependency(response$, individualFollowLink.href);
}
}
}
}
}
}
}
}),
);
}
/**

View File

@@ -24,6 +24,7 @@ import { testFindAllDataImplementation } from './base/find-all-data.spec';
import { testSearchDataImplementation } from './base/search-data.spec';
import { testPatchDataImplementation } from './base/patch-data.spec';
import { testDeleteDataImplementation } from './base/delete-data.spec';
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
const url = 'fake-url';
const collectionId = 'fake-collection-id';
@@ -35,7 +36,7 @@ describe('CollectionDataService', () => {
let translate: TranslateService;
let notificationsService: any;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
let objectCache: ObjectCacheServiceStub;
let halService: any;
const mockCollection1: Collection = Object.assign(new Collection(), {
@@ -205,14 +206,12 @@ describe('CollectionDataService', () => {
buildFromRequestUUID: buildResponse$,
buildSingle: buildResponse$
});
objectCache = jasmine.createSpyObj('objectCache', {
remove: jasmine.createSpy('remove')
});
objectCache = new ObjectCacheServiceStub();
halService = new HALEndpointServiceStub(url);
notificationsService = new NotificationsServiceStub();
translate = getMockTranslateService();
service = new CollectionDataService(requestService, rdbService, objectCache, halService, null, notificationsService, null, null, translate);
service = new CollectionDataService(requestService, rdbService, objectCache as ObjectCacheService, halService, null, notificationsService, null, null, translate);
}
});

View File

@@ -7,13 +7,187 @@
*/
import { testFindAllDataImplementation } from '../base/find-all-data.spec';
import { ProcessDataService } from './process-data.service';
import { ProcessDataService, TIMER_FACTORY } from './process-data.service';
import { testDeleteDataImplementation } from '../base/delete-data.spec';
import { waitForAsync, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { RequestService } from '../request.service';
import { RemoteData } from '../remote-data';
import { RequestEntryState } from '../request-entry-state.model';
import { Process } from '../../../process-page/processes/process.model';
import { ProcessStatus } from '../../../process-page/processes/process-status.model';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { ReducerManager } from '@ngrx/store';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { DSOChangeAnalyzer } from '../dso-change-analyzer.service';
import { BitstreamFormatDataService } from '../bitstream-format-data.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TestScheduler } from 'rxjs/testing';
import { testSearchDataImplementation } from '../base/search-data.spec';
import { PaginatedList } from '../paginated-list.model';
import { FindListOptions } from '../find-list-options.model';
import { of } from 'rxjs';
import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
describe('ProcessDataService', () => {
let testScheduler;
const mockTimer = (fn: () => {}, interval: number) => {
fn();
return 555;
};
describe('composition', () => {
const initService = () => new ProcessDataService(null, null, null, null, null, null);
const initService = () => new ProcessDataService(null, null, null, null, null, null, null, null);
testFindAllDataImplementation(initService);
testDeleteDataImplementation(initService);
testSearchDataImplementation(initService);
});
let requestService = getMockRequestService();
let processDataService;
let remoteDataBuildService;
describe('autoRefreshUntilCompletion', () => {
beforeEach(waitForAsync(() => {
testScheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
TestBed.configureTestingModule({
imports: [],
providers: [
ProcessDataService,
{ provide: RequestService, useValue: null },
{ provide: RemoteDataBuildService, useValue: null },
{ provide: ObjectCacheService, useValue: null },
{ provide: ReducerManager, useValue: null },
{ provide: HALEndpointService, useValue: null },
{ provide: DSOChangeAnalyzer, useValue: null },
{ provide: BitstreamFormatDataService, useValue: null },
{ provide: NotificationsService, useValue: null },
{ provide: TIMER_FACTORY, useValue: mockTimer },
]
});
processDataService = TestBed.inject(ProcessDataService);
spyOn(processDataService, 'invalidateByHref');
}));
it('should not do any polling when the process is already completed', () => {
testScheduler.run(({ cold, expectObservable }) => {
let completedProcess = new Process();
completedProcess.processStatus = ProcessStatus.COMPLETED;
const completedProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, completedProcess);
spyOn(processDataService, 'findById').and.returnValue(
cold('c', {
'c': completedProcessRD
})
);
let process$ = processDataService.autoRefreshUntilCompletion('instantly');
expectObservable(process$).toBe('c', {
c: completedProcessRD
});
});
expect(processDataService.findById).toHaveBeenCalledTimes(1);
expect(processDataService.invalidateByHref).not.toHaveBeenCalled();
});
it('should poll until a process completes', () => {
testScheduler.run(({ cold, expectObservable }) => {
const runningProcess = Object.assign(new Process(), {
_links: {
self: {
href: 'https://rest.api/processes/123'
}
}
});
runningProcess.processStatus = ProcessStatus.RUNNING;
const completedProcess = new Process();
completedProcess.processStatus = ProcessStatus.COMPLETED;
const runningProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, runningProcess);
const completedProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, completedProcess);
spyOn(processDataService, 'findById').and.returnValue(
cold('r 150ms c', {
'r': runningProcessRD,
'c': completedProcessRD
})
);
let process$ = processDataService.autoRefreshUntilCompletion('foo', 100);
expectObservable(process$).toBe('r 150ms c', {
'r': runningProcessRD,
'c': completedProcessRD
});
});
expect(processDataService.findById).toHaveBeenCalledTimes(1);
expect(processDataService.invalidateByHref).toHaveBeenCalledTimes(1);
});
});
describe('autoRefreshingSearchBy', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [],
providers: [
ProcessDataService,
{ provide: RequestService, useValue: requestService },
{ provide: RemoteDataBuildService, useValue: null },
{ provide: ObjectCacheService, useValue: null },
{ provide: ReducerManager, useValue: null },
{ provide: HALEndpointService, useValue: null },
{ provide: DSOChangeAnalyzer, useValue: null },
{ provide: BitstreamFormatDataService, useValue: null },
{ provide: NotificationsService, useValue: null },
{ provide: TIMER_FACTORY, useValue: mockTimer },
]
});
processDataService = TestBed.inject(ProcessDataService);
}));
it('should refresh after the specified interval', fakeAsync(() => {
const runningProcess = Object.assign(new Process(), {
_links: {
self: {
href: 'https://rest.api/processes/123'
}
}
});
runningProcess.processStatus = ProcessStatus.RUNNING;
const runningProcessPagination: PaginatedList<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

@@ -1,12 +1,12 @@
import { Injectable } from '@angular/core';
import { Injectable, NgZone, Inject, InjectionToken } from '@angular/core';
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { Process } from '../../../process-page/processes/process.model';
import { PROCESS } from '../../../process-page/processes/process.resource-type';
import { Observable } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { Observable, Subscription } from 'rxjs';
import { switchMap, filter, distinctUntilChanged, find } from 'rxjs/operators';
import { PaginatedList } from '../paginated-list.model';
import { Bitstream } from '../../shared/bitstream.model';
import { RemoteData } from '../remote-data';
@@ -19,12 +19,29 @@ import { dataService } from '../base/data-service.decorator';
import { DeleteData, DeleteDataImpl } from '../base/delete-data';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { NoContent } from '../../shared/NoContent.model';
import { getAllCompletedRemoteData } from '../../shared/operators';
import { ProcessStatus } from 'src/app/process-page/processes/process-status.model';
import { hasValue } from '../../../shared/empty.util';
import { SearchData, SearchDataImpl } from '../base/search-data';
/**
* Create an InjectionToken for the default JS setTimeout function, purely so we can mock it during
* testing. (fakeAsync isn't working for this case)
*/
export const TIMER_FACTORY = new InjectionToken<(callback: (...args: any[]) => void, ms?: number, ...args: any[]) => NodeJS.Timeout>('timer', {
providedIn: 'root',
factory: () => setTimeout
});
@Injectable()
@dataService(PROCESS)
export class ProcessDataService extends IdentifiableDataService<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,
@@ -33,11 +50,30 @@ export class ProcessDataService extends IdentifiableDataService<Process> impleme
protected halService: HALEndpointService,
protected bitstreamDataService: BitstreamDataService,
protected notificationsService: NotificationsService,
protected zone: NgZone,
@Inject(TIMER_FACTORY) protected timer: (callback: (...args: any[]) => void, ms?: number, ...args: any[]) => NodeJS.Timeout
) {
super('processes', requestService, rdbService, objectCache, halService);
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
}
/**
* Return true if the given process has the given status
* @protected
*/
protected static statusIs(process: Process, status: ProcessStatus): boolean {
return hasValue(process) && process.processStatus === status;
}
/**
* Return true if the given process has the status COMPLETED or FAILED
*/
public static hasCompletedOrFailed(process: Process): boolean {
return ProcessDataService.statusIs(process, ProcessStatus.COMPLETED) ||
ProcessDataService.statusIs(process, ProcessStatus.FAILED);
}
/**
@@ -77,6 +113,71 @@ export class ProcessDataService extends IdentifiableDataService<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
@@ -101,4 +202,74 @@ export class ProcessDataService extends IdentifiableDataService<Process> impleme
public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return this.deleteData.deleteByHref(href, copyVirtualMetadata);
}
/**
* Clear the timeout for the given id, if that timeout exists
* @protected
*/
protected clearCurrentTimeout(id: string): void {
const timeout = this.activelyBeingPolled.get(id);
if (hasValue(timeout)) {
clearTimeout(timeout);
}
this.activelyBeingPolled.delete(id);
}
/**
* Poll the process with the given ID, using the given interval, until that process either
* completes successfully or fails
*
* Return an Observable<RemoteData> for the Process. Note that this will also emit while the
* process is still running. It will only emit again when the process (not the RemoteData!) changes
* status. That makes it more convenient to retrieve that process for a component: you can replace
* a findByID call with this method, rather than having to do a separate findById, and then call
* this method
*
* @param processId The ID of the {@link Process} to poll
* @param pollingIntervalInMs The interval for how often the request needs to be polled
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be
* automatically resolved
*/
public autoRefreshUntilCompletion(processId: string, pollingIntervalInMs = 5000, ...linksToFollow: FollowLinkConfig<Process>[]): Observable<RemoteData<Process>> {
const process$: Observable<RemoteData<Process>> = this.findById(processId, true, true, ...linksToFollow)
.pipe(
getAllCompletedRemoteData(),
);
// Create a subscription that marks the data as stale if the process hasn't been completed and
// the polling interval time has been exceeded.
const sub = process$.pipe(
filter((processRD: RemoteData<Process>) =>
!ProcessDataService.hasCompletedOrFailed(processRD.payload) &&
!this.activelyBeingPolled.has(processId)
)
).subscribe((processRD: RemoteData<Process>) => {
this.clearCurrentTimeout(processId);
if (processRD.hasSucceeded) {
const nextTimeout = this.timer(() => {
this.activelyBeingPolled.delete(processId);
this.invalidateByHref(processRD.payload._links.self.href);
}, pollingIntervalInMs);
this.activelyBeingPolled.set(processId, nextTimeout);
}
});
this.subs.set(processId, sub);
// When the process completes create a one off subscription (the `find` completes the
// observable) that unsubscribes the previous one, removes the processId from the list of
// processes being polled and clears any running timeouts
process$.pipe(
find((processRD: RemoteData<Process>) => ProcessDataService.hasCompletedOrFailed(processRD.payload))
).subscribe(() => {
this.stopAutoRefreshing(processId);
});
return process$.pipe(
distinctUntilChanged((previous: RemoteData<Process>, current: RemoteData<Process>) =>
previous.payload?.processStatus === current.payload?.processStatus,
)
);
}
}

View File

@@ -23,6 +23,7 @@ import { FindListOptions } from './find-list-options.model';
import { testSearchDataImplementation } from './base/search-data.spec';
import { MetadataValue } from '../shared/metadata.models';
import { MetadataRepresentationType } from '../shared/metadata-representation/metadata-representation.model';
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
describe('RelationshipDataService', () => {
let service: RelationshipDataService;
@@ -114,14 +115,7 @@ describe('RelationshipDataService', () => {
'href': buildList$,
'https://rest.api/core/publication/relationships': relationships$
});
const objectCache = Object.assign({
/* eslint-disable no-empty,@typescript-eslint/no-empty-function */
remove: () => {
},
hasBySelfLinkObservable: () => observableOf(false),
hasByHref$: () => observableOf(false)
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */
}) as ObjectCacheService;
const objectCache = new ObjectCacheServiceStub();
const itemService = jasmine.createSpyObj('itemService', {
findById: (uuid) => createSuccessfulRemoteDataObject(relatedItems.find((relatedItem) => relatedItem.id === uuid)),
@@ -133,7 +127,7 @@ describe('RelationshipDataService', () => {
requestService,
rdbService,
halService,
objectCache,
objectCache as ObjectCacheService,
itemService,
null,
jasmine.createSpy('paginatedRelationsToItems').and.returnValue((v) => v),

View File

@@ -10,6 +10,7 @@ import { RequestService } from './request.service';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { hasValueOperator } from '../../shared/empty.util';
import { ObjectCacheService } from '../cache/object-cache.service';
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
describe('RelationshipTypeDataService', () => {
let service: RelationshipTypeDataService;
@@ -28,7 +29,7 @@ describe('RelationshipTypeDataService', () => {
let buildList;
let rdbService;
let objectCache;
let objectCache: ObjectCacheServiceStub;
function init() {
restEndpointURL = 'https://rest.api/relationshiptypes';
@@ -60,21 +61,14 @@ describe('RelationshipTypeDataService', () => {
buildList = createSuccessfulRemoteDataObject(createPaginatedList([relationshipType1, relationshipType2]));
rdbService = getMockRemoteDataBuildService(undefined, observableOf(buildList));
objectCache = Object.assign({
/* eslint-disable no-empty,@typescript-eslint/no-empty-function */
remove: () => {
},
hasBySelfLinkObservable: () => observableOf(false)
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */
}) as ObjectCacheService;
objectCache = new ObjectCacheServiceStub();
}
function initTestService() {
return new RelationshipTypeDataService(
requestService,
rdbService,
objectCache,
objectCache as ObjectCacheService,
halService,
);
}

View File

@@ -22,6 +22,7 @@ import {
import { ReplaceOperation } from 'fast-json-patch';
import { RequestEntry } from '../../../data/request-entry.model';
import { FindListOptions } from '../../../data/find-list-options.model';
import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub';
describe('QualityAssuranceEventDataService', () => {
let scheduler: TestScheduler;
@@ -32,7 +33,7 @@ describe('QualityAssuranceEventDataService', () => {
let responseCacheEntryC: RequestEntry;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
let objectCache: ObjectCacheServiceStub;
let halService: HALEndpointService;
let notificationsService: NotificationsService;
let http: HttpClient;
@@ -91,7 +92,7 @@ describe('QualityAssuranceEventDataService', () => {
buildFromRequestUUIDAndAwait: jasmine.createSpy('buildFromRequestUUIDAndAwait')
});
objectCache = {} as ObjectCacheService;
objectCache = new ObjectCacheServiceStub();
halService = jasmine.createSpyObj('halService', {
getEndpoint: cold('a|', { a: endpointURL })
});
@@ -103,7 +104,7 @@ describe('QualityAssuranceEventDataService', () => {
service = new QualityAssuranceEventDataService(
requestService,
rdbService,
objectCache,
objectCache as ObjectCacheService,
halService,
notificationsService,
comparator

View File

@@ -19,6 +19,7 @@ import {
} from '../../../../shared/mocks/notifications.mock';
import { RequestEntry } from '../../../data/request-entry.model';
import { QualityAssuranceSourceDataService } from './quality-assurance-source-data.service';
import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub';
describe('QualityAssuranceSourceDataService', () => {
let scheduler: TestScheduler;
@@ -26,7 +27,7 @@ describe('QualityAssuranceSourceDataService', () => {
let responseCacheEntry: RequestEntry;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
let objectCache: ObjectCacheServiceStub;
let halService: HALEndpointService;
let notificationsService: NotificationsService;
let http: HttpClient;
@@ -63,7 +64,7 @@ describe('QualityAssuranceSourceDataService', () => {
}),
});
objectCache = {} as ObjectCacheService;
objectCache = new ObjectCacheServiceStub();
halService = jasmine.createSpyObj('halService', {
getEndpoint: cold('a|', { a: endpointURL })
});
@@ -75,7 +76,7 @@ describe('QualityAssuranceSourceDataService', () => {
service = new QualityAssuranceSourceDataService(
requestService,
rdbService,
objectCache,
objectCache as ObjectCacheService,
halService,
notificationsService
);

View File

@@ -19,6 +19,7 @@ import {
qualityAssuranceTopicObjectMorePid
} from '../../../../shared/mocks/notifications.mock';
import { RequestEntry } from '../../../data/request-entry.model';
import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub';
describe('QualityAssuranceTopicDataService', () => {
let scheduler: TestScheduler;
@@ -26,7 +27,7 @@ describe('QualityAssuranceTopicDataService', () => {
let responseCacheEntry: RequestEntry;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
let objectCache: ObjectCacheServiceStub;
let halService: HALEndpointService;
let notificationsService: NotificationsService;
let http: HttpClient;
@@ -63,7 +64,7 @@ describe('QualityAssuranceTopicDataService', () => {
}),
});
objectCache = {} as ObjectCacheService;
objectCache = new ObjectCacheServiceStub();
halService = jasmine.createSpyObj('halService', {
getEndpoint: cold('a|', { a: endpointURL })
});
@@ -75,7 +76,7 @@ describe('QualityAssuranceTopicDataService', () => {
service = new QualityAssuranceTopicDataService(
requestService,
rdbService,
objectCache,
objectCache as ObjectCacheService,
halService,
notificationsService
);

View File

@@ -20,13 +20,14 @@ import { FindListOptions } from '../data/find-list-options.model';
import { EPersonDataService } from '../eperson/eperson-data.service';
import { GroupDataService } from '../eperson/group-data.service';
import { RestRequestMethod } from '../data/rest-request-method';
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
describe('ResourcePolicyService', () => {
let scheduler: TestScheduler;
let service: ResourcePolicyDataService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
let objectCache: ObjectCacheServiceStub;
let halService: HALEndpointService;
let responseCacheEntry: RequestEntry;
let ePersonService: EPersonDataService;
@@ -139,14 +140,14 @@ describe('ResourcePolicyService', () => {
a: 'https://rest.api/rest/api/eperson/groups/' + groupUUID
}),
});
objectCache = {} as ObjectCacheService;
objectCache = new ObjectCacheServiceStub();
const notificationsService = {} as NotificationsService;
const comparator = {} as any;
service = new ResourcePolicyDataService(
requestService,
rdbService,
objectCache,
objectCache as ObjectCacheService,
halService,
notificationsService,
comparator,

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

@@ -25,6 +25,7 @@ import { createPaginatedList } from '../../../shared/testing/utils.test';
import { RequestEntry } from '../../data/request-entry.model';
import { VocabularyDataService } from './vocabulary.data.service';
import { VocabularyEntryDetailsDataService } from './vocabulary-entry-details.data.service';
import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub';
describe('VocabularyService', () => {
let scheduler: TestScheduler;
@@ -205,6 +206,7 @@ describe('VocabularyService', () => {
function initTestService() {
hrefOnlyDataService = getMockHrefOnlyDataService();
objectCache = new ObjectCacheServiceStub() as ObjectCacheService;
return new VocabularyService(
requestService,
@@ -253,7 +255,9 @@ describe('VocabularyService', () => {
spyOn((service as any).vocabularyDataService, 'findById').and.callThrough();
spyOn((service as any).vocabularyDataService, 'findAll').and.callThrough();
spyOn((service as any).vocabularyDataService, 'findByHref').and.callThrough();
spyOn((service as any).vocabularyDataService, 'getVocabularyByMetadataAndCollection').and.callThrough();
spyOn((service as any).vocabularyDataService.findAllData, 'getFindAllHref').and.returnValue(observableOf(entriesRequestURL));
spyOn((service as any).vocabularyDataService.searchData, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL));
});
afterEach(() => {
@@ -310,6 +314,23 @@ describe('VocabularyService', () => {
expect(result).toBeObservable(expected);
});
});
describe('getVocabularyByMetadataAndCollection', () => {
it('should proxy the call to vocabularyDataService.getVocabularyByMetadataAndCollection', () => {
scheduler.schedule(() => service.getVocabularyByMetadataAndCollection(metadata, collectionUUID));
scheduler.flush();
expect((service as any).vocabularyDataService.getVocabularyByMetadataAndCollection).toHaveBeenCalledWith(metadata, collectionUUID, true, true);
});
it('should return a RemoteData<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

@@ -17,13 +17,14 @@ import { RestResponse } from '../cache/response.models';
import { RequestEntry } from '../data/request-entry.model';
import { FindListOptions } from '../data/find-list-options.model';
import { GroupDataService } from '../eperson/group-data.service';
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
describe('SupervisionOrderService', () => {
let scheduler: TestScheduler;
let service: SupervisionOrderDataService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
let objectCache: ObjectCacheServiceStub;
let halService: HALEndpointService;
let responseCacheEntry: RequestEntry;
let groupService: GroupDataService;
@@ -127,14 +128,14 @@ describe('SupervisionOrderService', () => {
a: 'https://rest.api/rest/api/group/groups/' + groupUUID
}),
});
objectCache = {} as ObjectCacheService;
objectCache = new ObjectCacheServiceStub();
const notificationsService = {} as NotificationsService;
const comparator = {} as any;
service = new SupervisionOrderDataService(
requestService,
rdbService,
objectCache,
objectCache as ObjectCacheService,
halService,
notificationsService,
comparator,

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,10 +1,19 @@
<ds-themed-home-news></ds-themed-home-news>
<div class="container">
<ng-container *ngIf="(site$ | async) as site">
<ds-view-tracker [object]="site"></ds-view-tracker>
</ng-container>
<ds-themed-search-form [inPlaceSearch]="false" [searchPlaceholder]="'home.search-form.placeholder' | translate"></ds-themed-search-form>
<ds-themed-top-level-community-list></ds-themed-top-level-community-list>
<ds-recent-item-list *ngIf="recentSubmissionspageSize>0"></ds-recent-item-list>
<div [ngClass]="appConfig.homePage.showDiscoverFilters ? 'container-fluid' : 'container'">
<div class="row m-5">
<div class="col-sm-3" *ngIf="appConfig.homePage.showDiscoverFilters">
<ds-configuration-search-page [sideBarWidth]="12" [showViewModes]="false" [searchEnabled]="false"
[inPlaceSearch]="false" [showScopeSelector]="false"></ds-configuration-search-page>
</div>
<div [ngClass]="appConfig.homePage.showDiscoverFilters ? 'col-sm-9' : 'col-sm-12'">
<ng-container *ngIf="(site$ | async) as site">
<ds-view-tracker [object]="site"></ds-view-tracker>
</ng-container>
<ds-themed-search-form [inPlaceSearch]="false"
[searchPlaceholder]="'home.search-form.placeholder' | translate"></ds-themed-search-form>
<ds-themed-top-level-community-list></ds-themed-top-level-community-list>
<ds-recent-item-list *ngIf="recentSubmissionspageSize>0"></ds-recent-item-list>
</div>
</div>
</div>
<ds-suggestions-popup></ds-suggestions-popup>

View File

@@ -10,6 +10,7 @@ import { NotifyInfoService } from '../core/coar-notify/notify-info/notify-info.s
import { LinkDefinition, LinkHeadService } from '../core/services/link-head.service';
import { isNotEmpty } from '../shared/empty.util';
import { APP_CONFIG, AppConfig } from 'src/config/app-config.interface';
@Component({
selector: 'ds-home-page',
styleUrls: ['./home-page.component.scss'],
@@ -25,6 +26,7 @@ export class HomePageComponent implements OnInit, OnDestroy {
inboxLinks: LinkDefinition[] = [];
constructor(
@Inject(APP_CONFIG) protected appConfig: AppConfig,
private route: ActivatedRoute,
private responseService: ServerResponseService,
private notifyInfoService: NotifyInfoService,

View File

@@ -3,7 +3,6 @@ import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { HomeNewsComponent } from './home-news/home-news.component';
import { HomePageRoutingModule } from './home-page-routing.module';
import { HomePageComponent } from './home-page.component';
import { TopLevelCommunityListComponent } from './top-level-community-list/top-level-community-list.component';
import { StatisticsModule } from '../statistics/statistics.module';
@@ -13,6 +12,7 @@ import { RecentItemListComponent } from './recent-item-list/recent-item-list.com
import { JournalEntitiesModule } from '../entity-groups/journal-entities/journal-entities.module';
import { ResearchEntitiesModule } from '../entity-groups/research-entities/research-entities.module';
import { ThemedTopLevelCommunityListComponent } from './top-level-community-list/themed-top-level-community-list.component';
import { SearchModule } from '../shared/search/search.module';
import { NotificationsModule } from '../notifications/notifications.module';
const DECLARATIONS = [
@@ -29,6 +29,7 @@ const DECLARATIONS = [
imports: [
CommonModule,
SharedModule.withEntryComponents(),
SearchModule,
JournalEntitiesModule.withEntryComponents(),
ResearchEntitiesModule.withEntryComponents(),
HomePageRoutingModule,

View File

@@ -5,8 +5,8 @@
{{ 'process.detail.title' | translate:{ id: process?.processId, name: process?.scriptName } }}
</h1>
</div>
<div *ngIf="refreshCounter$ | async as seconds" class="col-2 refresh-counter">
Refreshing in {{ seconds }}s <i class="fas fa-sync-alt fa-spin"></i>
<div *ngIf="isRefreshing$ | async" class="col-2 refresh-counter">
{{ 'process.detail.refreshing' | translate }} <i class="fas fa-sync-alt fa-spin"></i>
</div>
</div>

View File

@@ -27,7 +27,6 @@ import { ProcessDataService } from '../../core/data/processes/process-data.servi
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
import { createPaginatedList } from '../../shared/testing/utils.test';
@@ -35,7 +34,10 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { getProcessListRoute } from '../process-page-routing.paths';
import {ProcessStatus} from '../processes/process-status.model';
import { PaginatedList } from '../../core/data/paginated-list.model';
import { RouterTestingModule } from '@angular/router/testing';
import { RouterStub } from '../../shared/testing/router.stub';
import { ActivatedRouteStub } from '../../shared/testing/active-router.stub';
describe('ProcessDetailComponent', () => {
let component: ProcessDetailComponent;
@@ -45,44 +47,18 @@ describe('ProcessDetailComponent', () => {
let nameService: DSONameService;
let bitstreamDataService: BitstreamDataService;
let httpClient: HttpClient;
let route: ActivatedRoute;
let route: ActivatedRouteStub;
let router: RouterStub;
let modalService;
let notificationsService: NotificationsServiceStub;
let process: Process;
let fileName: string;
let files: Bitstream[];
let processOutput;
let modalService;
let notificationsService;
let router;
let processOutput: string;
function init() {
processOutput = 'Process Started';
process = Object.assign(new Process(), {
processId: 1,
scriptName: 'script-name',
processStatus: 'COMPLETED',
parameters: [
{
name: '-f',
value: 'file.xml'
},
{
name: '-i',
value: 'identifier'
}
],
_links: {
self: {
href: 'https://rest.api/processes/1'
},
output: {
href: 'https://rest.api/processes/1/output'
}
}
});
fileName = 'fake-file-name';
files = [
Object.assign(new Bitstream(), {
@@ -100,6 +76,33 @@ describe('ProcessDetailComponent', () => {
}
})
];
processOutput = 'Process Started';
process = Object.assign(new Process(), {
processId: 1,
scriptName: 'script-name',
processStatus: 'COMPLETED',
parameters: [
{
name: '-f',
value: 'file.xml'
},
{
name: '-i',
value: 'identifier'
}
],
files: createSuccessfulRemoteDataObject$(Object.assign(new PaginatedList(), {
page: files,
})),
_links: {
self: {
href: 'https://rest.api/processes/1'
},
output: {
href: 'https://rest.api/processes/1/output'
}
},
});
const logBitstream = Object.assign(new Bitstream(), {
id: 'output.log',
_links: {
@@ -110,6 +113,7 @@ describe('ProcessDetailComponent', () => {
getFiles: createSuccessfulRemoteDataObject$(createPaginatedList(files)),
delete: createSuccessfulRemoteDataObject$(null),
findById: createSuccessfulRemoteDataObject$(process),
autoRefreshUntilCompletion: createSuccessfulRemoteDataObject$(process)
});
bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', {
findByHref: createSuccessfulRemoteDataObject$(logBitstream)
@@ -127,28 +131,22 @@ describe('ProcessDetailComponent', () => {
notificationsService = new NotificationsServiceStub();
router = jasmine.createSpyObj('router', {
navigateByUrl:{}
});
router = new RouterStub();
route = jasmine.createSpyObj('route', {
data: observableOf({ process: createSuccessfulRemoteDataObject(process) }),
snapshot: {
params: { id: process.processId }
}
route = new ActivatedRouteStub({
id: process.processId,
}, {
process: createSuccessfulRemoteDataObject$(process),
});
}
beforeEach(waitForAsync(() => {
init();
TestBed.configureTestingModule({
void TestBed.configureTestingModule({
declarations: [ProcessDetailComponent, ProcessDetailFieldComponent, VarDirective, FileSizePipe],
imports: [TranslateModule.forRoot()],
imports: [TranslateModule.forRoot(), RouterTestingModule],
providers: [
{
provide: ActivatedRoute,
useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }), snapshot: { params: { id: 1 } } },
},
{ provide: ActivatedRoute, useValue: route },
{ provide: ProcessDataService, useValue: processService },
{ provide: BitstreamDataService, useValue: bitstreamDataService },
{ provide: DSONameService, useValue: nameService },
@@ -253,6 +251,8 @@ describe('ProcessDetailComponent', () => {
describe('deleteProcess', () => {
it('should delete the process and navigate back to the overview page on success', () => {
spyOn(component, 'closeModal');
spyOn(router, 'navigateByUrl').and.callThrough();
component.deleteProcess(process);
expect(processService.delete).toHaveBeenCalledWith(process.processId);
@@ -263,6 +263,7 @@ describe('ProcessDetailComponent', () => {
it('should delete the process and not navigate on error', () => {
(processService.delete as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$());
spyOn(component, 'closeModal');
spyOn(router, 'navigateByUrl').and.callThrough();
component.deleteProcess(process);
@@ -272,98 +273,4 @@ describe('ProcessDetailComponent', () => {
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
});
describe('refresh counter', () => {
const queryRefreshCounter = () => fixture.debugElement.query(By.css('.refresh-counter'));
describe('if process is completed', () => {
beforeEach(() => {
process.processStatus = ProcessStatus.COMPLETED;
route.data = observableOf({process: createSuccessfulRemoteDataObject(process)});
});
it('should not show', () => {
spyOn(component, 'startRefreshTimer');
const refreshCounter = queryRefreshCounter();
expect(refreshCounter).toBeNull();
expect(component.startRefreshTimer).not.toHaveBeenCalled();
});
});
describe('if process is not finished', () => {
beforeEach(() => {
process.processStatus = ProcessStatus.RUNNING;
route.data = observableOf({process: createSuccessfulRemoteDataObject(process)});
fixture.detectChanges();
component.stopRefreshTimer();
});
it('should call startRefreshTimer', () => {
spyOn(component, 'startRefreshTimer');
component.ngOnInit();
fixture.detectChanges(); // subscribe to process observable with async pipe
expect(component.startRefreshTimer).toHaveBeenCalled();
});
it('should call refresh method every 5 seconds, until process is completed', fakeAsync(() => {
spyOn(component, 'refresh').and.callThrough();
spyOn(component, 'stopRefreshTimer').and.callThrough();
// start off with a running process in order for the refresh counter starts counting up
process.processStatus = ProcessStatus.RUNNING;
// set findbyId to return a completed process
(processService.findById as jasmine.Spy).and.returnValue(observableOf(createSuccessfulRemoteDataObject(process)));
component.ngOnInit();
fixture.detectChanges(); // subscribe to process observable with async pipe
expect(component.refresh).not.toHaveBeenCalled();
expect(component.refreshCounter$.value).toBe(0);
tick(1001); // 1 second + 1 ms by the setTimeout
expect(component.refreshCounter$.value).toBe(5); // 5 - 0
tick(2001); // 2 seconds + 1 ms by the setTimeout
expect(component.refreshCounter$.value).toBe(3); // 5 - 2
tick(2001); // 2 seconds + 1 ms by the setTimeout
expect(component.refreshCounter$.value).toBe(1); // 3 - 2
tick(1001); // 1 second + 1 ms by the setTimeout
expect(component.refreshCounter$.value).toBe(0); // 1 - 1
// set the process to completed right before the counter checks the process
process.processStatus = ProcessStatus.COMPLETED;
(processService.findById as jasmine.Spy).and.returnValue(observableOf(createSuccessfulRemoteDataObject(process)));
tick(1000); // 1 second
expect(component.refresh).toHaveBeenCalledTimes(1);
expect(component.stopRefreshTimer).toHaveBeenCalled();
expect(component.refreshCounter$.value).toBe(0);
tick(1001); // 1 second + 1 ms by the setTimeout
// startRefreshTimer not called again
expect(component.refreshCounter$.value).toBe(0);
discardPeriodicTasks(); // discard any periodic tasks that have not yet executed
}));
it('should show if refreshCounter is different from 0', () => {
component.refreshCounter$.next(1);
fixture.detectChanges();
const refreshCounter = queryRefreshCounter();
expect(refreshCounter).not.toBeNull();
});
});
});
});

View File

@@ -1,8 +1,8 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, NgZone, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
import { Component, Inject, NgZone, OnInit, PLATFORM_ID, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, interval, Observable, shareReplay, Subscription } from 'rxjs';
import { finalize, map, switchMap, take, tap } from 'rxjs/operators';
import { BehaviorSubject, Observable } from 'rxjs';
import { finalize, map, switchMap, take, tap, find, startWith, filter } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
@@ -14,7 +14,7 @@ import { DSpaceObject } from '../../core/shared/dspace-object.model';
import {
getFirstCompletedRemoteData,
getFirstSucceededRemoteData,
getFirstSucceededRemoteDataPayload
getFirstSucceededRemoteDataPayload, getAllSucceededRemoteDataPayload
} from '../../core/shared/operators';
import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { AlertType } from '../../shared/alert/alert-type';
@@ -26,8 +26,8 @@ import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { getProcessListRoute } from '../process-page-routing.paths';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { isPlatformBrowser } from '@angular/common';
import { PROCESS_PAGE_FOLLOW_LINKS } from '../process-page.resolver';
@Component({
selector: 'ds-process-detail',
@@ -78,15 +78,17 @@ export class ProcessDetailComponent implements OnInit, OnDestroy {
*/
dateFormat = 'yyyy-MM-dd HH:mm:ss ZZZZ';
refreshCounter$ = new BehaviorSubject(0);
isRefreshing$: Observable<boolean>;
isDeleting: boolean;
protected autoRefreshingID: string;
/**
* Reference to NgbModal
*/
protected modalRef: NgbModalRef;
private refreshTimerSub?: Subscription;
constructor(
@Inject(PLATFORM_ID) protected platformId: object,
protected route: ActivatedRoute,
@@ -108,69 +110,36 @@ export class ProcessDetailComponent implements OnInit, OnDestroy {
*/
ngOnInit(): void {
this.processRD$ = this.route.data.pipe(
map((data) => {
switchMap((data) => {
if (isPlatformBrowser(this.platformId)) {
if (!this.isProcessFinished(data.process.payload)) {
this.startRefreshTimer();
}
}
return data.process as RemoteData<Process>;
}),
redirectOn4xx(this.router, this.authService),
shareReplay(1)
);
this.filesRD$ = this.processRD$.pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((process: Process) => this.processService.getFiles(process.processId))
);
}
refresh() {
this.processRD$ = this.processService.findById(
this.route.snapshot.params.id,
false,
true,
followLink('script')
).pipe(
getFirstSucceededRemoteData(),
redirectOn4xx(this.router, this.authService),
tap((processRemoteData: RemoteData<Process>) => {
if (!this.isProcessFinished(processRemoteData.payload)) {
this.startRefreshTimer();
}
}),
shareReplay(1)
);
this.filesRD$ = this.processRD$.pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((process: Process) => this.processService.getFiles(process.processId))
);
}
startRefreshTimer() {
this.refreshCounter$.next(0);
this.refreshTimerSub = interval(1000).subscribe(
value => {
if (value > 5) {
setTimeout(() => {
this.refresh();
this.stopRefreshTimer();
this.refreshCounter$.next(0);
}, 1);
this.autoRefreshingID = this.route.snapshot.params.id;
return this.processService.autoRefreshUntilCompletion(this.autoRefreshingID, 5000, ...PROCESS_PAGE_FOLLOW_LINKS);
} else {
this.refreshCounter$.next(5 - value);
return [data.process as RemoteData<Process>];
}
});
}),
filter(() => !this.isDeleting),
redirectOn4xx(this.router, this.authService),
);
this.isRefreshing$ = this.processRD$.pipe(
find((processRD: RemoteData<Process>) => ProcessDataService.hasCompletedOrFailed(processRD.payload)),
map(() => false),
startWith(true)
);
this.filesRD$ = this.processRD$.pipe(
getAllSucceededRemoteDataPayload(),
switchMap((process: Process) => process.files),
);
}
stopRefreshTimer() {
if (hasValue(this.refreshTimerSub)) {
this.refreshTimerSub.unsubscribe();
this.refreshTimerSub = undefined;
/**
* Make sure the autoRefreshUntilCompletion is cleaned up properly
*/
ngOnDestroy() {
if (hasValue(this.autoRefreshingID)) {
this.processService.stopAutoRefreshing(this.autoRefreshingID);
}
}
@@ -249,15 +218,17 @@ export class ProcessDetailComponent implements OnInit, OnDestroy {
* @param process
*/
deleteProcess(process: Process) {
this.isDeleting = true;
this.processService.delete(process.processId).pipe(
getFirstCompletedRemoteData()
).subscribe((rd) => {
if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get('process.detail.delete.success'));
this.closeModal();
this.router.navigateByUrl(getProcessListRoute());
void this.router.navigateByUrl(getProcessListRoute());
} else {
this.notificationsService.error(this.translateService.get('process.detail.delete.error'));
this.isDeleting = false;
}
});
}
@@ -276,8 +247,4 @@ export class ProcessDetailComponent implements OnInit, OnDestroy {
closeModal() {
this.modalRef.close();
}
ngOnDestroy(): void {
this.stopRefreshTimer();
}
}

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

@@ -6,8 +6,9 @@ import { Process } from './processes/process.model';
import { followLink } from '../shared/utils/follow-link-config.model';
import { ProcessDataService } from '../core/data/processes/process-data.service';
import { BreadcrumbConfig } from '../breadcrumbs/breadcrumb/breadcrumb-config.model';
import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../core/shared/operators';
import { getFirstCompletedRemoteData } from '../core/shared/operators';
import { ProcessBreadcrumbsService } from './process-breadcrumbs.service';
import { RemoteData } from '../core/data/remote-data';
/**
* This class represents a resolver that requests a specific process before the route is activated
@@ -28,12 +29,11 @@ export class ProcessBreadcrumbResolver implements Resolve<BreadcrumbConfig<Proce
const id = route.params.id;
return this.processService.findById(route.params.id, true, false, followLink('script')).pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
map((object: Process) => {
getFirstCompletedRemoteData(),
map((object: RemoteData<Process>) => {
const fullPath = state.url;
const url = fullPath.substr(0, fullPath.indexOf(id)) + id;
return { provider: this.breadcrumbService, key: object, url: url };
return { provider: this.breadcrumbService, key: object.payload, url: url };
})
);
}

View File

@@ -3,6 +3,7 @@ import { Injectable } from '@angular/core';
import { BreadcrumbsProviderService } from '../core/breadcrumbs/breadcrumbsProviderService';
import { Breadcrumb } from '../breadcrumbs/breadcrumb/breadcrumb.model';
import { Process } from './processes/process.model';
import { hasValue } from '../shared/empty.util';
/**
* Service to calculate process breadcrumbs for a single part of the route
@@ -16,6 +17,10 @@ export class ProcessBreadcrumbsService implements BreadcrumbsProviderService<Pro
* @param url The url to use as a link for this breadcrumb
*/
getBreadcrumbs(key: Process, url: string): Observable<Breadcrumb[]> {
return observableOf([new Breadcrumb(key.processId + ' - ' + key.scriptName, url)]);
if (hasValue(key)) {
return observableOf([new Breadcrumb(key.processId + ' - ' + key.scriptName, url)]);
} else {
return observableOf([]);
}
}
}

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

@@ -7,6 +7,10 @@ import { followLink } from '../shared/utils/follow-link-config.model';
import { ProcessDataService } from '../core/data/processes/process-data.service';
import { getFirstCompletedRemoteData } from '../core/shared/operators';
export const PROCESS_PAGE_FOLLOW_LINKS = [
followLink('files'),
];
/**
* This class represents a resolver that requests a specific process before the route is activated
*/
@@ -23,7 +27,7 @@ export class ProcessPageResolver implements Resolve<RemoteData<Process>> {
* or an error if something went wrong
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Process>> {
return this.processService.findById(route.params.id, false, true, followLink('script')).pipe(
return this.processService.findById(route.params.id, false, true, ...PROCESS_PAGE_FOLLOW_LINKS).pipe(
getFirstCompletedRemoteData(),
);
}

View File

@@ -0,0 +1,34 @@
import { typedObject } from '../../core/cache/builders/build-decorators';
import { excludeFromEquals } from '../../core/utilities/equals.decorators';
import { autoserialize } from 'cerialize';
import { ResourceType } from '../../core/shared/resource-type';
import { FILETYPES } from './filetypes.resource-type';
/**
* Object representing the file types of the {@link Bitstream}s of a {@link Process}
*/
@typedObject
export class Filetypes {
static type = FILETYPES;
/**
* The id of this {@link Filetypes}
*/
@autoserialize
id: string;
/**
* The values of this {@link Filetypes}
*/
@autoserialize
values: string[];
/**
* The object type
*/
@excludeFromEquals
@autoserialize
type: ResourceType;
}

View File

@@ -0,0 +1,8 @@
/**
* The resource type for {@link Filetypes}
*
* Needs to be in a separate file to prevent circular dependencies in webpack.
*/
import { ResourceType } from '../../core/shared/resource-type';
export const FILETYPES = new ResourceType('filetypes');

View File

@@ -2,8 +2,8 @@
* List of process statuses
*/
export enum ProcessStatus {
SCHEDULED,
RUNNING,
COMPLETED,
FAILED
SCHEDULED = 'SCHEDULED',
RUNNING = 'RUNNING',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED'
}

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';
@@ -13,6 +13,10 @@ import { RemoteData } from '../../core/data/remote-data';
import { SCRIPT } from '../scripts/script.resource-type';
import { Script } from '../scripts/script.model';
import { CacheableObject } from '../../core/cache/cacheable-object.model';
import { BITSTREAM } from '../../core/shared/bitstream.resource-type';
import { PaginatedList } from '../../core/data/paginated-list.model';
import { Filetypes } from './filetypes.model';
import { FILETYPES } from './filetypes.resource-type';
/**
* Object representing a process
@@ -31,7 +35,7 @@ export class Process implements CacheableObject {
/**
* The identifier for this process
*/
@autoserialize
@autoserializeAs(String)
processId: string;
/**
@@ -40,6 +44,12 @@ export class Process implements CacheableObject {
@autoserialize
userId: string;
/**
* The creation time for this process
*/
@autoserialize
creationTime: string;
/**
* The start time for this process
*/
@@ -78,7 +88,8 @@ export class Process implements CacheableObject {
self: HALLink,
script: HALLink,
output: HALLink,
files: HALLink
files: HALLink,
filetypes: HALLink,
};
/**
@@ -94,4 +105,19 @@ export class Process implements CacheableObject {
*/
@link(PROCESS_OUTPUT_TYPE)
output?: Observable<RemoteData<Bitstream>>;
/**
* The files created by this Process
* Will be undefined unless the output {@link HALLink} has been resolved.
*/
@link(BITSTREAM, true)
files?: Observable<RemoteData<PaginatedList<Bitstream>>>;
/**
* The filetypes present in this Process
* Will be undefined unless the output {@link HALLink} has been resolved.
*/
@link(FILETYPES)
filetypes?: Observable<RemoteData<Filetypes>>;
}

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

@@ -157,11 +157,20 @@ export class SearchFilterComponent implements OnInit {
}
get regionId(): string {
return `search-filter-region-${this.sequenceId}`;
if (this.inPlaceSearch) {
return `search-filter-region-${this.sequenceId}`;
} else {
return `search-filter-region-home-${this.sequenceId}`;
}
}
get toggleId(): string {
return `search-filter-toggle-${this.sequenceId}`;
if (this.inPlaceSearch) {
return `search-filter-toggle-${this.sequenceId}`;
} else {
return `search-filter-toggle-home-${this.sequenceId}`;
}
}
/**

View File

@@ -1,4 +1,5 @@
<h3>{{"search.filters.head" | translate}}</h3>
<h3 *ngIf="inPlaceSearch">{{filterLabel+'.filters.head' | translate}}</h3>
<h2 *ngIf="!inPlaceSearch">{{filterLabel+'.filters.head' | translate}}</h2>
<div *ngIf="(filters | async)?.hasSucceeded">
<div *ngFor="let filter of (filters | async)?.payload; trackBy: trackUpdate">
<ds-search-filter [scope]="currentScope" [filter]="filter" [inPlaceSearch]="inPlaceSearch" [refreshFilters]="refreshFilters"></ds-search-filter>

View File

@@ -20,7 +20,8 @@ describe('SearchFiltersComponent', () => {
getClearFiltersQueryParams: () => {
},
getSearchLink: () => {
}
},
getConfigurationSearchConfig: () => { },
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */
};

View File

@@ -61,6 +61,7 @@ export class SearchFiltersComponent implements OnInit, OnDestroy {
searchLink: string;
subs = [];
filterLabel = 'search';
/**
* Initialize instance variables
@@ -77,6 +78,9 @@ export class SearchFiltersComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
if (!this.inPlaceSearch) {
this.filterLabel = 'discover';
}
this.clearParams = this.searchConfigService.getCurrentFrontendFilters().pipe(map((filters) => {
Object.keys(filters).forEach((f) => filters[f] = null);
return filters;

View File

@@ -23,7 +23,7 @@
[filters]="filters"
[refreshFilters]="refreshFilters"
[inPlaceSearch]="inPlaceSearch"></ds-themed-search-filters>
<ds-themed-search-settings [currentSortOption]="currentSortOption"
<ds-themed-search-settings *ngIf="inPlaceSearch" [currentSortOption]="currentSortOption"
[sortOptionsList]="sortOptionsList"></ds-themed-search-settings>
</div>
</div>

View File

@@ -10,8 +10,9 @@
<ng-template *ngTemplateOutlet="searchContent"></ng-template>
</div>
<ds-page-with-sidebar *ngIf="showSidebar && (initialized$ | async)" [id]="'search-page'" [sidebarContent]="sidebarContent">
<ng-template *ngTemplateOutlet="searchContent"></ng-template>
<ds-page-with-sidebar *ngIf="showSidebar && (initialized$ | async)" [id]="'search-page'" [sideBarWidth]="sideBarWidth"
[sidebarContent]="sidebarContent">
<ng-template *ngTemplateOutlet="searchContent"></ng-template>
</ds-page-with-sidebar>
<ng-template #searchContent>
@@ -22,15 +23,15 @@
</div>
<div id="search-content" class="col-12">
<div class="d-block d-md-none search-controls clearfix">
<ds-view-mode-switch [viewModeList]="viewModeList" [inPlaceSearch]="inPlaceSearch"></ds-view-mode-switch>
<button [attr.aria-label]="'search.sidebar.open' | translate" (click)="openSidebar()"
aria-controls="search-sidebar-content"
<ds-view-mode-switch *ngIf="inPlaceSearch" [viewModeList]="viewModeList"
[inPlaceSearch]="inPlaceSearch"></ds-view-mode-switch>
<button (click)="openSidebar()" aria-controls="#search-body"
class="btn btn-outline-primary float-right open-sidebar"><i
class="fas fa-sliders"></i> {{"search.sidebar.open"
| translate}}
</button>
</div>
<ds-themed-search-results [searchResults]="resultsRD$ | async"
<ds-themed-search-results *ngIf="inPlaceSearch" [searchResults]="resultsRD$ | async"
[searchConfig]="searchOptions$ | async"
[configuration]="(currentConfiguration$ | async)"
[disableHeader]="!searchEnabled"

View File

@@ -34,7 +34,6 @@ import { ThemedSearchSettingsComponent } from './search-settings/themed-search-s
import { NouisliderModule } from 'ng2-nouislider';
import { ThemedSearchFiltersComponent } from './search-filters/themed-search-filters.component';
import { ThemedSearchSidebarComponent } from './search-sidebar/themed-search-sidebar.component';
const COMPONENTS = [
SearchComponent,
ThemedSearchComponent,

View File

@@ -0,0 +1,31 @@
import { Observable, of as observableOf } from 'rxjs';
import { CacheableObject } from '../../core/cache/cacheable-object.model';
import { ObjectCacheEntry } from '../../core/cache/object-cache.reducer';
/* eslint-disable @typescript-eslint/no-empty-function */
/**
* Stub class of {@link ObjectCacheService}
*/
export class ObjectCacheServiceStub {
add(_object: CacheableObject, _msToLive: number, _requestUUID: string, _alternativeLink?: string): void {
}
remove(_href: string): void {
}
getByHref(_href: string): Observable<ObjectCacheEntry> {
return observableOf(undefined);
}
hasByHref$(_href: string): Observable<boolean> {
return observableOf(false);
}
addDependency(_href$: string | Observable<string>, _dependsOnHref$: string | Observable<string>): void {
}
removeDependents(_href: string): void {
}
}

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

@@ -2152,6 +2152,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",
@@ -2206,6 +2208,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",
@@ -2440,6 +2448,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",
@@ -3472,12 +3498,32 @@
"process.detail.delete.error": "Something went wrong when deleting the process",
"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",
@@ -5582,6 +5628,8 @@
"admin.system-wide-alert.title": "System-wide Alerts",
"discover.filters.head": "Discover",
"item-access-control-title": "This form allows you to perform changes to the access conditions of the item's metadata or its bitstreams.",
"collection-access-control-title": "This form allows you to perform changes to the access conditions of all the items owned by this collection. Changes may be performed to either all Item metadata or all content (bitstreams).",
@@ -5962,3 +6010,4 @@
"type-equals-journal-article_condition.label": "Type equals Journal Article",
}

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'
}
]
@@ -274,7 +306,8 @@ export class DefaultAppConfig implements AppConfig {
},
topLevelCommunityList: {
pageSize: 5
}
},
showDiscoverFilters: false
};
// Item Config

View File

@@ -1,7 +1,7 @@
import { Config } from './config.interface';
/**
* Config that determines how the dropdown list of years are created for browse-by-date components
* Config that determines how the recentSubmissions list showing at home page
*/
export interface HomeConfig extends Config {
recentSubmissions: {
@@ -19,4 +19,8 @@ export interface HomeConfig extends Config {
topLevelCommunityList: {
pageSize: number;
};
/*
* Enable or disable the Discover filters on the homepage
*/
showDiscoverFilters: boolean;
}

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'
},
]
}
@@ -242,7 +246,8 @@ export const environment: BuildConfig = {
},
topLevelCommunityList: {
pageSize: 5
}
},
showDiscoverFilters: false
},
item: {
edit: {

View File

@@ -109,4 +109,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;
}