From e1b80bcbafc6ce71d1dbdaecc601452e2739761e Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Tue, 23 Jun 2020 18:12:02 +0200 Subject: [PATCH 001/126] [CST-3088] changed buildHrefFromFindOptions in the way to use search params while building url --- src/app/core/data/data.service.spec.ts | 24 +++++++++++++++++++++++- src/app/core/data/data.service.ts | 19 ++++++++++++++----- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index a99fc54269..31013c5132 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -18,6 +18,7 @@ import { FindListOptions, PatchRequest } from './request.models'; import { RequestService } from './request.service'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { RequestParam } from '../cache/models/request-param.model'; const endpoint = 'https://rest.api/core'; @@ -150,7 +151,8 @@ describe('DataService', () => { currentPage: 6, elementsPerPage: 10, sort: sortOptions, - startsWith: 'ab' + startsWith: 'ab', + }; const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` + `&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`; @@ -160,6 +162,26 @@ describe('DataService', () => { }); }); + it('should include all searchParams in href if any provided in options', () => { + options = { searchParams: [ + new RequestParam('param1', 'test'), + new RequestParam('param2', 'test2'), + ] }; + const expected = `${endpoint}?param1=test¶m2=test2`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include linkPath in href if any provided', () => { + const expected = `${endpoint}/test/entries`; + + (service as any).getFindAllHref({}, 'test/entries').subscribe((value) => { + expect(value).toBe(expected); + }); + }); + it('should include single linksToFollow as embed', () => { const expected = `${endpoint}?embed=bundles`; diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index ca59daa5af..d7e24892ef 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -86,13 +86,17 @@ export abstract class DataService { * Return an observable that emits created HREF * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - protected getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array>): Observable { - let result$: Observable; + public getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array>): Observable { + let endpoint$: Observable; const args = []; - result$ = this.getBrowseEndpoint(options, linkPath).pipe(distinctUntilChanged()); + endpoint$ = this.getBrowseEndpoint(options).pipe( + filter((href: string) => isNotEmpty(href)), + map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href), + distinctUntilChanged() + ); - return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); + return endpoint$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); } /** @@ -104,7 +108,7 @@ export abstract class DataService { * Return an observable that emits created HREF * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - protected getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable { + public getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable { let result$: Observable; const args = []; @@ -145,6 +149,11 @@ export abstract class DataService { if (hasValue(options.startsWith)) { args = [...args, `startsWith=${options.startsWith}`]; } + if (hasValue(options.searchParams)) { + options.searchParams.forEach((param: RequestParam) => { + args = [...args, `${param.fieldName}=${param.fieldValue}`]; + }) + } args = this.addEmbedParams(args, ...linksToFollow); if (isNotEmpty(args)) { return new URLCombiner(href, `?${args.join('&')}`).toString(); From 1156bd3934932858da65f25a2dd902401cb5852d Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Tue, 23 Jun 2020 18:17:47 +0200 Subject: [PATCH 002/126] [CST-3088] Added vocabulary service and models --- src/app/core/core.module.ts | 28 +- src/app/core/data/request.models.ts | 10 + .../models/vocabularies.resource-type.ts | 11 + .../models/vocabulary-entry.model.ts | 103 +++++++ .../vocabularies/models/vocabulary.model.ts | 61 ++++ ...y-entries-response-parsing.service.spec.ts | 111 ++++++++ ...bulary-entries-response-parsing.service.ts | 20 ++ .../vocabularies/vocabulary.service.spec.ts | 269 ++++++++++++++++++ .../vocabularies/vocabulary.service.ts | 194 +++++++++++++ 9 files changed, 796 insertions(+), 11 deletions(-) create mode 100644 src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts create mode 100644 src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts create mode 100644 src/app/core/submission/vocabularies/models/vocabulary.model.ts create mode 100644 src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts create mode 100644 src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.ts create mode 100644 src/app/core/submission/vocabularies/vocabulary.service.spec.ts create mode 100644 src/app/core/submission/vocabularies/vocabulary.service.ts diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 715f7a5cc0..a92b52b339 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -16,8 +16,8 @@ import { MenuService } from '../shared/menu/menu.service'; import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service'; import { MOCK_RESPONSE_MAP, - ResponseMapMock, - mockResponseMap + mockResponseMap, + ResponseMapMock } from '../shared/mocks/dspace-rest-v2/mocks/response-map.mock'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { SelectableListService } from '../shared/object-list/selectable-list/selectable-list.service'; @@ -145,6 +145,9 @@ import { Version } from './shared/version.model'; import { VersionHistory } from './shared/version-history.model'; import { WorkflowActionDataService } from './data/workflow-action-data.service'; import { WorkflowAction } from './tasks/models/workflow-action-object.model'; +import { VocabularyEntry } from './submission/vocabularies/models/vocabulary-entry.model'; +import { Vocabulary } from './submission/vocabularies/models/vocabulary.model'; +import { VocabularyEntriesResponseParsingService } from './submission/vocabularies/vocabulary-entries-response-parsing.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -178,7 +181,7 @@ const PROVIDERS = [ SiteDataService, DSOResponseParsingService, { provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap }, - { provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient]}, + { provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient] }, DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService, @@ -272,7 +275,8 @@ const PROVIDERS = [ }, NotificationsService, FilteredDiscoveryPageResponseParsingService, - { provide: NativeWindowService, useFactory: NativeWindowFactory } + { provide: NativeWindowService, useFactory: NativeWindowFactory }, + VocabularyEntriesResponseParsingService ]; /** @@ -314,7 +318,9 @@ export const models = ExternalSourceEntry, Version, VersionHistory, - WorkflowAction + WorkflowAction, + Vocabulary, + VocabularyEntry ]; @NgModule({ @@ -333,6 +339,12 @@ export const models = }) export class CoreModule { + constructor(@Optional() @SkipSelf() parentModule: CoreModule) { + if (isNotEmpty(parentModule)) { + throw new Error('CoreModule is already loaded. Import it in the AppModule only'); + } + } + static forRoot(): ModuleWithProviders { return { ngModule: CoreModule, @@ -341,10 +353,4 @@ export class CoreModule { ] }; } - - constructor(@Optional() @SkipSelf() parentModule: CoreModule) { - if (isNotEmpty(parentModule)) { - throw new Error('CoreModule is already loaded. Import it in the AppModule only'); - } - } } diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 5866cce797..c9fe1fe0ce 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -20,6 +20,7 @@ import { URLCombiner } from '../url-combiner/url-combiner'; import { TaskResponseParsingService } from '../tasks/task-response-parsing.service'; import { ContentSourceResponseParsingService } from './content-source-response-parsing.service'; import { MappedCollectionsReponseParsingService } from './mapped-collections-reponse-parsing.service'; +import { VocabularyEntriesResponseParsingService } from '../submission/vocabularies/vocabulary-entries-response-parsing.service'; /* tslint:disable:max-classes-per-file */ @@ -442,6 +443,15 @@ export class MyDSpaceRequest extends GetRequest { public responseMsToLive = 10 * 1000; } +/** + * Request to get vocabulary entries + */ +export class VocabularyEntriesRequest extends FindListRequest { + getResponseParser(): GenericConstructor { + return VocabularyEntriesResponseParsingService; + } +} + export class RequestError extends Error { statusCode: number; statusText: string; diff --git a/src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts b/src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts new file mode 100644 index 0000000000..4d49abd823 --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts @@ -0,0 +1,11 @@ +import { ResourceType } from '../../../shared/resource-type'; + +/** + * The resource type for vocabulary models + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ + +export const VOCABULARY = new ResourceType('vocabulary'); +export const VOCABULARY_ENTRY = new ResourceType('vocabularyEntry'); diff --git a/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts new file mode 100644 index 0000000000..5fe951c8aa --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts @@ -0,0 +1,103 @@ +import { autoserialize, deserialize } from 'cerialize'; + +import { HALLink } from '../../../shared/hal-link.model'; +import { VOCABULARY_ENTRY } from './vocabularies.resource-type'; +import { typedObject } from '../../../cache/builders/build-decorators'; +import { excludeFromEquals } from '../../../utilities/equals.decorators'; +import { PLACEHOLDER_PARENT_METADATA } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; +import { OtherInformation } from '../../../../shared/form/builder/models/form-field-metadata-value.model'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { ListableObject } from '../../../../shared/object-collection/shared/listable-object.model'; +import { GenericConstructor } from '../../../shared/generic-constructor'; + +/** + * Model class for a Vocabulary + */ +@typedObject +export class VocabularyEntry extends ListableObject { + static type = VOCABULARY_ENTRY; + + /** + * The identifier of this vocabulary entry + */ + @autoserialize + authority: string; + + /** + * The display value of this vocabulary entry + */ + @autoserialize + display: string; + + /** + * The value of this vocabulary entry + */ + @autoserialize + value: string; + + /** + * An object containing additional information related to this vocabulary entry + */ + @autoserialize + otherInformation: OtherInformation; + + /** + * A string representing the kind of vocabulary entry + */ + @excludeFromEquals + @autoserialize + public type: any; + + /** + * The {@link HALLink}s for this ExternalSourceEntry + */ + @deserialize + _links: { + self: HALLink; + vocabularyEntryDetail: HALLink; + }; + + /** + * This method checks if entry has an authority value + * + * @return boolean + */ + hasAuthority(): boolean { + return isNotEmpty(this.authority); + } + + /** + * This method checks if entry has a value + * + * @return boolean + */ + hasValue(): boolean { + return isNotEmpty(this.value); + } + + /** + * This method checks if entry has related information object + * + * @return boolean + */ + hasOtherInformation(): boolean { + return isNotEmpty(this.otherInformation); + } + + /** + * This method checks if entry has a placeholder as value + * + * @return boolean + */ + hasPlaceholder(): boolean { + return this.hasValue() && this.value === PLACEHOLDER_PARENT_METADATA; + } + + /** + * Method that returns as which type of object this object should be rendered + */ + getRenderTypes(): Array> { + return [this.constructor as GenericConstructor]; + } + +} diff --git a/src/app/core/submission/vocabularies/models/vocabulary.model.ts b/src/app/core/submission/vocabularies/models/vocabulary.model.ts new file mode 100644 index 0000000000..8672d1c6ed --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabulary.model.ts @@ -0,0 +1,61 @@ +import { autoserialize, deserialize } from 'cerialize'; + +import { HALLink } from '../../../shared/hal-link.model'; +import { VOCABULARY } from './vocabularies.resource-type'; +import { CacheableObject } from '../../../cache/object-cache.reducer'; +import { typedObject } from '../../../cache/builders/build-decorators'; +import { excludeFromEquals } from '../../../utilities/equals.decorators'; + +/** + * Model class for a Vocabulary + */ +@typedObject +export class Vocabulary implements CacheableObject { + static type = VOCABULARY; + /** + * The identifier of this Vocabulary + */ + @autoserialize + id: string; + + /** + * The name of this Vocabulary + */ + @autoserialize + name: string; + + /** + * True if it is possible to scroll all the entries in the vocabulary without providing a filter parameter + */ + @autoserialize + scrollable: boolean; + + /** + * True if the vocabulary exposes a tree structure where some entries are parent of others + */ + @autoserialize + hierarchical: boolean; + + /** + * For hierarchical vocabularies express the preference to preload the tree at a specific + * level of depth (0 only the top nodes are shown, 1 also their children are preloaded and so on) + */ + @autoserialize + preloadLevel: any; + + /** + * A string representing the kind of Vocabulary model + */ + @excludeFromEquals + @autoserialize + public type: any; + + /** + * The {@link HALLink}s for this Vocabulary + */ + @deserialize + _links: { + self: HALLink, + entries: HALLink + }; +} diff --git a/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts new file mode 100644 index 0000000000..592ed234a7 --- /dev/null +++ b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts @@ -0,0 +1,111 @@ +import { getMockObjectCacheService } from '../../../shared/mocks/object-cache.service.mock'; +import { ErrorResponse, GenericSuccessResponse } from '../../cache/response.models'; +import { DSpaceRESTV2Response } from '../../dspace-rest-v2/dspace-rest-v2-response.model'; +import { VocabularyEntriesResponseParsingService } from './vocabulary-entries-response-parsing.service'; +import { VocabularyEntriesRequest } from '../../data/request.models'; + +fdescribe('VocabularyEntriesResponseParsingService', () => { + let service: VocabularyEntriesResponseParsingService; + const metadata = 'dc.type'; + const collectionUUID = '8b39g7ya-5a4b-438b-851f-be1d5b4a1c5a'; + const entriesRequestURL = `https://rest.api/rest/api/submission/vocabularies/types/entries?metadata=${metadata}&collection=${collectionUUID}` + + beforeEach(() => { + service = new VocabularyEntriesResponseParsingService(getMockObjectCacheService()); + }); + + describe('parse', () => { + const request = new VocabularyEntriesRequest('client/f5b4ccb8-fbb0-4548-b558-f234d9fdfad6', entriesRequestURL); + + const validResponse = { + payload: { + _embedded: { + entries: [ + { + display: 'testValue1', + value: 'testValue1', + otherInformation: {}, + type: 'vocabularyEntry' + }, + { + display: 'testValue2', + value: 'testValue2', + otherInformation: {}, + type: 'vocabularyEntry' + }, + { + display: 'testValue3', + value: 'testValue3', + otherInformation: {}, + type: 'vocabularyEntry' + }, + { + authority: 'authorityId1', + display: 'testValue1', + value: 'testValue1', + otherInformation: { + id: 'VR131402', + parent: 'Research Subject Categories::SOCIAL SCIENCES::Social sciences::Social work', + hasChildren: 'false', + note: 'Familjeforskning' + }, + type: 'vocabularyEntry', + _links: { + vocabularyEntryDetail: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:VR131402' + } + } + } + ] + }, + _links: { + first: { + href: 'https://rest.api/discover/browses/author/entries?page=0&size=5' + }, + self: { + href: 'https://rest.api/discover/browses/author/entries' + }, + next: { + href: 'https://rest.api/discover/browses/author/entries?page=1&size=5' + }, + last: { + href: 'https://rest.api/discover/browses/author/entries?page=9&size=5' + } + }, + page: { + size: 5, + totalElements: 50, + totalPages: 10, + number: 0 + } + }, + statusCode: 200, + statusText: 'OK' + } as DSpaceRESTV2Response; + + const invalidResponseNotAList = { + statusCode: 200, + statusText: 'OK' + } as DSpaceRESTV2Response; + + const invalidResponseStatusCode = { + payload: {}, statusCode: 500, statusText: 'Internal Server Error' + } as DSpaceRESTV2Response; + + it('should return a GenericSuccessResponse if data contains a valid browse entries response', () => { + const response = service.parse(request, validResponse); + expect(response.constructor).toBe(GenericSuccessResponse); + }); + + it('should return an ErrorResponse if data contains an invalid browse entries response', () => { + const response = service.parse(request, invalidResponseNotAList); + expect(response.constructor).toBe(ErrorResponse); + }); + + it('should return an ErrorResponse if data contains a statuscode other than 200', () => { + const response = service.parse(request, invalidResponseStatusCode); + expect(response.constructor).toBe(ErrorResponse); + }); + + }); +}); diff --git a/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.ts b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.ts new file mode 100644 index 0000000000..680b949733 --- /dev/null +++ b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; + +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { BrowseEntriesResponseParsingService } from '../../data/browse-entries-response-parsing.service'; + +/** + * A service responsible for parsing data for a vocabulary entries response + */ +@Injectable() +export class VocabularyEntriesResponseParsingService extends BrowseEntriesResponseParsingService { + + protected toCache = false; + + constructor( + protected objectCache: ObjectCacheService, + ) { + super(objectCache); + } + +} diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts new file mode 100644 index 0000000000..2596e368b5 --- /dev/null +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -0,0 +1,269 @@ +import { HttpClient } from '@angular/common/http'; + +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { NotificationsService } from '../../../shared/notifications/notifications.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 { RequestService } from '../../data/request.service'; +import { FindListOptions, VocabularyEntriesRequest } from '../../data/request.models'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { PageInfo } from '../../shared/page-info.model'; +import { PaginatedList } from '../../data/paginated-list'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { RequestEntry } from '../../data/request.reducer'; +import { RestResponse } from '../../cache/response.models'; +import { VocabularyService } from './vocabulary.service'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; + +describe('VocabularyService', () => { + let scheduler: TestScheduler; + let service: VocabularyService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let responseCacheEntry: RequestEntry; + + const vocabulary: any = { + id: 'types', + name: 'types', + scrollable: true, + hierarchical: false, + preloadLevel: 1, + type: 'vocabulary', + uuid: 'vocabulary-types', + _links: { + self: { + href: 'https://rest.api/rest/api/submission/vocabularies/types' + }, + entries: { + href: 'https://rest.api/rest/api/submission/vocabularies/types/entries' + }, + } + }; + + const anotherVocabulary: any = { + id: 'srsc', + name: 'srsc', + scrollable: false, + hierarchical: true, + preloadLevel: 2, + type: 'vocabulary', + uuid: 'vocabulary-srsc', + _links: { + self: { + href: 'https://rest.api/rest/api/submission/vocabularies/types' + }, + entries: { + href: 'https://rest.api/rest/api/submission/vocabularies/types/entries' + }, + } + }; + + const vocabularyEntry: any = { + display: 'testValue1', + value: 'testValue1', + otherInformation: {}, + type: 'vocabularyEntry' + }; + + const vocabularyEntryWithAuthority: any = { + authority: 'authorityId1', + display: 'testValue1', + value: 'testValue1', + otherInformation: { + id: 'VR131402', + parent: 'Research Subject Categories::SOCIAL SCIENCES::Social sciences::Social work', + hasChildren: 'false', + note: 'Familjeforskning' + }, + type: 'vocabularyEntry', + _links: { + vocabularyEntryDetail: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:VR131402' + } + } + }; + const endpointURL = `https://rest.api/rest/api/submission/vocabularies`; + const requestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const vocabularyId = 'types'; + const metadata = 'dc.type'; + const collectionUUID = '8b39g7ya-5a4b-438b-851f-be1d5b4a1c5a'; + const searchRequestURL = `https://rest.api/rest/api/submission/vocabularies/search/byMetadataAndCollection?metadata=${metadata}&collection=${collectionUUID}`; + const entriesRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?metadata=${metadata}&collection=${collectionUUID}`; + + const pageInfo = new PageInfo(); + const array = [vocabulary, anotherVocabulary]; + const paginatedList = new PaginatedList(pageInfo, array); + const vocabularyRD = createSuccessfulRemoteDataObject(vocabulary); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + const getRequestEntry$ = (successful: boolean) => { + return observableOf({ + response: { isSuccessful: successful, payload: vocabulary } as any + } as RequestEntry) + }; + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + + function initTestService() { + return new VocabularyService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + configure: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('a|', { + a: vocabularyRD + }), + buildList: hot('a|', { + a: paginatedListRD + }), + }); + + service = initTestService(); + + spyOn((service as any).dataService, 'findById').and.callThrough(); + spyOn((service as any).dataService, 'findAll').and.callThrough(); + spyOn((service as any).dataService, 'findByHref').and.callThrough(); + spyOn((service as any).dataService, 'searchBy').and.callThrough(); + spyOn((service as any).dataService, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL)); + spyOn((service as any).dataService, 'getFindAllHref').and.returnValue(observableOf(entriesRequestURL)); + }); + + afterEach(() => { + service = null; + }); + + describe('findById', () => { + it('should proxy the call to dataservice.findById', () => { + scheduler.schedule(() => service.findById(vocabularyId)); + scheduler.flush(); + + expect((service as any).dataService.findById).toHaveBeenCalledWith(vocabularyId); + }); + + it('should return a RemoteData for the object with the given id', () => { + const result = service.findById(vocabularyId); + const expected = cold('a|', { + a: vocabularyRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('findByHref', () => { + it('should proxy the call to dataservice.findByHref', () => { + scheduler.schedule(() => service.findByHref(requestURL)); + scheduler.flush(); + + expect((service as any).dataService.findByHref).toHaveBeenCalledWith(requestURL); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.findByHref(requestURL); + const expected = cold('a|', { + a: vocabularyRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('findAll', () => { + it('should proxy the call to dataservice.findAll', () => { + scheduler.schedule(() => service.findAll()); + scheduler.flush(); + + expect((service as any).dataService.findAll).toHaveBeenCalled(); + }); + + it('should return a RemoteData>', () => { + const result = service.findAll(); + const expected = cold('a|', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('searchByMetadataAndCollection', () => { + it('should proxy the call to dataservice.findByHref', () => { + const options = new FindListOptions(); + options.searchParams = [ + new RequestParam('metadata', metadata), + new RequestParam('collection', collectionUUID) + ]; + scheduler.schedule(() => service.searchByMetadataAndCollection(metadata, collectionUUID).subscribe()); + scheduler.flush(); + + expect((service as any).dataService.findByHref).toHaveBeenCalledWith(searchRequestURL); + }); + + it('should return a RemoteData for the search', () => { + const result = service.searchByMetadataAndCollection(metadata, collectionUUID); + const expected = cold('a|', { + a: vocabularyRD + }); + expect(result).toBeObservable(expected); + }); + + }); + + describe('getVocabularyEntries', () => { + + beforeEach(() => { + requestService = getMockRequestService(getRequestEntry$(true)); + rdbService = getMockRemoteDataBuildService(); + spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); + service = initTestService(); + }); + + it('should configure a new VocabularyEntriesRequest', () => { + const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entriesRequestURL); + + scheduler.schedule(() => service.getVocabularyEntries(vocabularyId, metadata, collectionUUID).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + service.getVocabularyEntries(vocabularyId, metadata, collectionUUID); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + + }); + + }); + +}); diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts new file mode 100644 index 0000000000..cfb795c942 --- /dev/null +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -0,0 +1,194 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { distinctUntilChanged, first, flatMap, map } from 'rxjs/operators'; + +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { dataService } from '../../cache/builders/build-decorators'; +import { DataService } from '../../data/data.service'; +import { RequestService } from '../../data/request.service'; +import { FindListOptions, RestRequest, VocabularyEntriesRequest } from '../../data/request.models'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RemoteData } from '../../data/remote-data'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CoreState } from '../../core.reducers'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ChangeAnalyzer } from '../../data/change-analyzer'; +import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; +import { PaginatedList } from '../../data/paginated-list'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { Vocabulary } from './models/vocabulary.model'; +import { VOCABULARY } from './models/vocabularies.resource-type'; +import { VocabularyEntry } from './models/vocabulary-entry.model'; +import { hasValue, isNotEmptyOperator } from '../../../shared/empty.util'; +import { configureRequest, filterSuccessfulResponses, getRequestFromRequestHref } from '../../shared/operators'; +import { GenericSuccessResponse } from '../../cache/response.models'; + +/* tslint:disable:max-classes-per-file */ + +/** + * A private DataService implementation to delegate specific methods to. + */ +class DataServiceImpl extends DataService { + protected linkPath = 'vocabularies'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: ChangeAnalyzer) { + super(); + } + +} + +/** + * A service responsible for fetching/sending data from/to the REST API on the vocabularies endpoint + */ +@Injectable({ + providedIn: 'root' +}) +@dataService(VOCABULARY) +export class VocabularyService { + protected searchByMetadataAndCollectionMethod = 'byMetadataAndCollection'; + private dataService: DataServiceImpl; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + } + + /** + * Returns an observable of {@link RemoteData} of a {@link Vocabulary}, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link Vocabulary} + * @param href The url of {@link Vocabulary} we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits vocabulary object + */ + findByHref(href: string, ...linksToFollow: Array>): Observable> { + return this.dataService.findByHref(href, ...linksToFollow); + } + + /** + * Returns an observable of {@link RemoteData} of a {@link Vocabulary}, based on its ID, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the object + * @param id ID of {@link Vocabulary} we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits vocabulary object + */ + findById(id: string, ...linksToFollow: Array>): Observable> { + return this.dataService.findById(id, ...linksToFollow); + } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + return this.dataService.findAll(options, ...linksToFollow); + } + + /** + * Return the {@link VocabularyEntry} list for a given {@link Vocabulary} + * + * @param id The vocabulary id to retrieve the entries for + * @param metadata The metadata name + * @param collectionUUID The collection UUID + * @param options The {@link FindListOptions} for the request + * @return {Observable>>} + * Return an observable that emits object list + */ + getVocabularyEntries(id: string, metadata: string, collectionUUID: string, options: FindListOptions = {}): Observable>> { + options = Object.assign({}, options, { + searchParams: [ + new RequestParam('metadata', metadata), + new RequestParam('collection', collectionUUID) + ] + }); + + return this.dataService.getFindAllHref(options, `${id}/entries`).pipe( + isNotEmptyOperator(), + distinctUntilChanged(), + getVocabularyEntriesFor(this.requestService, this.rdbService) + ); + } + + /** + * Return the controlled {@link Vocabulary} configured for the specified metadata and collection if any. + * + * @param metadata The metadata name + * @param collectionUUID The collection UUID + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + searchByMetadataAndCollection(metadata: string, collectionUUID: string, ...linksToFollow: Array>): Observable> { + const options = new FindListOptions(); + options.searchParams = [ + new RequestParam('metadata', metadata), + new RequestParam('collection', collectionUUID) + ]; + + return this.dataService.getSearchByHref(this.searchByMetadataAndCollectionMethod, options).pipe( + first((href: string) => hasValue(href)), + flatMap((href: string) => this.dataService.findByHref(href)) + ) + } + +} + +/** + * Operator for turning a href into a PaginatedList of VocabularyEntry + * @param requestService + * @param rdb + */ +export const getVocabularyEntriesFor = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => + source.pipe( + map((href: string) => new VocabularyEntriesRequest(requestService.generateRequestId(), href)), + configureRequest(requestService), + toRDPaginatedVocabularyEntries(requestService, rdb) + ); + +/** + * Operator for turning a RestRequest into a PaginatedList of VocabularyEntry + * @param requestService + * @param rdb + */ +export const toRDPaginatedVocabularyEntries = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => { + const href$ = source.pipe(map((request: RestRequest) => request.href)); + + const requestEntry$ = href$.pipe(getRequestFromRequestHref(requestService)); + + const payload$ = requestEntry$.pipe( + filterSuccessfulResponses(), + map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), + map((list: PaginatedList) => Object.assign(list, { + page: list.page ? list.page.map((entry: VocabularyEntry) => Object.assign(new VocabularyEntry(), entry)) : list.page + })), + distinctUntilChanged() + ); + + return rdb.toRemoteDataObservable(requestEntry$, payload$); + }; From c7cf11f4214a42d0c6994967f2d97aee0dfb96db Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Wed, 24 Jun 2020 12:52:16 +0200 Subject: [PATCH 003/126] [CST-3088] Improved VocabularyFindOptions --- .../models/vocabulary-find-options.model.ts | 45 +++++++++++++++++++ ...y-entries-response-parsing.service.spec.ts | 2 +- .../vocabularies/vocabulary.service.spec.ts | 10 +++-- .../vocabularies/vocabulary.service.ts | 27 +++-------- 4 files changed, 58 insertions(+), 26 deletions(-) create mode 100644 src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts diff --git a/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts new file mode 100644 index 0000000000..18309ed1fe --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts @@ -0,0 +1,45 @@ +import { SortOptions } from '../../../cache/models/sort-options.model'; +import { FindListOptions } from '../../../data/request.models'; +import { RequestParam } from '../../../cache/models/request-param.model'; +import { isNotEmpty } from '../../../../shared/empty.util'; + +/** + * Representing properties used to build a vocabulary find request + */ +export class VocabularyFindOptions extends FindListOptions { + + constructor(public collection: string = '', + public name: string = '', + public metadata: string = '', + public query: string = '', + public elementsPerPage?: number, + public currentPage?: number, + public sort?: SortOptions, + public filter?: string, + public exact?: string, + public entryID?: string, + ) { + super(); + + const searchParams = []; + if (isNotEmpty(metadata)) { + searchParams.push(new RequestParam('metadata', metadata)) + } + if (isNotEmpty(collection)) { + searchParams.push(new RequestParam('collection', collection)) + } + if (isNotEmpty(query)) { + searchParams.push(new RequestParam('query', query)) + } + if (isNotEmpty(filter)) { + searchParams.push(new RequestParam('filter', filter)) + } + if (isNotEmpty(exact)) { + searchParams.push(new RequestParam('exact', exact)) + } + if (isNotEmpty(entryID)) { + searchParams.push(new RequestParam('entryID', entryID)) + } + this.searchParams = searchParams; + } +} diff --git a/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts index 592ed234a7..9f28ccea17 100644 --- a/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts @@ -4,7 +4,7 @@ import { DSpaceRESTV2Response } from '../../dspace-rest-v2/dspace-rest-v2-respon import { VocabularyEntriesResponseParsingService } from './vocabulary-entries-response-parsing.service'; import { VocabularyEntriesRequest } from '../../data/request.models'; -fdescribe('VocabularyEntriesResponseParsingService', () => { +describe('VocabularyEntriesResponseParsingService', () => { let service: VocabularyEntriesResponseParsingService; const metadata = 'dc.type'; const collectionUUID = '8b39g7ya-5a4b-438b-851f-be1d5b4a1c5a'; diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts index 2596e368b5..5323c29f30 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -19,6 +19,7 @@ import { RestResponse } from '../../cache/response.models'; import { VocabularyService } from './vocabulary.service'; import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { VocabularyFindOptions } from './models/vocabulary-find-options.model'; describe('VocabularyService', () => { let scheduler: TestScheduler; @@ -95,6 +96,7 @@ describe('VocabularyService', () => { const vocabularyId = 'types'; const metadata = 'dc.type'; const collectionUUID = '8b39g7ya-5a4b-438b-851f-be1d5b4a1c5a'; + const vocabularyOptions = new VocabularyFindOptions(collectionUUID, vocabularyId, metadata); const searchRequestURL = `https://rest.api/rest/api/submission/vocabularies/search/byMetadataAndCollection?metadata=${metadata}&collection=${collectionUUID}`; const entriesRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?metadata=${metadata}&collection=${collectionUUID}`; @@ -223,14 +225,14 @@ describe('VocabularyService', () => { new RequestParam('metadata', metadata), new RequestParam('collection', collectionUUID) ]; - scheduler.schedule(() => service.searchByMetadataAndCollection(metadata, collectionUUID).subscribe()); + scheduler.schedule(() => service.searchByMetadataAndCollection(vocabularyOptions).subscribe()); scheduler.flush(); expect((service as any).dataService.findByHref).toHaveBeenCalledWith(searchRequestURL); }); it('should return a RemoteData for the search', () => { - const result = service.searchByMetadataAndCollection(metadata, collectionUUID); + const result = service.searchByMetadataAndCollection(vocabularyOptions); const expected = cold('a|', { a: vocabularyRD }); @@ -251,14 +253,14 @@ describe('VocabularyService', () => { it('should configure a new VocabularyEntriesRequest', () => { const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entriesRequestURL); - scheduler.schedule(() => service.getVocabularyEntries(vocabularyId, metadata, collectionUUID).subscribe()); + scheduler.schedule(() => service.getVocabularyEntries(vocabularyOptions).subscribe()); scheduler.flush(); expect(requestService.configure).toHaveBeenCalledWith(expected); }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { - service.getVocabularyEntries(vocabularyId, metadata, collectionUUID); + service.getVocabularyEntries(vocabularyOptions); expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index cfb795c942..094f87544f 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -19,13 +19,13 @@ import { NotificationsService } from '../../../shared/notifications/notification import { ChangeAnalyzer } from '../../data/change-analyzer'; import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; import { PaginatedList } from '../../data/paginated-list'; -import { RequestParam } from '../../cache/models/request-param.model'; import { Vocabulary } from './models/vocabulary.model'; import { VOCABULARY } from './models/vocabularies.resource-type'; import { VocabularyEntry } from './models/vocabulary-entry.model'; import { hasValue, isNotEmptyOperator } from '../../../shared/empty.util'; import { configureRequest, filterSuccessfulResponses, getRequestFromRequestHref } from '../../shared/operators'; import { GenericSuccessResponse } from '../../cache/response.models'; +import { VocabularyFindOptions } from './models/vocabulary-find-options.model'; /* tslint:disable:max-classes-per-file */ @@ -111,22 +111,13 @@ export class VocabularyService { /** * Return the {@link VocabularyEntry} list for a given {@link Vocabulary} * - * @param id The vocabulary id to retrieve the entries for - * @param metadata The metadata name - * @param collectionUUID The collection UUID - * @param options The {@link FindListOptions} for the request + * @param options The {@link VocabularyFindOptions} for the request * @return {Observable>>} * Return an observable that emits object list */ - getVocabularyEntries(id: string, metadata: string, collectionUUID: string, options: FindListOptions = {}): Observable>> { - options = Object.assign({}, options, { - searchParams: [ - new RequestParam('metadata', metadata), - new RequestParam('collection', collectionUUID) - ] - }); + getVocabularyEntries(options: VocabularyFindOptions): Observable>> { - return this.dataService.getFindAllHref(options, `${id}/entries`).pipe( + return this.dataService.getFindAllHref(options, `${options.name}/entries`).pipe( isNotEmptyOperator(), distinctUntilChanged(), getVocabularyEntriesFor(this.requestService, this.rdbService) @@ -136,18 +127,12 @@ export class VocabularyService { /** * Return the controlled {@link Vocabulary} configured for the specified metadata and collection if any. * - * @param metadata The metadata name - * @param collectionUUID The collection UUID + * @param options The {@link VocabularyFindOptions} for the request * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved * @return {Observable>>} * Return an observable that emits object list */ - searchByMetadataAndCollection(metadata: string, collectionUUID: string, ...linksToFollow: Array>): Observable> { - const options = new FindListOptions(); - options.searchParams = [ - new RequestParam('metadata', metadata), - new RequestParam('collection', collectionUUID) - ]; + searchByMetadataAndCollection(options: VocabularyFindOptions, ...linksToFollow: Array>): Observable> { return this.dataService.getSearchByHref(this.searchByMetadataAndCollectionMethod, options).pipe( first((href: string) => hasValue(href)), From 3117916d5b127da82e1740d8c8529dd7d549f37e Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Wed, 24 Jun 2020 19:00:13 +0200 Subject: [PATCH 004/126] [CST-3088] Added vocabularyEntryDetails's methods to VocabularyService --- .../models/vocabularies.resource-type.ts | 1 + .../models/vocabulary-entry-detail.model.ts | 33 ++ .../models/vocabulary-entry.model.ts | 4 +- .../models/vocabulary-options.model.ts | 35 ++ .../vocabularies/vocabulary.service.spec.ts | 419 ++++++++++++------ .../vocabularies/vocabulary.service.ts | 91 +++- 6 files changed, 433 insertions(+), 150 deletions(-) create mode 100644 src/app/core/submission/vocabularies/models/vocabulary-entry-detail.model.ts create mode 100644 src/app/core/submission/vocabularies/models/vocabulary-options.model.ts diff --git a/src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts b/src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts index 4d49abd823..5902fe4e17 100644 --- a/src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts +++ b/src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts @@ -9,3 +9,4 @@ import { ResourceType } from '../../../shared/resource-type'; export const VOCABULARY = new ResourceType('vocabulary'); export const VOCABULARY_ENTRY = new ResourceType('vocabularyEntry'); +export const VOCABULARY_ENTRY_DETAIL = new ResourceType('vocabularyEntryDetail'); diff --git a/src/app/core/submission/vocabularies/models/vocabulary-entry-detail.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-entry-detail.model.ts new file mode 100644 index 0000000000..e8d0fc835a --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabulary-entry-detail.model.ts @@ -0,0 +1,33 @@ +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; + +import { HALLink } from '../../../shared/hal-link.model'; +import { VOCABULARY_ENTRY_DETAIL } from './vocabularies.resource-type'; +import { typedObject } from '../../../cache/builders/build-decorators'; +import { VocabularyEntry } from './vocabulary-entry.model'; + +/** + * Model class for a VocabularyEntryDetail + */ +@typedObject +@inheritSerialization(VocabularyEntry) +export class VocabularyEntryDetail extends VocabularyEntry { + static type = VOCABULARY_ENTRY_DETAIL; + + /** + * In an hierarchical vocabulary representing if entry is selectable as value + */ + @autoserialize + selectable: boolean; + + /** + * The {@link HALLink}s for this ExternalSourceEntry + */ + @deserialize + _links: { + self: HALLink; + vocabulary: HALLink; + parent: HALLink; + children + }; + +} diff --git a/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts index 5fe951c8aa..5ef61eba2e 100644 --- a/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts +++ b/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts @@ -11,7 +11,7 @@ import { ListableObject } from '../../../../shared/object-collection/shared/list import { GenericConstructor } from '../../../shared/generic-constructor'; /** - * Model class for a Vocabulary + * Model class for a VocabularyEntry */ @typedObject export class VocabularyEntry extends ListableObject { @@ -54,7 +54,7 @@ export class VocabularyEntry extends ListableObject { @deserialize _links: { self: HALLink; - vocabularyEntryDetail: HALLink; + vocabularyEntryDetail?: HALLink; }; /** diff --git a/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts new file mode 100644 index 0000000000..c28d8504bd --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts @@ -0,0 +1,35 @@ +/** + * Representing vocabulary properties + */ +export class VocabularyOptions { + + /** + * The name of the vocabulary + */ + name: string; + + /** + * The metadata field name (e.g. "dc.type") for which the vocabulary is used: + */ + metadata: string; + + /** + * The uuid of the collection where the item is being submitted + */ + scope: string; + + /** + * A boolean representing if value is closely related to a vocabulary entry or not + */ + closed: boolean; + + constructor(name: string, + metadata: string, + scope: string, + closed: boolean = false) { + this.name = name; + this.metadata = metadata; + this.scope = scope; + this.closed = closed; + } +} diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts index 5323c29f30..da496899d1 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -9,7 +9,7 @@ import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.s import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { RequestService } from '../../data/request.service'; -import { FindListOptions, VocabularyEntriesRequest } from '../../data/request.models'; +import { VocabularyEntriesRequest } from '../../data/request.models'; import { RequestParam } from '../../cache/models/request-param.model'; import { PageInfo } from '../../shared/page-info.model'; import { PaginatedList } from '../../data/paginated-list'; @@ -48,7 +48,7 @@ describe('VocabularyService', () => { } }; - const anotherVocabulary: any = { + const hierarchicalVocabulary: any = { id: 'srsc', name: 'srsc', scrollable: false, @@ -90,8 +90,55 @@ describe('VocabularyService', () => { } } }; + + const vocabularyEntryDetail: any = { + authority: 'authorityId1', + display: 'testValue1', + value: 'testValue1', + otherInformation: { + id: 'VR131402', + parent: 'Research Subject Categories::SOCIAL SCIENCES::Social sciences::Social work', + hasChildren: 'true', + note: 'Familjeforskning' + }, + type: 'vocabularyEntryDetail', + _links: { + self: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:VR131402' + }, + parent: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:parent' + }, + children: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:children' + } + } + }; + + const anotherVocabularyEntryDetail: any = { + authority: 'authorityId1', + display: 'children', + value: 'children', + otherInformation: { + id: 'VR131402', + parent: 'Research Subject Categories::SOCIAL SCIENCES::Social sciences::Social work', + hasChildren: 'false', + note: 'Familjeforskning' + }, + type: 'vocabularyEntryDetail', + _links: { + self: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:children' + }, + parent: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:testValue1' + } + } + }; + const endpointURL = `https://rest.api/rest/api/submission/vocabularies`; const requestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}`; + const entryDetailRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:SCB110/${hierarchicalVocabulary.id}:testValue`; const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; const vocabularyId = 'types'; const metadata = 'dc.type'; @@ -101,10 +148,14 @@ describe('VocabularyService', () => { const entriesRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?metadata=${metadata}&collection=${collectionUUID}`; const pageInfo = new PageInfo(); - const array = [vocabulary, anotherVocabulary]; + const array = [vocabulary, hierarchicalVocabulary]; + const arrayEntries = [vocabularyEntryDetail, anotherVocabularyEntryDetail]; const paginatedList = new PaginatedList(pageInfo, array); + const entriesPaginatedList = new PaginatedList(pageInfo, arrayEntries); const vocabularyRD = createSuccessfulRemoteDataObject(vocabulary); + const vocabularyEntryDetailRD = createSuccessfulRemoteDataObject(vocabularyEntryDetail); const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + const entriesPaginatedListRD = createSuccessfulRemoteDataObject(entriesPaginatedList); const getRequestEntry$ = (successful: boolean) => { return observableOf({ response: { isSuccessful: successful, payload: vocabulary } as any @@ -114,6 +165,7 @@ describe('VocabularyService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = {} as any; + const comparatorEntry = {} as any; function initTestService() { return new VocabularyService( @@ -123,149 +175,248 @@ describe('VocabularyService', () => { halService, notificationsService, http, - comparator + comparator, + comparatorEntry ); } - beforeEach(() => { - scheduler = getTestScheduler(); - - halService = jasmine.createSpyObj('halService', { - getEndpoint: cold('a', { a: endpointURL }) - }); - - responseCacheEntry = new RequestEntry(); - responseCacheEntry.response = new RestResponse(true, 200, 'Success'); - - requestService = jasmine.createSpyObj('requestService', { - generateRequestId: requestUUID, - configure: true, - removeByHrefSubstring: {}, - getByHref: observableOf(responseCacheEntry), - getByUUID: observableOf(responseCacheEntry), - }); - rdbService = jasmine.createSpyObj('rdbService', { - buildSingle: hot('a|', { - a: vocabularyRD - }), - buildList: hot('a|', { - a: paginatedListRD - }), - }); - - service = initTestService(); - - spyOn((service as any).dataService, 'findById').and.callThrough(); - spyOn((service as any).dataService, 'findAll').and.callThrough(); - spyOn((service as any).dataService, 'findByHref').and.callThrough(); - spyOn((service as any).dataService, 'searchBy').and.callThrough(); - spyOn((service as any).dataService, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL)); - spyOn((service as any).dataService, 'getFindAllHref').and.returnValue(observableOf(entriesRequestURL)); - }); - - afterEach(() => { - service = null; - }); - - describe('findById', () => { - it('should proxy the call to dataservice.findById', () => { - scheduler.schedule(() => service.findById(vocabularyId)); - scheduler.flush(); - - expect((service as any).dataService.findById).toHaveBeenCalledWith(vocabularyId); - }); - - it('should return a RemoteData for the object with the given id', () => { - const result = service.findById(vocabularyId); - const expected = cold('a|', { - a: vocabularyRD - }); - expect(result).toBeObservable(expected); - }); - }); - - describe('findByHref', () => { - it('should proxy the call to dataservice.findByHref', () => { - scheduler.schedule(() => service.findByHref(requestURL)); - scheduler.flush(); - - expect((service as any).dataService.findByHref).toHaveBeenCalledWith(requestURL); - }); - - it('should return a RemoteData for the object with the given URL', () => { - const result = service.findByHref(requestURL); - const expected = cold('a|', { - a: vocabularyRD - }); - expect(result).toBeObservable(expected); - }); - }); - - describe('findAll', () => { - it('should proxy the call to dataservice.findAll', () => { - scheduler.schedule(() => service.findAll()); - scheduler.flush(); - - expect((service as any).dataService.findAll).toHaveBeenCalled(); - }); - - it('should return a RemoteData>', () => { - const result = service.findAll(); - const expected = cold('a|', { - a: paginatedListRD - }); - expect(result).toBeObservable(expected); - }); - }); - - describe('searchByMetadataAndCollection', () => { - it('should proxy the call to dataservice.findByHref', () => { - const options = new FindListOptions(); - options.searchParams = [ - new RequestParam('metadata', metadata), - new RequestParam('collection', collectionUUID) - ]; - scheduler.schedule(() => service.searchByMetadataAndCollection(vocabularyOptions).subscribe()); - scheduler.flush(); - - expect((service as any).dataService.findByHref).toHaveBeenCalledWith(searchRequestURL); - }); - - it('should return a RemoteData for the search', () => { - const result = service.searchByMetadataAndCollection(vocabularyOptions); - const expected = cold('a|', { - a: vocabularyRD - }); - expect(result).toBeObservable(expected); - }); - - }); - - describe('getVocabularyEntries', () => { - + describe('vocabularies endpoint', () => { beforeEach(() => { - requestService = getMockRequestService(getRequestEntry$(true)); - rdbService = getMockRemoteDataBuildService(); - spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + configure: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('a|', { + a: vocabularyRD + }), + buildList: hot('a|', { + a: paginatedListRD + }), + }); + service = initTestService(); + + 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, 'searchBy').and.callThrough(); + spyOn((service as any).vocabularyDataService, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL)); + spyOn((service as any).vocabularyDataService, 'getFindAllHref').and.returnValue(observableOf(entriesRequestURL)); }); - it('should configure a new VocabularyEntriesRequest', () => { - const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entriesRequestURL); - - scheduler.schedule(() => service.getVocabularyEntries(vocabularyOptions).subscribe()); - scheduler.flush(); - - expect(requestService.configure).toHaveBeenCalledWith(expected); + afterEach(() => { + service = null; }); - it('should call RemoteDataBuildService to create the RemoteData Observable', () => { - service.getVocabularyEntries(vocabularyOptions); + describe('findVocabularyById', () => { + it('should proxy the call to vocabularyDataService.findVocabularyById', () => { + scheduler.schedule(() => service.findVocabularyById(vocabularyId)); + scheduler.flush(); - expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + expect((service as any).vocabularyDataService.findById).toHaveBeenCalledWith(vocabularyId); + }); + + it('should return a RemoteData for the object with the given id', () => { + const result = service.findVocabularyById(vocabularyId); + const expected = cold('a|', { + a: vocabularyRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('findVocabularyByHref', () => { + it('should proxy the call to vocabularyDataService.findVocabularyByHref', () => { + scheduler.schedule(() => service.findVocabularyByHref(requestURL)); + scheduler.flush(); + + expect((service as any).vocabularyDataService.findByHref).toHaveBeenCalledWith(requestURL); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.findVocabularyByHref(requestURL); + const expected = cold('a|', { + a: vocabularyRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('findAllVocabularies', () => { + it('should proxy the call to vocabularyDataService.findAllVocabularies', () => { + scheduler.schedule(() => service.findAllVocabularies()); + scheduler.flush(); + + expect((service as any).vocabularyDataService.findAll).toHaveBeenCalled(); + }); + + it('should return a RemoteData>', () => { + const result = service.findAllVocabularies(); + const expected = cold('a|', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('searchVocabularyByMetadataAndCollection', () => { + it('should proxy the call to vocabularyDataService.findVocabularyByHref', () => { + const options = new VocabularyFindOptions(); + options.searchParams = [ + new RequestParam('metadata', metadata), + new RequestParam('collection', collectionUUID) + ]; + scheduler.schedule(() => service.searchVocabularyByMetadataAndCollection(vocabularyOptions).subscribe()); + scheduler.flush(); + + expect((service as any).vocabularyDataService.findByHref).toHaveBeenCalledWith(searchRequestURL); + }); + + it('should return a RemoteData for the search', () => { + const result = service.searchVocabularyByMetadataAndCollection(vocabularyOptions); + const expected = cold('a|', { + a: vocabularyRD + }); + expect(result).toBeObservable(expected); + }); + + }); + + describe('getVocabularyEntries', () => { + + beforeEach(() => { + requestService = getMockRequestService(getRequestEntry$(true)); + rdbService = getMockRemoteDataBuildService(); + spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); + service = initTestService(); + }); + + it('should configure a new VocabularyEntriesRequest', () => { + const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entriesRequestURL); + + scheduler.schedule(() => service.getVocabularyEntries(vocabularyOptions).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + service.getVocabularyEntries(vocabularyOptions); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + + }); + + }); + }); + + describe('vocabularyEntryDetails endpoint', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + configure: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('a|', { + a: vocabularyEntryDetailRD + }), + buildList: hot('a|', { + a: entriesPaginatedListRD + }), + }); + + service = initTestService(); + + spyOn((service as any).vocabularyEntryDetailDataService, 'findById').and.callThrough(); + spyOn((service as any).vocabularyEntryDetailDataService, 'findAll').and.callThrough(); + spyOn((service as any).vocabularyEntryDetailDataService, 'findByHref').and.callThrough(); + spyOn((service as any).vocabularyEntryDetailDataService, 'searchBy').and.callThrough(); + spyOn((service as any).vocabularyEntryDetailDataService, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL)); + spyOn((service as any).vocabularyEntryDetailDataService, 'getFindAllHref').and.returnValue(observableOf(entriesRequestURL)); + }); + + afterEach(() => { + service = null; + }); + + describe('findEntryDetailByHref', () => { + it('should proxy the call to vocabularyDataService.findVocabularyByHref', () => { + scheduler.schedule(() => service.findEntryDetailByHref(entryDetailRequestURL)); + scheduler.flush(); + + expect((service as any).vocabularyEntryDetailDataService.findByHref).toHaveBeenCalledWith(entryDetailRequestURL); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.findEntryDetailByHref(entryDetailRequestURL); + const expected = cold('a|', { + a: vocabularyEntryDetailRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('findEntryDetailByValue', () => { + it('should proxy the call to vocabularyDataService.findVocabularyById', () => { + scheduler.schedule(() => service.findEntryDetailByValue('testValue', hierarchicalVocabulary.id)); + scheduler.flush(); + const expectedId = `${hierarchicalVocabulary.id}:testValue` + expect((service as any).vocabularyEntryDetailDataService.findById).toHaveBeenCalledWith(expectedId); + }); + + it('should return a RemoteData for the object with the given id', () => { + const result = service.findEntryDetailByValue('testValue', hierarchicalVocabulary.id); + const expected = cold('a|', { + a: vocabularyEntryDetailRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('searchByGroup', () => { + it('should proxy the call to vocabularyEntryDetailDataService.searchBy', () => { + const options = new VocabularyFindOptions(); + options.searchParams.push(new RequestParam('vocabulary', 'srsc')); + scheduler.schedule(() => service.searchTopEntries('srsc', new VocabularyFindOptions())); + scheduler.flush(); + + expect((service as any).vocabularyEntryDetailDataService.searchBy).toHaveBeenCalledWith((service as any).searchTopMethod, options); + }); + + it('should return a RemoteData) for the search', () => { + const result = service.searchTopEntries('srsc', new VocabularyFindOptions()); + const expected = cold('a|', { + a: entriesPaginatedListRD + }); + expect(result).toBeObservable(expected); + }); }); }); - }); diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index 094f87544f..13929525d4 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -26,13 +26,15 @@ import { hasValue, isNotEmptyOperator } from '../../../shared/empty.util'; import { configureRequest, filterSuccessfulResponses, getRequestFromRequestHref } from '../../shared/operators'; import { GenericSuccessResponse } from '../../cache/response.models'; import { VocabularyFindOptions } from './models/vocabulary-find-options.model'; +import { VocabularyEntryDetail } from './models/vocabulary-entry-detail.model'; +import { RequestParam } from '../../cache/models/request-param.model'; /* tslint:disable:max-classes-per-file */ /** * A private DataService implementation to delegate specific methods to. */ -class DataServiceImpl extends DataService { +class VocabularyDataServiceImpl extends DataService { protected linkPath = 'vocabularies'; constructor( @@ -49,6 +51,26 @@ class DataServiceImpl extends DataService { } +/** + * A private DataService implementation to delegate specific methods to. + */ +class VocabularyEntryDetailDataServiceImpl extends DataService { + protected linkPath = 'vocabularyEntryDetails'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: ChangeAnalyzer) { + super(); + } + +} + /** * A service responsible for fetching/sending data from/to the REST API on the vocabularies endpoint */ @@ -58,7 +80,9 @@ class DataServiceImpl extends DataService { @dataService(VOCABULARY) export class VocabularyService { protected searchByMetadataAndCollectionMethod = 'byMetadataAndCollection'; - private dataService: DataServiceImpl; + protected searchTopMethod = 'top'; + private vocabularyDataService: VocabularyDataServiceImpl; + private vocabularyEntryDetailDataService: VocabularyEntryDetailDataServiceImpl; constructor( protected requestService: RequestService, @@ -67,8 +91,10 @@ export class VocabularyService { protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + protected comparatorVocabulary: DefaultChangeAnalyzer, + protected comparatorEntry: DefaultChangeAnalyzer) { + this.vocabularyDataService = new VocabularyDataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorVocabulary); + this.vocabularyEntryDetailDataService = new VocabularyEntryDetailDataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorEntry); } /** @@ -79,8 +105,8 @@ export class VocabularyService { * @return {Observable>} * Return an observable that emits vocabulary object */ - findByHref(href: string, ...linksToFollow: Array>): Observable> { - return this.dataService.findByHref(href, ...linksToFollow); + findVocabularyByHref(href: string, ...linksToFollow: Array>): Observable> { + return this.vocabularyDataService.findByHref(href, ...linksToFollow); } /** @@ -91,8 +117,8 @@ export class VocabularyService { * @return {Observable>} * Return an observable that emits vocabulary object */ - findById(id: string, ...linksToFollow: Array>): Observable> { - return this.dataService.findById(id, ...linksToFollow); + findVocabularyById(id: string, ...linksToFollow: Array>): Observable> { + return this.vocabularyDataService.findById(id, ...linksToFollow); } /** @@ -104,8 +130,8 @@ export class VocabularyService { * @return {Observable>>} * Return an observable that emits object list */ - findAll(options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { - return this.dataService.findAll(options, ...linksToFollow); + findAllVocabularies(options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + return this.vocabularyDataService.findAll(options, ...linksToFollow); } /** @@ -117,7 +143,7 @@ export class VocabularyService { */ getVocabularyEntries(options: VocabularyFindOptions): Observable>> { - return this.dataService.getFindAllHref(options, `${options.name}/entries`).pipe( + return this.vocabularyDataService.getFindAllHref(options, `${options.name}/entries`).pipe( isNotEmptyOperator(), distinctUntilChanged(), getVocabularyEntriesFor(this.requestService, this.rdbService) @@ -132,14 +158,51 @@ export class VocabularyService { * @return {Observable>>} * Return an observable that emits object list */ - searchByMetadataAndCollection(options: VocabularyFindOptions, ...linksToFollow: Array>): Observable> { + searchVocabularyByMetadataAndCollection(options: VocabularyFindOptions, ...linksToFollow: Array>): Observable> { - return this.dataService.getSearchByHref(this.searchByMetadataAndCollectionMethod, options).pipe( + return this.vocabularyDataService.getSearchByHref(this.searchByMetadataAndCollectionMethod, options).pipe( first((href: string) => hasValue(href)), - flatMap((href: string) => this.dataService.findByHref(href)) + flatMap((href: string) => this.vocabularyDataService.findByHref(href)) ) } + /** + * Returns an observable of {@link RemoteData} of a {@link VocabularyEntryDetail}, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link VocabularyEntryDetail} + * @param href The url of {@link VocabularyEntryDetail} we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits vocabulary object + */ + findEntryDetailByHref(href: string, ...linksToFollow: Array>): Observable> { + return this.vocabularyEntryDetailDataService.findByHref(href, ...linksToFollow); + } + + /** + * Returns an observable of {@link RemoteData} of a {@link VocabularyEntryDetail}, based on its ID, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the object + * @param value The entry value for which to provide detailed information. + * @param name The name of {@link Vocabulary} to which the entry belongs + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits VocabularyEntryDetail object + */ + findEntryDetailByValue(value: string, name: string, ...linksToFollow: Array>): Observable> { + const id = `${name}:${value}`; + return this.vocabularyEntryDetailDataService.findById(id, ...linksToFollow); + } + + /** + * Return the top level {@link VocabularyEntryDetail} list for a given hierarchical vocabulary + * + * @param name The name of hierarchical {@link Vocabulary} to which the entries belongs + * @param options The {@link VocabularyFindOptions} for the request + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + searchTopEntries(name: string, options: VocabularyFindOptions, ...linksToFollow: Array>): Observable>> { + options.searchParams.push(new RequestParam('vocabulary', name)); + return this.vocabularyEntryDetailDataService.searchBy(this.searchTopMethod, options, ...linksToFollow) + } } /** From 63cca76b49a5e5ed21cb7064904b01043910a189 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 25 Jun 2020 15:21:44 +0200 Subject: [PATCH 005/126] [CST-3088] Replace use of AuthorityService with new VocabularyService --- src/app/core/core.module.ts | 12 +- .../builder/json-patch-operations-builder.ts | 8 +- .../models => shared}/confidence-type.ts | 0 .../vocabularies/vocabulary.service.ts | 4 +- .../authority-confidence-state.directive.ts | 11 +- src/app/shared/chips/chips.component.spec.ts | 2 +- .../shared/chips/models/chips-item.model.ts | 2 +- src/app/shared/chips/models/chips.model.ts | 4 +- ...c-form-control-container.component.spec.ts | 10 +- .../date-picker/date-picker.component.spec.ts | 10 +- .../date-picker/date-picker.component.ts | 6 +- .../models/ds-dynamic-input.model.ts | 12 +- .../list/dynamic-list-checkbox-group.model.ts | 30 +- .../list/dynamic-list-radio-group.model.ts | 10 +- .../list/dynamic-list.component.spec.ts | 84 ++--- .../models/list/dynamic-list.component.ts | 59 ++-- .../lookup/dynamic-lookup.component.spec.ts | 71 ++-- .../models/lookup/dynamic-lookup.component.ts | 312 +++++++++--------- .../dynamic-relation-group.component.spec.ts | 17 +- .../dynamic-relation-group.components.ts | 52 ++- ...amic-scrollable-dropdown.component.spec.ts | 38 +-- .../dynamic-scrollable-dropdown.component.ts | 68 ++-- .../dynamic-scrollable-dropdown.model.ts | 6 +- .../models/tag/dynamic-tag.component.spec.ts | 33 +- .../models/tag/dynamic-tag.component.ts | 65 ++-- .../dynamic-typeahead.component.spec.ts | 38 +-- .../typeahead/dynamic-typeahead.component.ts | 61 ++-- .../form/builder/form-builder.service.spec.ts | 18 +- .../models/form-field-metadata-value.model.ts | 10 +- .../form/builder/models/form-field.model.ts | 75 ++++- .../builder/parsers/date-field-parser.spec.ts | 2 +- .../parsers/disabled-field-parser.spec.ts | 2 +- .../parsers/dropdown-field-parser.spec.ts | 6 +- .../builder/parsers/dropdown-field-parser.ts | 6 +- .../form/builder/parsers/field-parser.ts | 113 +++---- .../builder/parsers/list-field-parser.spec.ts | 4 +- .../form/builder/parsers/list-field-parser.ts | 10 +- .../parsers/lookup-field-parser.spec.ts | 4 +- .../builder/parsers/lookup-field-parser.ts | 4 +- .../parsers/lookup-name-field-parser.spec.ts | 4 +- .../parsers/lookup-name-field-parser.ts | 4 +- .../builder/parsers/name-field-parser.spec.ts | 2 +- .../parsers/onebox-field-parser.spec.ts | 4 +- .../builder/parsers/onebox-field-parser.ts | 4 +- .../form/builder/parsers/parser-options.ts | 2 +- .../relation-group-field-parser.spec.ts | 2 +- .../parsers/relation-group-field-parser.ts | 2 +- .../form/builder/parsers/row-parser.spec.ts | 16 +- .../shared/form/builder/parsers/row-parser.ts | 6 +- .../parsers/series-field-parser.spec.ts | 2 +- .../builder/parsers/tag-field-parser.spec.ts | 4 +- .../form/builder/parsers/tag-field-parser.ts | 6 +- .../parsers/textarea-field-parser.spec.ts | 2 +- src/app/shared/mocks/form-models.mock.ts | 37 ++- src/app/shared/mocks/submission.mock.ts | 4 +- .../shared/testing/authority-service.stub.ts | 21 -- .../shared/testing/vocabulary-service.stub.ts | 29 ++ .../section-form-operations.service.spec.ts | 6 +- .../form/section-form-operations.service.ts | 10 +- 59 files changed, 740 insertions(+), 706 deletions(-) rename src/app/core/{integration/models => shared}/confidence-type.ts (100%) delete mode 100644 src/app/shared/testing/authority-service.stub.ts create mode 100644 src/app/shared/testing/vocabulary-service.stub.ts diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index a92b52b339..704f6c9e62 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -86,9 +86,6 @@ import { EPersonDataService } from './eperson/eperson-data.service'; import { EpersonResponseParsingService } from './eperson/eperson-response-parsing.service'; import { EPerson } from './eperson/models/eperson.model'; import { Group } from './eperson/models/group.model'; -import { AuthorityService } from './integration/authority.service'; -import { IntegrationResponseParsingService } from './integration/integration-response-parsing.service'; -import { AuthorityValue } from './integration/models/authority.value'; import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder'; import { MetadataField } from './metadata/metadata-field.model'; import { MetadataSchema } from './metadata/metadata-schema.model'; @@ -148,6 +145,8 @@ import { WorkflowAction } from './tasks/models/workflow-action-object.model'; import { VocabularyEntry } from './submission/vocabularies/models/vocabulary-entry.model'; import { Vocabulary } from './submission/vocabularies/models/vocabulary.model'; import { VocabularyEntriesResponseParsingService } from './submission/vocabularies/vocabulary-entries-response-parsing.service'; +import { VocabularyEntryDetail } from './submission/vocabularies/models/vocabulary-entry-detail.model'; +import { VocabularyService } from './submission/vocabularies/vocabulary.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -224,8 +223,6 @@ const PROVIDERS = [ SubmissionResponseParsingService, SubmissionJsonPatchOperationsService, JsonPatchOperationsBuilder, - AuthorityService, - IntegrationResponseParsingService, MetadataschemaParsingService, MetadatafieldParsingService, UploaderService, @@ -276,6 +273,7 @@ const PROVIDERS = [ NotificationsService, FilteredDiscoveryPageResponseParsingService, { provide: NativeWindowService, useFactory: NativeWindowFactory }, + VocabularyService, VocabularyEntriesResponseParsingService ]; @@ -305,7 +303,6 @@ export const models = SubmissionSectionModel, SubmissionUploadsModel, AuthStatus, - AuthorityValue, BrowseEntry, BrowseDefinition, ClaimedTask, @@ -320,7 +317,8 @@ export const models = VersionHistory, WorkflowAction, Vocabulary, - VocabularyEntry + VocabularyEntry, + VocabularyEntryDetail ]; @NgModule({ diff --git a/src/app/core/json-patch/builder/json-patch-operations-builder.ts b/src/app/core/json-patch/builder/json-patch-operations-builder.ts index c45183b4ef..36c82db283 100644 --- a/src/app/core/json-patch/builder/json-patch-operations-builder.ts +++ b/src/app/core/json-patch/builder/json-patch-operations-builder.ts @@ -9,7 +9,7 @@ import { JsonPatchOperationPathObject } from './json-patch-operation-path-combin import { Injectable } from '@angular/core'; import { isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { dateToISOFormat } from '../../../shared/date.util'; -import { AuthorityValue } from '../../integration/models/authority.value'; +import { VocabularyEntry } from '../../submission/vocabularies/models/vocabulary-entry.model'; import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model'; @@ -106,7 +106,7 @@ export class JsonPatchOperationsBuilder { operationValue = value; } else if (value instanceof Date) { operationValue = new FormFieldMetadataValueObject(dateToISOFormat(value)); - } else if (value instanceof AuthorityValue) { + } else if (value instanceof VocabularyEntry) { operationValue = this.prepareAuthorityValue(value); } else if (value instanceof FormFieldLanguageValueObject) { operationValue = new FormFieldMetadataValueObject(value.value, value.language); @@ -127,8 +127,8 @@ export class JsonPatchOperationsBuilder { protected prepareAuthorityValue(value: any) { let operationValue: any = null; - if (isNotEmpty(value.id)) { - operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.id); + if (isNotEmpty(value.authority)) { + operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.authority); } else { operationValue = new FormFieldMetadataValueObject(value.value, value.language); } diff --git a/src/app/core/integration/models/confidence-type.ts b/src/app/core/shared/confidence-type.ts similarity index 100% rename from src/app/core/integration/models/confidence-type.ts rename to src/app/core/shared/confidence-type.ts diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index 13929525d4..bd5c5b6c48 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -74,9 +74,7 @@ class VocabularyEntryDetailDataServiceImpl extends DataService; @@ -102,7 +102,7 @@ export class Chips { private getChipsIcons(item) { const icons = []; - if (typeof item === 'string' || item instanceof FormFieldMetadataValueObject || item instanceof AuthorityValue) { + if (typeof item === 'string' || item instanceof FormFieldMetadataValueObject || item instanceof VocabularyEntry) { return icons; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts index 4dee6905d2..171ad69f64 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts @@ -44,7 +44,7 @@ import { SharedModule } from '../../../shared.module'; import { DynamicDsDatePickerModel } from './models/date-picker/date-picker.model'; import { DynamicRelationGroupModel } from './models/relation-group/dynamic-relation-group.model'; import { DynamicListCheckboxGroupModel } from './models/list/dynamic-list-checkbox-group.model'; -import { AuthorityOptions } from '../../../../core/integration/models/authority-options.model'; +import { VocabularyOptions } from '../../../../core/submission/vocabularies/models/vocabulary-options.model'; import { DynamicListRadioGroupModel } from './models/list/dynamic-list-radio-group.model'; import { DynamicLookupModel } from './models/lookup/dynamic-lookup.model'; import { DynamicScrollableDropdownModel } from './models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; @@ -74,7 +74,7 @@ import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils'; describe('DsDynamicFormControlContainerComponent test suite', () => { - const authorityOptions: AuthorityOptions = { + const vocabularyOptions: VocabularyOptions = { closed: false, metadata: 'list', name: 'type_programme', @@ -104,7 +104,7 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { new DynamicTypeaheadModel({ id: 'typeahead', metadataFields: [], repeatable: false, submissionId: '1234' }), new DynamicScrollableDropdownModel({ id: 'scrollableDropdown', - authorityOptions: authorityOptions, + vocabularyOptions: vocabularyOptions, metadataFields: [], repeatable: false, submissionId: '1234' @@ -112,12 +112,12 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { new DynamicTagModel({ id: 'tag', metadataFields: [], repeatable: false, submissionId: '1234' }), new DynamicListCheckboxGroupModel({ id: 'checkboxList', - authorityOptions: authorityOptions, + vocabularyOptions: vocabularyOptions, repeatable: true }), new DynamicListRadioGroupModel({ id: 'radioList', - authorityOptions: authorityOptions, + vocabularyOptions: vocabularyOptions, repeatable: false }), new DynamicRelationGroupModel({ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts index 78c2d5d217..e08116e5fa 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts @@ -8,10 +8,6 @@ import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dyna import { DsDatePickerComponent } from './date-picker.component'; import { DynamicDsDatePickerModel } from './date-picker.model'; -import { FormBuilderService } from '../../../form-builder.service'; - -import { FormComponent } from '../../../../form.component'; -import { FormService } from '../../../../form.service'; import { createTestComponent } from '../../../../../testing/utils.test'; export const DATE_TEST_GROUP = new FormGroup({ @@ -20,7 +16,7 @@ export const DATE_TEST_GROUP = new FormGroup({ export const DATE_TEST_MODEL_CONFIG = { disabled: false, - errorMessages: {required: 'You must enter at least the year.'}, + errorMessages: { required: 'You must enter at least the year.' }, id: 'date', label: 'Date', name: 'date', @@ -52,8 +48,8 @@ describe('DsDatePickerComponent test suite', () => { providers: [ ChangeDetectorRef, DsDatePickerComponent, - {provide: DynamicFormLayoutService, useValue: {}}, - {provide: DynamicFormValidationService, useValue: {}} + { provide: DynamicFormLayoutService, useValue: {} }, + { provide: DynamicFormValidationService, useValue: {} } ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts index 2e22f314ed..8523c60cf0 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts @@ -20,10 +20,6 @@ export class DsDatePickerComponent extends DynamicFormControlComponent implement @Input() bindId = true; @Input() group: FormGroup; @Input() model: DynamicDsDatePickerModel; - // @Input() - // minDate; - // @Input() - // maxDate; @Output() selected = new EventEmitter(); @Output() remove = new EventEmitter(); @@ -65,7 +61,7 @@ export class DsDatePickerComponent extends DynamicFormControlComponent implement this.initialMonth = now.getMonth() + 1; this.initialDay = now.getDate(); - if (this.model.value && this.model.value !== null) { + if (this.model && this.model.value !== null) { const values = this.model.value.toString().split(DS_DATE_PICKER_SEPARATOR); if (values.length > 0) { this.initialYear = parseInt(values[0], 10); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts index 3827df7be6..b723f7aa40 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts @@ -2,13 +2,13 @@ import { DynamicFormControlLayout, DynamicInputModel, DynamicInputModelConfig, s import { Subject } from 'rxjs'; import { LanguageCode } from '../../models/form-field-language-value.model'; -import { AuthorityOptions } from '../../../../../core/integration/models/authority-options.model'; +import { VocabularyOptions } from '../../../../../core/submission/vocabularies/models/vocabulary-options.model'; import { hasValue } from '../../../../empty.util'; import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; import { RelationshipOptions } from '../../models/relationship-options.model'; export interface DsDynamicInputModelConfig extends DynamicInputModelConfig { - authorityOptions?: AuthorityOptions; + vocabularyOptions?: VocabularyOptions; languageCodes?: LanguageCode[]; language?: string; value?: any; @@ -20,7 +20,7 @@ export interface DsDynamicInputModelConfig extends DynamicInputModelConfig { export class DsDynamicInputModel extends DynamicInputModel { - @serializable() authorityOptions: AuthorityOptions; + @serializable() vocabularyOptions: VocabularyOptions; @serializable() private _languageCodes: LanguageCode[]; @serializable() private _language: string; @serializable() languageUpdates: Subject; @@ -58,11 +58,11 @@ export class DsDynamicInputModel extends DynamicInputModel { this.language = lang; }); - this.authorityOptions = config.authorityOptions; + this.vocabularyOptions = config.vocabularyOptions; } get hasAuthority(): boolean { - return this.authorityOptions && hasValue(this.authorityOptions.name); + return this.vocabularyOptions && hasValue(this.vocabularyOptions.name); } get hasLanguages(): boolean { @@ -83,7 +83,7 @@ export class DsDynamicInputModel extends DynamicInputModel { set languageCodes(languageCodes: LanguageCode[]) { this._languageCodes = languageCodes; - if (!this.language || this.language === null || this.language === '') { + if (!this.language || this.language === '') { this.language = this.languageCodes ? this.languageCodes[0].code : null; } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model.ts index f6b58c1504..fab371483c 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model.ts @@ -1,16 +1,17 @@ import { Subject } from 'rxjs'; - import { - DynamicCheckboxGroupModel, DynamicFormControlLayout, + DynamicCheckboxGroupModel, + DynamicFormControlLayout, DynamicFormGroupModelConfig, serializable } from '@ng-dynamic-forms/core'; -import { AuthorityValue } from '../../../../../../core/integration/models/authority.value'; -import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; + +import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { VocabularyOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-options.model'; import { hasValue } from '../../../../../empty.util'; export interface DynamicListCheckboxGroupModelConfig extends DynamicFormGroupModelConfig { - authorityOptions: AuthorityOptions; + vocabularyOptions: VocabularyOptions; groupLength?: number; repeatable: boolean; value?: any; @@ -18,43 +19,44 @@ export interface DynamicListCheckboxGroupModelConfig extends DynamicFormGroupMod export class DynamicListCheckboxGroupModel extends DynamicCheckboxGroupModel { - @serializable() authorityOptions: AuthorityOptions; + @serializable() vocabularyOptions: VocabularyOptions; @serializable() repeatable: boolean; @serializable() groupLength: number; - @serializable() _value: AuthorityValue[]; isListGroup = true; valueUpdates: Subject; constructor(config: DynamicListCheckboxGroupModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); - this.authorityOptions = config.authorityOptions; + this.vocabularyOptions = config.vocabularyOptions; this.groupLength = config.groupLength || 5; this._value = []; this.repeatable = config.repeatable; this.valueUpdates = new Subject(); - this.valueUpdates.subscribe((value: AuthorityValue | AuthorityValue[]) => this.value = value); + this.valueUpdates.subscribe((value: VocabularyEntry | VocabularyEntry[]) => this.value = value); this.valueUpdates.next(config.value); } - get hasAuthority(): boolean { - return this.authorityOptions && hasValue(this.authorityOptions.name); - } + @serializable() _value: VocabularyEntry[]; get value() { return this._value; } - set value(value: AuthorityValue | AuthorityValue[]) { + set value(value: VocabularyEntry | VocabularyEntry[]) { if (value) { if (Array.isArray(value)) { this._value = value; } else { // _value is non extendible so assign it a new array - const newValue = (this.value as AuthorityValue[]).concat([value]); + const newValue = (this.value as VocabularyEntry[]).concat([value]); this._value = newValue } } } + + get hasAuthority(): boolean { + return this.vocabularyOptions && hasValue(this.vocabularyOptions.name); + } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model.ts index 287c10f3fe..a4a0fb7749 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model.ts @@ -4,11 +4,11 @@ import { DynamicRadioGroupModelConfig, serializable } from '@ng-dynamic-forms/core'; -import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; +import { VocabularyOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-options.model'; import { hasValue } from '../../../../../empty.util'; export interface DynamicListModelConfig extends DynamicRadioGroupModelConfig { - authorityOptions: AuthorityOptions; + vocabularyOptions: VocabularyOptions; groupLength?: number; repeatable: boolean; value?: any; @@ -16,7 +16,7 @@ export interface DynamicListModelConfig extends DynamicRadioGroupModelConfig { - @serializable() authorityOptions: AuthorityOptions; + @serializable() vocabularyOptions: VocabularyOptions; @serializable() repeatable: boolean; @serializable() groupLength: number; isListGroup = true; @@ -24,13 +24,13 @@ export class DynamicListRadioGroupModel extends DynamicRadioGroupModel { constructor(config: DynamicListModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); - this.authorityOptions = config.authorityOptions; + this.vocabularyOptions = config.vocabularyOptions; this.groupLength = config.groupLength || 5; this.repeatable = config.repeatable; this.valueUpdates.next(config.value); } get hasAuthority(): boolean { - return this.authorityOptions && hasValue(this.authorityOptions.name); + return this.vocabularyOptions && hasValue(this.vocabularyOptions.name); } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts index 63ae56e59a..b35ee10e8a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts @@ -2,25 +2,25 @@ import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { async, ComponentFixture, inject, TestBed, } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; - -import { DsDynamicListComponent } from './dynamic-list.component'; -import { DynamicListCheckboxGroupModel } from './dynamic-list-checkbox-group.model'; -import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; -import { FormBuilderService } from '../../../form-builder.service'; +import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { DynamicFormControlLayout, DynamicFormLayoutService, DynamicFormsCoreModule, DynamicFormValidationService } from '@ng-dynamic-forms/core'; -import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; -import { AuthorityService } from '../../../../../../core/integration/authority.service'; -import { AuthorityServiceStub } from '../../../../../testing/authority-service.stub'; + +import { DsDynamicListComponent } from './dynamic-list.component'; +import { DynamicListCheckboxGroupModel } from './dynamic-list-checkbox-group.model'; +import { VocabularyOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-options.model'; +import { FormBuilderService } from '../../../form-builder.service'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +import { VocabularyServiceStub } from '../../../../../testing/vocabulary-service.stub'; import { DynamicListRadioGroupModel } from './dynamic-list-radio-group.model'; -import { By } from '@angular/platform-browser'; -import { AuthorityValue } from '../../../../../../core/integration/models/authority.value'; +import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { createTestComponent } from '../../../../../testing/utils.test'; export const LAYOUT_TEST = { @@ -35,12 +35,12 @@ export const LIST_TEST_GROUP = new FormGroup({ }); export const LIST_CHECKBOX_TEST_MODEL_CONFIG = { - authorityOptions: { + vocabularyOptions: { closed: false, metadata: 'listCheckbox', name: 'type_programme', scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, + } as VocabularyOptions, disabled: false, id: 'listCheckbox', label: 'Programme', @@ -52,12 +52,12 @@ export const LIST_CHECKBOX_TEST_MODEL_CONFIG = { }; export const LIST_RADIO_TEST_MODEL_CONFIG = { - authorityOptions: { + vocabularyOptions: { closed: false, metadata: 'listRadio', name: 'type_programme', scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, + } as VocabularyOptions, disabled: false, id: 'listRadio', label: 'Programme', @@ -77,7 +77,7 @@ describe('DsDynamicListComponent test suite', () => { let html; let modelValue; - const authorityServiceStub = new AuthorityServiceStub(); + const vocabularyServiceStub = new VocabularyServiceStub(); // async beforeEach beforeEach(async(() => { @@ -99,9 +99,9 @@ describe('DsDynamicListComponent test suite', () => { DsDynamicListComponent, DynamicFormValidationService, FormBuilderService, - {provide: AuthorityService, useValue: authorityServiceStub}, - {provide: DynamicFormLayoutService, useValue: {}}, - {provide: DynamicFormValidationService, useValue: {}} + { provide: VocabularyService, useValue: vocabularyServiceStub }, + { provide: DynamicFormLayoutService, useValue: {} }, + { provide: DynamicFormValidationService, useValue: {} } ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); @@ -147,20 +147,16 @@ describe('DsDynamicListComponent test suite', () => { }); it('should init component properly', () => { - const results$ = authorityServiceStub.getEntriesByName({} as any); - - results$.subscribe((results) => { - expect((listComp as any).optionsList).toEqual(results.payload); - expect(listComp.items.length).toBe(1); - expect(listComp.items[0].length).toBe(2); - }) + expect((listComp as any).optionsList).toEqual(vocabularyServiceStub.getList()); + expect(listComp.items.length).toBe(1); + expect(listComp.items[0].length).toBe(2); }); it('should set model value properly when a checkbox option is selected', () => { const de = listFixture.debugElement.queryAll(By.css('div.custom-checkbox')); const items = de[0].queryAll(By.css('input.custom-control-input')); const item = items[0]; - modelValue = [Object.assign(new AuthorityValue(), {id: 1, display: 'one', value: 1})]; + modelValue = [Object.assign(new VocabularyEntry(), { authority: 1, display: 'one', value: 1 })]; item.nativeElement.click(); @@ -187,7 +183,7 @@ describe('DsDynamicListComponent test suite', () => { listComp = listFixture.componentInstance; // FormComponent test instance listComp.group = LIST_TEST_GROUP; listComp.model = new DynamicListCheckboxGroupModel(LIST_CHECKBOX_TEST_MODEL_CONFIG, LAYOUT_TEST); - modelValue = [Object.assign(new AuthorityValue(), {id: 1, display: 'one', value: 1})]; + modelValue = [Object.assign(new VocabularyEntry(), { authority: 1, display: 'one', value: 1 })]; listComp.model.value = modelValue; listFixture.detectChanges(); }); @@ -198,13 +194,9 @@ describe('DsDynamicListComponent test suite', () => { }); it('should init component properly', () => { - const results$ = authorityServiceStub.getEntriesByName({} as any); - - results$.subscribe((results) => { - expect((listComp as any).optionsList).toEqual(results.payload); - expect(listComp.model.value).toEqual(modelValue); - expect((listComp.model as DynamicListCheckboxGroupModel).group[0].value).toBeTruthy(); - }) + expect((listComp as any).optionsList).toEqual(vocabularyServiceStub.getList()); + expect(listComp.model.value).toEqual(modelValue); + expect((listComp.model as DynamicListCheckboxGroupModel).group[0].value).toBeTruthy(); }); it('should set model value properly when a checkbox option is deselected', () => { @@ -237,20 +229,16 @@ describe('DsDynamicListComponent test suite', () => { }); it('should init component properly', () => { - const results$ = authorityServiceStub.getEntriesByName({} as any); - - results$.subscribe((results) => { - expect((listComp as any).optionsList).toEqual(results.payload); - expect(listComp.items.length).toBe(1); - expect(listComp.items[0].length).toBe(2); - }) + expect((listComp as any).optionsList).toEqual(vocabularyServiceStub.getList()); + expect(listComp.items.length).toBe(1); + expect(listComp.items[0].length).toBe(2); }); it('should set model value when a radio option is selected', () => { const de = listFixture.debugElement.queryAll(By.css('div.custom-radio')); const items = de[0].queryAll(By.css('input.custom-control-input')); const item = items[0]; - modelValue = Object.assign(new AuthorityValue(), {id: 1, display: 'one', value: 1}); + modelValue = Object.assign(new VocabularyEntry(), { authority: 1, display: 'one', value: 1 }); item.nativeElement.click(); @@ -265,7 +253,7 @@ describe('DsDynamicListComponent test suite', () => { listComp = listFixture.componentInstance; // FormComponent test instance listComp.group = LIST_TEST_GROUP; listComp.model = new DynamicListRadioGroupModel(LIST_RADIO_TEST_MODEL_CONFIG, LAYOUT_TEST); - modelValue = Object.assign(new AuthorityValue(), {id: 1, display: 'one', value: 1}); + modelValue = Object.assign(new VocabularyEntry(), { authority: 1, display: 'one', value: 1 }); listComp.model.value = modelValue; listFixture.detectChanges(); }); @@ -276,13 +264,9 @@ describe('DsDynamicListComponent test suite', () => { }); it('should init component properly', () => { - const results$ = authorityServiceStub.getEntriesByName({} as any); - - results$.subscribe((results) => { - expect((listComp as any).optionsList).toEqual(results.payload); - expect(listComp.model.value).toEqual(modelValue); - expect((listComp.model as DynamicListRadioGroupModel).options[0].value).toBeTruthy(); - }) + expect((listComp as any).optionsList).toEqual(vocabularyServiceStub.getList()); + expect(listComp.model.value).toEqual(modelValue); + expect((listComp.model as DynamicListRadioGroupModel).options[0].value).toBeTruthy(); }); }); }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts index d023f1c583..3c5a86f362 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts @@ -1,20 +1,23 @@ import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { FormGroup } from '@angular/forms'; + +import { + DynamicCheckboxModel, + DynamicFormControlComponent, + DynamicFormLayoutService, + DynamicFormValidationService +} from '@ng-dynamic-forms/core'; import { findKey } from 'lodash'; -import { AuthorityService } from '../../../../../../core/integration/authority.service'; -import { IntegrationSearchOptions } from '../../../../../../core/integration/models/integration-options.model'; +import { VocabularyFindOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-find-options.model'; import { hasValue, isNotEmpty } from '../../../../../empty.util'; import { DynamicListCheckboxGroupModel } from './dynamic-list-checkbox-group.model'; import { FormBuilderService } from '../../../form-builder.service'; -import { - DynamicCheckboxModel, - DynamicFormControlComponent, DynamicFormLayoutService, - DynamicFormValidationService -} from '@ng-dynamic-forms/core'; -import { AuthorityValue } from '../../../../../../core/integration/models/authority.value'; import { DynamicListRadioGroupModel } from './dynamic-list-radio-group.model'; -import { IntegrationData } from '../../../../../../core/integration/integration-data'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; +import { PaginatedList } from '../../../../../../core/data/paginated-list'; +import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; export interface ListItem { id: string, @@ -39,10 +42,10 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen @Output() focus: EventEmitter = new EventEmitter(); public items: ListItem[][] = []; - protected optionsList: AuthorityValue[]; - protected searchOptions: IntegrationSearchOptions; + protected optionsList: VocabularyEntry[]; + protected searchOptions: VocabularyFindOptions; - constructor(private authorityService: AuthorityService, + constructor(private vocabularyService: VocabularyService, private cdr: ChangeDetectorRef, private formBuilderService: FormBuilderService, protected layoutService: DynamicFormLayoutService, @@ -54,10 +57,10 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen ngOnInit() { if (this.hasAuthorityOptions()) { // TODO Replace max elements 1000 with a paginated request when pagination bug is resolved - this.searchOptions = new IntegrationSearchOptions( - this.model.authorityOptions.scope, - this.model.authorityOptions.name, - this.model.authorityOptions.metadata, + this.searchOptions = new VocabularyFindOptions( + this.model.vocabularyOptions.scope, + this.model.vocabularyOptions.name, + this.model.vocabularyOptions.metadata, '', 1000, // Max elements 1);// Current Page @@ -77,13 +80,13 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen const target = event.target as any; if (this.model.repeatable) { // Target tabindex coincide with the array index of the value into the authority list - const authorityValue: AuthorityValue = this.optionsList[target.tabIndex]; + const entry: VocabularyEntry = this.optionsList[target.tabIndex]; if (target.checked) { - this.model.valueUpdates.next(authorityValue); + this.model.valueUpdates.next(entry); } else { const newValue = []; this.model.value - .filter((item) => item.value !== authorityValue.value) + .filter((item) => item.value !== entry.value) .forEach((item) => newValue.push(item)); this.model.valueUpdates.next(newValue); } @@ -94,16 +97,18 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen } protected setOptionsFromAuthority() { - if (this.model.authorityOptions.name && this.model.authorityOptions.name.length > 0) { + if (this.model.vocabularyOptions.name && this.model.vocabularyOptions.name.length > 0) { const listGroup = this.group.controls[this.model.id] as FormGroup; - this.authorityService.getEntriesByName(this.searchOptions).subscribe((authorities: IntegrationData) => { + this.vocabularyService.getVocabularyEntries(this.searchOptions).pipe( + getFirstSucceededRemoteDataPayload() + ).subscribe((entries: PaginatedList) => { let groupCounter = 0; let itemsPerGroup = 0; let tempList: ListItem[] = []; - this.optionsList = authorities.payload as AuthorityValue[]; + this.optionsList = entries.page; // Make a list of available options (checkbox/radio) and split in groups of 'model.groupLength' - (authorities.payload as AuthorityValue[]).forEach((option, key) => { - const value = option.id || option.value; + entries.page.forEach((option, key) => { + const value = option.authority || option.value; const checked: boolean = isNotEmpty(findKey( this.model.value, (v) => v.value === option.value)); @@ -138,8 +143,8 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen } protected hasAuthorityOptions() { - return (hasValue(this.model.authorityOptions.scope) - && hasValue(this.model.authorityOptions.name) - && hasValue(this.model.authorityOptions.metadata)); + return (hasValue(this.model.vocabularyOptions.scope) + && hasValue(this.model.vocabularyOptions.name) + && hasValue(this.model.vocabularyOptions.metadata)); } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts index c77aabfeed..ce45e113a0 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts @@ -2,33 +2,33 @@ import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { async, ComponentFixture, fakeAsync, inject, TestBed, tick, } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; - -import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; -import { DynamicFormLayoutService, DynamicFormsCoreModule, DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; -import { AuthorityService } from '../../../../../../core/integration/authority.service'; -import { AuthorityServiceStub } from '../../../../../testing/authority-service.stub'; +import { DynamicFormLayoutService, DynamicFormsCoreModule, DynamicFormValidationService } from '@ng-dynamic-forms/core'; + +import { VocabularyOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-options.model'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +import { VocabularyServiceStub } from '../../../../../testing/vocabulary-service.stub'; import { DsDynamicLookupComponent } from './dynamic-lookup.component'; import { DynamicLookupModel } from './dynamic-lookup.model'; -import { InfiniteScrollModule } from 'ngx-infinite-scroll'; -import { TranslateModule } from '@ngx-translate/core'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; -import { By } from '@angular/platform-browser'; -import { AuthorityValue } from '../../../../../../core/integration/models/authority.value'; +import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { createTestComponent } from '../../../../../testing/utils.test'; import { DynamicLookupNameModel } from './dynamic-lookup-name.model'; import { AuthorityConfidenceStateDirective } from '../../../../../authority-confidence/authority-confidence-state.directive'; import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; let LOOKUP_TEST_MODEL_CONFIG = { - authorityOptions: { + vocabularyOptions: { closed: false, metadata: 'lookup', name: 'RPAuthority', scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, + } as VocabularyOptions, disabled: false, errorMessages: { required: 'Required field.' }, id: 'lookup', @@ -47,12 +47,12 @@ let LOOKUP_TEST_MODEL_CONFIG = { }; let LOOKUP_NAME_TEST_MODEL_CONFIG = { - authorityOptions: { + vocabularyOptions: { closed: false, metadata: 'lookup-name', name: 'RPAuthority', scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, + } as VocabularyOptions, disabled: false, errorMessages: { required: 'Required field.' }, id: 'lookupName', @@ -78,12 +78,12 @@ let LOOKUP_TEST_GROUP = new FormGroup({ describe('Dynamic Lookup component', () => { function init() { LOOKUP_TEST_MODEL_CONFIG = { - authorityOptions: { + vocabularyOptions: { closed: false, metadata: 'lookup', name: 'RPAuthority', scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, + } as VocabularyOptions, disabled: false, errorMessages: { required: 'Required field.' }, id: 'lookup', @@ -102,12 +102,12 @@ describe('Dynamic Lookup component', () => { }; LOOKUP_NAME_TEST_MODEL_CONFIG = { - authorityOptions: { + vocabularyOptions: { closed: false, metadata: 'lookup-name', name: 'RPAuthority', scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, + } as VocabularyOptions, disabled: false, errorMessages: { required: 'Required field.' }, id: 'lookupName', @@ -137,12 +137,11 @@ describe('Dynamic Lookup component', () => { let testFixture: ComponentFixture; let lookupFixture: ComponentFixture; let html; + let vocabularyServiceStub: VocabularyServiceStub; - let authorityServiceStub; // async beforeEach beforeEach(async(() => { - const authorityService = new AuthorityServiceStub(); - authorityServiceStub = authorityService; + vocabularyServiceStub = new VocabularyServiceStub(); TestBed.configureTestingModule({ imports: [ DynamicFormsCoreModule, @@ -162,7 +161,7 @@ describe('Dynamic Lookup component', () => { providers: [ ChangeDetectorRef, DsDynamicLookupComponent, - { provide: AuthorityService, useValue: authorityService }, + { provide: VocabularyService, useValue: vocabularyServiceStub }, { provide: DynamicFormLayoutService, useValue: {} }, { provide: DynamicFormValidationService, useValue: {} } ], @@ -247,7 +246,7 @@ describe('Dynamic Lookup component', () => { it('should return search results', fakeAsync(() => { const de = lookupFixture.debugElement.queryAll(By.css('button')); const btnEl = de[0].nativeElement; - const results$ = authorityServiceStub.getEntriesByName({} as any); + const results = vocabularyServiceStub.getList(); lookupComp.firstInputValue = 'test'; lookupFixture.detectChanges(); @@ -255,17 +254,15 @@ describe('Dynamic Lookup component', () => { btnEl.click(); tick(); lookupFixture.detectChanges(); - results$.subscribe((results) => { - expect(lookupComp.optionsList).toEqual(results.payload); - }); + expect(lookupComp.optionsList).toEqual(results); })); it('should select a results entry properly', fakeAsync(() => { let de = lookupFixture.debugElement.queryAll(By.css('button')); const btnEl = de[0].nativeElement; - const selectedValue = Object.assign(new AuthorityValue(), { - id: 1, + const selectedValue = Object.assign(new VocabularyEntry(), { + authority: 1, display: 'one', value: 1 }); @@ -284,7 +281,7 @@ describe('Dynamic Lookup component', () => { expect(lookupComp.change.emit).toHaveBeenCalled(); })); - it('should set model.value on input type when AuthorityOptions.closed is false', fakeAsync(() => { + it('should set model.value on input type when VocabularyOptions.closed is false', fakeAsync(() => { lookupComp.firstInputValue = 'test'; lookupFixture.detectChanges(); @@ -293,8 +290,8 @@ describe('Dynamic Lookup component', () => { })); - it('should not set model.value on input type when AuthorityOptions.closed is true', () => { - lookupComp.model.authorityOptions.closed = true; + it('should not set model.value on input type when VocabularyOptions.closed is true', () => { + lookupComp.model.vocabularyOptions.closed = true; lookupComp.firstInputValue = 'test'; lookupFixture.detectChanges(); @@ -389,26 +386,26 @@ describe('Dynamic Lookup component', () => { it('should select a results entry properly', fakeAsync(() => { const payload = [ - Object.assign(new AuthorityValue(), { - id: 1, + Object.assign(new VocabularyEntry(), { + authority: 1, display: 'Name, Lastname', value: 1 }), - Object.assign(new AuthorityValue(), { - id: 2, + Object.assign(new VocabularyEntry(), { + authority: 2, display: 'NameTwo, LastnameTwo', value: 2 }), ]; let de = lookupFixture.debugElement.queryAll(By.css('button')); const btnEl = de[0].nativeElement; - const selectedValue = Object.assign(new AuthorityValue(), { - id: 1, + const selectedValue = Object.assign(new VocabularyEntry(), { + authority: 1, display: 'Name, Lastname', value: 1 }); spyOn(lookupComp.change, 'emit'); - authorityServiceStub.setNewPayload(payload); + vocabularyServiceStub.setNewPayload(payload); lookupComp.firstInputValue = 'test'; lookupFixture.detectChanges(); btnEl.click(); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts index d5516df6d9..24422e891a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts @@ -1,8 +1,7 @@ import { ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { FormGroup } from '@angular/forms'; -import { Subscription } from 'rxjs'; -import { of as observableOf } from 'rxjs'; +import { of as observableOf, Subscription } from 'rxjs'; import { catchError, distinctUntilChanged } from 'rxjs/operators'; import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'; import { @@ -11,16 +10,16 @@ import { DynamicFormValidationService } from '@ng-dynamic-forms/core'; -import { AuthorityService } from '../../../../../../core/integration/authority.service'; -import { DynamicLookupModel } from './dynamic-lookup.model'; -import { IntegrationSearchOptions } from '../../../../../../core/integration/models/integration-options.model'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +import { VocabularyFindOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-find-options.model'; import { hasValue, isEmpty, isNotEmpty, isNull, isUndefined } from '../../../../../empty.util'; -import { IntegrationData } from '../../../../../../core/integration/integration-data'; import { PageInfo } from '../../../../../../core/shared/page-info.model'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; -import { AuthorityValue } from '../../../../../../core/integration/models/authority.value'; +import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { DynamicLookupNameModel } from './dynamic-lookup-name.model'; -import { ConfidenceType } from '../../../../../../core/integration/models/confidence-type'; +import { ConfidenceType } from '../../../../../../core/shared/confidence-type'; +import { PaginatedList } from '../../../../../../core/data/paginated-list'; +import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; @Component({ selector: 'ds-dynamic-lookup', @@ -43,10 +42,10 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem public pageInfo: PageInfo; public optionsList: any; - protected searchOptions: IntegrationSearchOptions; + protected searchOptions: VocabularyFindOptions; protected subs: Subscription[] = []; - constructor(private authorityService: AuthorityService, + constructor(private vocabularyService: VocabularyService, private cdr: ChangeDetectorRef, protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService @@ -59,10 +58,10 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem }; ngOnInit() { - this.searchOptions = new IntegrationSearchOptions( - this.model.authorityOptions.scope, - this.model.authorityOptions.name, - this.model.authorityOptions.metadata, + this.searchOptions = new VocabularyFindOptions( + this.model.vocabularyOptions.scope, + this.model.vocabularyOptions.name, + this.model.vocabularyOptions.metadata, '', this.model.maxOptions, 1); @@ -79,6 +78,148 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem })); } + public formatItemForInput(item: any, field: number): string { + if (isUndefined(item) || isNull(item)) { + return ''; + } + return (typeof item === 'string') ? item : this.inputFormatter(item, field); + } + + public hasAuthorityValue() { + return hasValue(this.model.value) + && this.model.value.hasAuthority(); + } + + public hasEmptyValue() { + return isNotEmpty(this.getCurrentValue()); + } + + public clearFields() { + // Clear inputs whether there is no results and authority is closed + if (this.model.vocabularyOptions.closed) { + this.resetFields(); + } + } + + public isEditDisabled() { + return !this.hasAuthorityValue(); + } + + public isInputDisabled() { + return (this.model.vocabularyOptions.closed && this.hasAuthorityValue() && !this.editMode); + } + + public isLookupName() { + return (this.model instanceof DynamicLookupNameModel); + } + + public isSearchDisabled() { + return isEmpty(this.firstInputValue) || this.editMode; + } + + public onBlurEvent(event: Event) { + this.blur.emit(event); + } + + public onFocusEvent(event) { + this.focus.emit(event); + } + + public onChange(event) { + event.preventDefault(); + if (!this.model.vocabularyOptions.closed) { + if (isNotEmpty(this.getCurrentValue())) { + const currentValue = new FormFieldMetadataValueObject(this.getCurrentValue()); + if (!this.editMode) { + this.updateModel(currentValue); + } + } else { + this.remove(); + } + } + } + + public onScroll() { + if (!this.loading && this.pageInfo.currentPage <= this.pageInfo.totalPages) { + this.searchOptions.currentPage++; + this.search(); + } + } + + public onSelect(event) { + this.updateModel(event); + } + + public openChange(isOpened: boolean) { + if (!isOpened) { + if (this.model.vocabularyOptions.closed && !this.hasAuthorityValue()) { + this.setInputsValue(''); + } + } + } + + public remove() { + this.group.markAsPristine(); + this.model.valueUpdates.next(null); + this.change.emit(null); + } + + public saveChanges() { + if (isNotEmpty(this.getCurrentValue())) { + const newValue = Object.assign(new VocabularyEntry(), this.model.value, { + display: this.getCurrentValue(), + value: this.getCurrentValue() + }); + this.updateModel(newValue); + } else { + this.remove(); + } + this.switchEditMode(); + } + + public search() { + this.optionsList = null; + this.pageInfo = null; + + // Query + this.searchOptions.query = this.getCurrentValue(); + + this.loading = true; + this.subs.push(this.vocabularyService.getVocabularyEntries(this.searchOptions).pipe( + getFirstSucceededRemoteDataPayload(), + catchError(() => + observableOf(new PaginatedList( + new PageInfo(), + [] + )) + ), + distinctUntilChanged()) + .subscribe((list: PaginatedList) => { + console.log(list); + this.optionsList = list.page; + this.pageInfo = list.pageInfo; + this.loading = false; + this.cdr.detectChanges(); + })); + } + + public switchEditMode() { + this.editMode = !this.editMode; + } + + public whenClickOnConfidenceNotAccepted(sdRef: NgbDropdown, confidence: ConfidenceType) { + if (!this.model.readOnly) { + sdRef.open(); + this.search(); + } + } + + ngOnDestroy() { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + protected getCurrentValue(): string { let result = ''; if (!this.isLookupName()) { @@ -106,7 +247,7 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem protected setInputsValue(value) { if (hasValue(value)) { let displayValue = value; - if (value instanceof FormFieldMetadataValueObject || value instanceof AuthorityValue) { + if (value instanceof FormFieldMetadataValueObject || value instanceof VocabularyEntry) { displayValue = value.display; } @@ -131,145 +272,4 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem this.optionsList = null; this.pageInfo = null; } - - public formatItemForInput(item: any, field: number): string { - if (isUndefined(item) || isNull(item)) { - return ''; - } - return (typeof item === 'string') ? item : this.inputFormatter(item, field); - } - - public hasAuthorityValue() { - return hasValue(this.model.value) - && this.model.value.hasAuthority(); - } - - public hasEmptyValue() { - return isNotEmpty(this.getCurrentValue()); - } - - public clearFields() { - // Clear inputs whether there is no results and authority is closed - if (this.model.authorityOptions.closed) { - this.resetFields(); - } - } - - public isEditDisabled() { - return !this.hasAuthorityValue(); - } - - public isInputDisabled() { - return (this.model.authorityOptions.closed && this.hasAuthorityValue() && !this.editMode); - } - - public isLookupName() { - return (this.model instanceof DynamicLookupNameModel); - } - - public isSearchDisabled() { - return isEmpty(this.firstInputValue) || this.editMode; - } - - public onBlurEvent(event: Event) { - this.blur.emit(event); - } - - public onFocusEvent(event) { - this.focus.emit(event); - } - - public onChange(event) { - event.preventDefault(); - if (!this.model.authorityOptions.closed) { - if (isNotEmpty(this.getCurrentValue())) { - const currentValue = new FormFieldMetadataValueObject(this.getCurrentValue()); - if (!this.editMode) { - this.updateModel(currentValue); - } - } else { - this.remove(); - } - } - } - - public onScroll() { - if (!this.loading && this.pageInfo.currentPage <= this.pageInfo.totalPages) { - this.searchOptions.currentPage++; - this.search(); - } - } - - public onSelect(event) { - this.updateModel(event); - } - - public openChange(isOpened: boolean) { - if (!isOpened) { - if (this.model.authorityOptions.closed && !this.hasAuthorityValue()) { - this.setInputsValue(''); - } - } - } - - public remove() { - this.group.markAsPristine(); - this.model.valueUpdates.next(null); - this.change.emit(null); - } - - public saveChanges() { - if (isNotEmpty(this.getCurrentValue())) { - const newValue = Object.assign(new AuthorityValue(), this.model.value, { - display: this.getCurrentValue(), - value: this.getCurrentValue() - }); - this.updateModel(newValue); - } else { - this.remove(); - } - this.switchEditMode(); - } - - public search() { - this.optionsList = null; - this.pageInfo = null; - - // Query - this.searchOptions.query = this.getCurrentValue(); - - this.loading = true; - this.subs.push(this.authorityService.getEntriesByName(this.searchOptions).pipe( - catchError(() => { - const emptyResult = new IntegrationData( - new PageInfo(), - [] - ); - return observableOf(emptyResult); - }), - distinctUntilChanged()) - .subscribe((object: IntegrationData) => { - this.optionsList = object.payload; - this.pageInfo = object.pageInfo; - this.loading = false; - this.cdr.detectChanges(); - })); - } - - public switchEditMode() { - this.editMode = !this.editMode; - } - - public whenClickOnConfidenceNotAccepted(sdRef: NgbDropdown, confidence: ConfidenceType) { - if (!this.model.readOnly) { - sdRef.open(); - this.search(); - } - } - - ngOnDestroy() { - this.subs - .filter((sub) => hasValue(sub)) - .forEach((sub) => sub.unsubscribe()); - } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts index bcddb52123..64cf658d47 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts @@ -2,9 +2,12 @@ import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, inject, TestBed, } from '@angular/core/testing'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { Store, StoreModule } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; import { DsDynamicRelationGroupComponent } from './dynamic-relation-group.components'; import { DynamicRelationGroupModel, DynamicRelationGroupModelConfig } from './dynamic-relation-group.model'; @@ -13,18 +16,14 @@ import { FormFieldModel } from '../../../models/form-field.model'; import { FormBuilderService } from '../../../form-builder.service'; import { FormService } from '../../../../form.service'; import { FormComponent } from '../../../../form.component'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { Chips } from '../../../../../chips/models/chips.model'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; import { DsDynamicInputModel } from '../ds-dynamic-input.model'; import { createTestComponent } from '../../../../../testing/utils.test'; -import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; -import { AuthorityService } from '../../../../../../core/integration/authority.service'; -import { AuthorityServiceStub } from '../../../../../testing/authority-service.stub'; -import { Store, StoreModule } from '@ngrx/store'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +import { VocabularyServiceStub } from '../../../../../testing/vocabulary-service.stub'; import { StoreMock } from '../../../../../testing/store.mock'; import { FormRowModel } from '../../../../../../core/config/models/config-submission-form.model'; -import { GlobalConfig } from '../../../../../../../config/global-config.interface'; import { storeModuleConfig } from '../../../../../../app.reducer'; export let FORM_GROUP_TEST_MODEL_CONFIG; @@ -47,7 +46,7 @@ function init() { mandatoryMessage: 'Required field!', repeatable: false, selectableMetadata: [{ - authority: 'RPAuthority', + controlledVocabulary: 'RPAuthority', closed: false, metadata: 'dc.contributor.author' }], @@ -61,7 +60,7 @@ function init() { mandatory: 'false', repeatable: false, selectableMetadata: [{ - authority: 'OUAuthority', + controlledVocabulary: 'OUAuthority', closed: false, metadata: 'local.contributor.affiliation' }] @@ -128,7 +127,7 @@ describe('DsDynamicRelationGroupComponent test suite', () => { FormBuilderService, FormComponent, FormService, - { provide: AuthorityService, useValue: new AuthorityServiceStub() }, + { provide: VocabularyService, useValue: new VocabularyServiceStub() }, { provide: Store, useClass: StoreMock } ], schemas: [CUSTOM_ELEMENTS_SCHEMA] diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts index 11085a1bc3..21606d7abc 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts @@ -1,14 +1,4 @@ -import { - ChangeDetectorRef, - Component, - EventEmitter, - Inject, - Input, - OnDestroy, - OnInit, - Output, - ViewChild -} from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { combineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; @@ -33,12 +23,12 @@ import { hasValue, isEmpty, isNotEmpty, isNotNull } from '../../../../../empty.u import { shrinkInOut } from '../../../../../animations/shrink'; import { ChipsItem } from '../../../../../chips/models/chips-item.model'; import { hasOnlyEmptyProperties } from '../../../../../object.util'; -import { IntegrationSearchOptions } from '../../../../../../core/integration/models/integration-options.model'; -import { AuthorityService } from '../../../../../../core/integration/authority.service'; -import { IntegrationData } from '../../../../../../core/integration/integration-data'; +import { VocabularyFindOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-find-options.model'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; -import { AuthorityValue } from '../../../../../../core/integration/models/authority.value'; import { environment } from '../../../../../../../environments/environment'; +import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; +import { VocabularyEntryDetail } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; @Component({ selector: 'ds-dynamic-relation-group', @@ -64,9 +54,9 @@ export class DsDynamicRelationGroupComponent extends DynamicFormControlComponent private selectedChipItem: ChipsItem; private subs: Subscription[] = []; - @ViewChild('formRef', {static: false}) private formRef: FormComponent; + @ViewChild('formRef', { static: false }) private formRef: FormComponent; - constructor(private authorityService: AuthorityService, + constructor(private vocabularyService: VocabularyService, private formBuilderService: FormBuilderService, private formService: FormService, private cdr: ChangeDetectorRef, @@ -177,6 +167,12 @@ export class DsDynamicRelationGroupComponent extends DynamicFormControlComponent this.clear(); } + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + private addToChips() { if (!this.formRef.formGroup.valid) { this.formService.validateAllFormFields(this.formRef.formGroup); @@ -235,20 +231,16 @@ export class DsDynamicRelationGroupComponent extends DynamicFormControlComponent if (isObject(valueObj[fieldName]) && valueObj[fieldName].hasAuthority() && isNotEmpty(valueObj[fieldName].authority)) { const fieldId = fieldName.replace(/\./g, '_'); const model = this.formBuilderService.findById(fieldId, this.formModel); - const searchOptions: IntegrationSearchOptions = new IntegrationSearchOptions( - (model as any).authorityOptions.scope, - (model as any).authorityOptions.name, - (model as any).authorityOptions.metadata, + return$ = this.vocabularyService.findEntryDetailByValue( valueObj[fieldName].authority, - (model as any).maxOptions, - 1); - - return$ = this.authorityService.getEntryByValue(searchOptions).pipe( - map((result: IntegrationData) => Object.assign( + (model as any).vocabularyOptions.name + ).pipe( + getFirstSucceededRemoteDataPayload(), + map((entryDetail: VocabularyEntryDetail) => Object.assign( new FormFieldMetadataValueObject(), valueObj[fieldName], { - otherInformation: (result.payload[0] as AuthorityValue).otherInformation + otherInformation: entryDetail.otherInformation }) )); } else { @@ -315,10 +307,4 @@ export class DsDynamicRelationGroupComponent extends DynamicFormControlComponent } } - ngOnDestroy(): void { - this.subs - .filter((sub) => hasValue(sub)) - .forEach((sub) => sub.unsubscribe()); - } - } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts index 6086444264..c06908bdc5 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts @@ -9,12 +9,12 @@ import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { DynamicFormLayoutService, DynamicFormsCoreModule, DynamicFormValidationService } from '@ng-dynamic-forms/core'; import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; -import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; -import { AuthorityService } from '../../../../../../core/integration/authority.service'; -import { AuthorityServiceStub } from '../../../../../testing/authority-service.stub'; +import { VocabularyOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-options.model'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +import { VocabularyServiceStub } from '../../../../../testing/vocabulary-service.stub'; import { DsDynamicScrollableDropdownComponent } from './dynamic-scrollable-dropdown.component'; import { DynamicScrollableDropdownModel } from './dynamic-scrollable-dropdown.model'; -import { AuthorityValue } from '../../../../../../core/integration/models/authority.value'; +import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { createTestComponent, hasClass } from '../../../../../testing/utils.test'; export const SD_TEST_GROUP = new FormGroup({ @@ -22,14 +22,14 @@ export const SD_TEST_GROUP = new FormGroup({ }); export const SD_TEST_MODEL_CONFIG = { - authorityOptions: { + vocabularyOptions: { closed: false, metadata: 'dropdown', name: 'common_iso_languages', scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, + } as VocabularyOptions, disabled: false, - errorMessages: {required: 'Required field.'}, + errorMessages: { required: 'Required field.' }, id: 'dropdown', label: 'Language', maxOptions: 10, @@ -52,7 +52,7 @@ describe('Dynamic Dynamic Scrollable Dropdown component', () => { let html; let modelValue; - const authorityServiceStub = new AuthorityServiceStub(); + const vocabularyServiceStub = new VocabularyServiceStub(); // async beforeEach beforeEach(async(() => { @@ -74,9 +74,9 @@ describe('Dynamic Dynamic Scrollable Dropdown component', () => { providers: [ ChangeDetectorRef, DsDynamicScrollableDropdownComponent, - {provide: AuthorityService, useValue: authorityServiceStub}, - {provide: DynamicFormLayoutService, useValue: {}}, - {provide: DynamicFormValidationService, useValue: {}} + { provide: VocabularyService, useValue: vocabularyServiceStub }, + { provide: DynamicFormLayoutService, useValue: {} }, + { provide: DynamicFormValidationService, useValue: {} } ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); @@ -121,11 +121,8 @@ describe('Dynamic Dynamic Scrollable Dropdown component', () => { }); it('should init component properly', () => { - const results$ = authorityServiceStub.getEntriesByName({} as any); expect(scrollableDropdownComp.optionsList).toBeDefined(); - results$.subscribe((results) => { - expect(scrollableDropdownComp.optionsList).toEqual(results.payload); - }) + expect(scrollableDropdownComp.optionsList).toEqual(vocabularyServiceStub.getList()); }); it('should display dropdown menu entries', () => { @@ -154,7 +151,7 @@ describe('Dynamic Dynamic Scrollable Dropdown component', () => { })); it('should select a results entry properly', fakeAsync(() => { - const selectedValue = Object.assign(new AuthorityValue(), {id: 1, display: 'one', value: 1}); + const selectedValue = Object.assign(new VocabularyEntry(), { authority: 1, display: 'one', value: 1 }); let de: any = scrollableDropdownFixture.debugElement.query(By.css('button.ds-form-input-btn')); let btnEl = de.nativeElement; @@ -192,7 +189,7 @@ describe('Dynamic Dynamic Scrollable Dropdown component', () => { scrollableDropdownFixture = TestBed.createComponent(DsDynamicScrollableDropdownComponent); scrollableDropdownComp = scrollableDropdownFixture.componentInstance; // FormComponent test instance scrollableDropdownComp.group = SD_TEST_GROUP; - modelValue = Object.assign(new AuthorityValue(), {id: 1, display: 'one', value: 1}); + modelValue = Object.assign(new VocabularyEntry(), { authority: 1, display: 'one', value: 1 }); scrollableDropdownComp.model = new DynamicScrollableDropdownModel(SD_TEST_MODEL_CONFIG); scrollableDropdownComp.model.value = modelValue; scrollableDropdownFixture.detectChanges(); @@ -204,12 +201,9 @@ describe('Dynamic Dynamic Scrollable Dropdown component', () => { }); it('should init component properly', () => { - const results$ = authorityServiceStub.getEntriesByName({} as any); expect(scrollableDropdownComp.optionsList).toBeDefined(); - results$.subscribe((results) => { - expect(scrollableDropdownComp.optionsList).toEqual(results.payload); - expect(scrollableDropdownComp.model.value).toEqual(modelValue); - }) + expect(scrollableDropdownComp.optionsList).toEqual(vocabularyServiceStub.getList()); + expect(scrollableDropdownComp.model.value).toEqual(modelValue); }); }); }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts index 5eda1372eb..f6442407c4 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } fro import { FormGroup } from '@angular/forms'; import { Observable, of as observableOf } from 'rxjs'; -import { catchError, distinctUntilChanged, first, tap } from 'rxjs/operators'; +import { catchError, distinctUntilChanged, tap } from 'rxjs/operators'; import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'; import { DynamicFormControlComponent, @@ -10,13 +10,14 @@ import { DynamicFormValidationService } from '@ng-dynamic-forms/core'; -import { AuthorityValue } from '../../../../../../core/integration/models/authority.value'; +import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { DynamicScrollableDropdownModel } from './dynamic-scrollable-dropdown.model'; import { PageInfo } from '../../../../../../core/shared/page-info.model'; import { isNull, isUndefined } from '../../../../../empty.util'; -import { AuthorityService } from '../../../../../../core/integration/authority.service'; -import { IntegrationSearchOptions } from '../../../../../../core/integration/models/integration-options.model'; -import { IntegrationData } from '../../../../../../core/integration/integration-data'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +import { VocabularyFindOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-find-options.model'; +import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; +import { PaginatedList } from '../../../../../../core/data/paginated-list'; @Component({ selector: 'ds-dynamic-scrollable-dropdown', @@ -37,9 +38,9 @@ export class DsDynamicScrollableDropdownComponent extends DynamicFormControlComp public pageInfo: PageInfo; public optionsList: any; - protected searchOptions: IntegrationSearchOptions; + protected searchOptions: VocabularyFindOptions; - constructor(private authorityService: AuthorityService, + constructor(private vocabularyService: VocabularyService, private cdr: ChangeDetectorRef, protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService @@ -48,28 +49,26 @@ export class DsDynamicScrollableDropdownComponent extends DynamicFormControlComp } ngOnInit() { - this.searchOptions = new IntegrationSearchOptions( - this.model.authorityOptions.scope, - this.model.authorityOptions.name, - this.model.authorityOptions.metadata, + this.searchOptions = new VocabularyFindOptions( + this.model.vocabularyOptions.scope, + this.model.vocabularyOptions.name, + this.model.vocabularyOptions.metadata, '', this.model.maxOptions, 1); - this.authorityService.getEntriesByName(this.searchOptions).pipe( - catchError(() => { - const emptyResult = new IntegrationData( - new PageInfo(), - [] - ); - return observableOf(emptyResult); - }), - first()) - .subscribe((object: IntegrationData) => { - this.optionsList = object.payload; + this.vocabularyService.getVocabularyEntries(this.searchOptions).pipe( + getFirstSucceededRemoteDataPayload(), + catchError(() => observableOf(new PaginatedList( + new PageInfo(), + [] + )) + )) + .subscribe((list: PaginatedList) => { + this.optionsList = list.page; if (this.model.value) { this.setCurrentValue(this.model.value); } - this.pageInfo = object.pageInfo; + this.pageInfo = list.pageInfo; this.cdr.detectChanges(); }); @@ -80,7 +79,7 @@ export class DsDynamicScrollableDropdownComponent extends DynamicFormControlComp } - inputFormatter = (x: AuthorityValue): string => x.display || x.value; + inputFormatter = (x: VocabularyEntry): string => x.display || x.value; openDropdown(sdRef: NgbDropdown) { if (!this.model.readOnly) { @@ -92,18 +91,17 @@ export class DsDynamicScrollableDropdownComponent extends DynamicFormControlComp if (!this.loading && this.pageInfo.currentPage <= this.pageInfo.totalPages) { this.loading = true; this.searchOptions.currentPage++; - this.authorityService.getEntriesByName(this.searchOptions).pipe( - catchError(() => { - const emptyResult = new IntegrationData( - new PageInfo(), - [] - ); - return observableOf(emptyResult); - }), + this.vocabularyService.getVocabularyEntries(this.searchOptions).pipe( + getFirstSucceededRemoteDataPayload(), + catchError(() => observableOf(new PaginatedList( + new PageInfo(), + [] + )) + ), tap(() => this.loading = false)) - .subscribe((object: IntegrationData) => { - this.optionsList = this.optionsList.concat(object.payload); - this.pageInfo = object.pageInfo; + .subscribe((list: PaginatedList) => { + this.optionsList = this.optionsList.concat(list.page); + this.pageInfo = list.pageInfo; this.cdr.detectChanges(); }) } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model.ts index 8ab497ec6e..a9974717e4 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model.ts @@ -1,11 +1,11 @@ import { AUTOCOMPLETE_OFF, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; import { DsDynamicInputModel, DsDynamicInputModelConfig } from '../ds-dynamic-input.model'; -import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; +import { VocabularyOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-options.model'; export const DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN = 'SCROLLABLE_DROPDOWN'; export interface DynamicScrollableDropdownModelConfig extends DsDynamicInputModelConfig { - authorityOptions: AuthorityOptions; + vocabularyOptions: VocabularyOptions; maxOptions?: number; value?: any; } @@ -20,7 +20,7 @@ export class DynamicScrollableDropdownModel extends DsDynamicInputModel { super(config, layout); this.autoComplete = AUTOCOMPLETE_OFF; - this.authorityOptions = config.authorityOptions; + this.vocabularyOptions = config.vocabularyOptions; this.maxOptions = config.maxOptions || 10; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts index 79d652623f..a85c215b5f 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts @@ -12,15 +12,14 @@ import { import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { NgbModule, NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; -import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; -import { AuthorityService } from '../../../../../../core/integration/authority.service'; -import { AuthorityServiceStub } from '../../../../../testing/authority-service.stub'; +import { VocabularyOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-options.model'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +import { VocabularyServiceStub } from '../../../../../testing/vocabulary-service.stub'; import { DsDynamicTagComponent } from './dynamic-tag.component'; import { DynamicTagModel } from './dynamic-tag.model'; -import { GlobalConfig } from '../../../../../../../config/global-config.interface'; import { Chips } from '../../../../../chips/models/chips.model'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; -import { AuthorityValue } from '../../../../../../core/integration/models/authority.value'; +import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { createTestComponent } from '../../../../../testing/utils.test'; function createKeyUpEvent(key: number) { @@ -45,12 +44,12 @@ function init() { }); TAG_TEST_MODEL_CONFIG = { - authorityOptions: { + vocabularyOptions: { closed: false, metadata: 'tag', name: 'common_iso_languages', scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, + } as VocabularyOptions, disabled: false, id: 'tag', label: 'Keywords', @@ -75,7 +74,7 @@ describe('DsDynamicTagComponent test suite', () => { // async beforeEach beforeEach(async(() => { - const authorityServiceStub = new AuthorityServiceStub(); + const vocabularyServiceStub = new VocabularyServiceStub(); init(); TestBed.configureTestingModule({ imports: [ @@ -92,7 +91,7 @@ describe('DsDynamicTagComponent test suite', () => { providers: [ ChangeDetectorRef, DsDynamicTagComponent, - { provide: AuthorityService, useValue: authorityServiceStub }, + { provide: VocabularyService, useValue: vocabularyServiceStub }, { provide: DynamicFormLayoutService, useValue: {} }, { provide: DynamicFormValidationService, useValue: {} } ], @@ -124,7 +123,7 @@ describe('DsDynamicTagComponent test suite', () => { })); }); - describe('when authorityOptions are set', () => { + describe('when vocabularyOptions are set', () => { describe('and init model value is empty', () => { beforeEach(() => { @@ -148,20 +147,20 @@ describe('DsDynamicTagComponent test suite', () => { }); it('should search when 3+ characters typed', fakeAsync(() => { - spyOn((tagComp as any).authorityService, 'getEntriesByName').and.callThrough(); + spyOn((tagComp as any).vocabularyService, 'getVocabularyEntries').and.callThrough(); tagComp.search(observableOf('test')).subscribe(() => { - expect((tagComp as any).authorityService.getEntriesByName).toHaveBeenCalled(); + expect((tagComp as any).vocabularyService.getVocabularyEntries).toHaveBeenCalled(); }); })); it('should select a results entry properly', fakeAsync(() => { modelValue = [ - Object.assign(new AuthorityValue(), { id: 1, display: 'Name, Lastname', value: 1 }) + Object.assign(new VocabularyEntry(), { authority: 1, display: 'Name, Lastname', value: 1 }) ]; const event: NgbTypeaheadSelectItemEvent = { - item: Object.assign(new AuthorityValue(), { - id: 1, + item: Object.assign(new VocabularyEntry(), { + authority: 1, display: 'Name, Lastname', value: 1 }), @@ -239,7 +238,7 @@ describe('DsDynamicTagComponent test suite', () => { }); - describe('when authorityOptions are not set', () => { + describe('when vocabularyOptions are not set', () => { describe('and init model value is empty', () => { beforeEach(() => { @@ -247,7 +246,7 @@ describe('DsDynamicTagComponent test suite', () => { tagComp = tagFixture.componentInstance; // FormComponent test instance tagComp.group = TAG_TEST_GROUP; const config = TAG_TEST_MODEL_CONFIG; - config.authorityOptions = null; + config.vocabularyOptions = null; tagComp.model = new DynamicTagModel(config); tagFixture.detectChanges(); }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts index 69a7a5a8c2..85d791da6d 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, EventEmitter, Inject, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { @@ -6,17 +6,21 @@ import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; -import { of as observableOf, Observable } from 'rxjs'; -import { catchError, debounceTime, distinctUntilChanged, tap, switchMap, map, merge } from 'rxjs/operators'; +import { Observable, of as observableOf } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged, map, merge, switchMap, tap } from 'rxjs/operators'; import { NgbTypeahead, NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; import { isEqual } from 'lodash'; -import { AuthorityService } from '../../../../../../core/integration/authority.service'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; import { DynamicTagModel } from './dynamic-tag.model'; -import { IntegrationSearchOptions } from '../../../../../../core/integration/models/integration-options.model'; +import { VocabularyFindOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-find-options.model'; import { Chips } from '../../../../../chips/models/chips.model'; import { hasValue, isNotEmpty } from '../../../../../empty.util'; import { environment } from '../../../../../../../environments/environment'; +import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; +import { PaginatedList } from '../../../../../../core/data/paginated-list'; +import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { PageInfo } from '../../../../../../core/shared/page-info.model'; @Component({ selector: 'ds-dynamic-tag', @@ -32,17 +36,25 @@ export class DsDynamicTagComponent extends DynamicFormControlComponent implement @Output() change: EventEmitter = new EventEmitter(); @Output() focus: EventEmitter = new EventEmitter(); - @ViewChild('instance', {static: false}) instance: NgbTypeahead; + @ViewChild('instance', { static: false }) instance: NgbTypeahead; chips: Chips; hasAuthority: boolean; searching = false; - searchOptions: IntegrationSearchOptions; + searchOptions: VocabularyFindOptions; searchFailed = false; hideSearchingWhenUnsubscribed = new Observable(() => () => this.changeSearchingStatus(false)); currentValue: any; + constructor(private vocabularyService: VocabularyService, + private cdr: ChangeDetectorRef, + protected layoutService: DynamicFormLayoutService, + protected validationService: DynamicFormValidationService + ) { + super(layoutService, validationService); + } + formatter = (x: { display: string }) => x.display; search = (text$: Observable) => @@ -52,44 +64,33 @@ export class DsDynamicTagComponent extends DynamicFormControlComponent implement tap(() => this.changeSearchingStatus(true)), switchMap((term) => { if (term === '' || term.length < this.model.minChars) { - return observableOf({list: []}); + return observableOf({ list: [] }); } else { this.searchOptions.query = term; - return this.authorityService.getEntriesByName(this.searchOptions).pipe( - map((authorities) => { - // @TODO Pagination for authority is not working, to refactor when it will be fixed - return { - list: authorities.payload, - pageInfo: authorities.pageInfo - }; - }), + return this.vocabularyService.getVocabularyEntries(this.searchOptions).pipe( + getFirstSucceededRemoteDataPayload(), tap(() => this.searchFailed = false), catchError(() => { this.searchFailed = true; - return observableOf({list: []}); + return observableOf(new PaginatedList( + new PageInfo(), + [] + )); })); } }), - map((results) => results.list), + map((list: PaginatedList) => list.page), tap(() => this.changeSearchingStatus(false)), merge(this.hideSearchingWhenUnsubscribed)); - constructor(private authorityService: AuthorityService, - private cdr: ChangeDetectorRef, - protected layoutService: DynamicFormLayoutService, - protected validationService: DynamicFormValidationService - ) { - super(layoutService, validationService); - } - ngOnInit() { - this.hasAuthority = this.model.authorityOptions && hasValue(this.model.authorityOptions.name); + this.hasAuthority = this.model.vocabularyOptions && hasValue(this.model.vocabularyOptions.name); if (this.hasAuthority) { - this.searchOptions = new IntegrationSearchOptions( - this.model.authorityOptions.scope, - this.model.authorityOptions.name, - this.model.authorityOptions.metadata); + this.searchOptions = new VocabularyFindOptions( + this.model.vocabularyOptions.scope, + this.model.vocabularyOptions.name, + this.model.vocabularyOptions.metadata); } this.chips = new Chips( @@ -166,7 +167,7 @@ export class DsDynamicTagComponent extends DynamicFormControlComponent implement } private addTagsToChips() { - if (hasValue(this.currentValue) && (!this.hasAuthority || !this.model.authorityOptions.closed)) { + if (hasValue(this.currentValue) && (!this.hasAuthority || !this.model.vocabularyOptions.closed)) { let res: string[] = []; res = this.currentValue.split(','); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts index f31f0eeff9..48456230bf 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts @@ -10,10 +10,9 @@ import { DynamicFormLayoutService, DynamicFormsCoreModule, DynamicFormValidation import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; -import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; -import { AuthorityService } from '../../../../../../core/integration/authority.service'; -import { AuthorityServiceStub } from '../../../../../testing/authority-service.stub'; -import { GlobalConfig } from '../../../../../../../config/global-config.interface'; +import { VocabularyOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-options.model'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +import { VocabularyServiceStub } from '../../../../../testing/vocabulary-service.stub'; import { DsDynamicTypeaheadComponent } from './dynamic-typeahead.component'; import { DynamicTypeaheadModel } from './dynamic-typeahead.model'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; @@ -31,12 +30,12 @@ function init() { }); TYPEAHEAD_TEST_MODEL_CONFIG = { - authorityOptions: { + vocabularyOptions: { closed: false, metadata: 'typeahead', name: 'EVENTAuthority', scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, + } as VocabularyOptions, disabled: false, id: 'typeahead', label: 'Conference', @@ -49,6 +48,7 @@ function init() { value: undefined }; } + describe('DsDynamicTypeaheadComponent test suite', () => { let testComp: TestComponent; @@ -59,7 +59,7 @@ describe('DsDynamicTypeaheadComponent test suite', () => { // async beforeEach beforeEach(async(() => { - const authorityServiceStub = new AuthorityServiceStub(); + const vocabularyServiceStub = new VocabularyServiceStub(); init(); TestBed.configureTestingModule({ imports: [ @@ -79,7 +79,7 @@ describe('DsDynamicTypeaheadComponent test suite', () => { providers: [ ChangeDetectorRef, DsDynamicTypeaheadComponent, - { provide: AuthorityService, useValue: authorityServiceStub }, + { provide: VocabularyService, useValue: vocabularyServiceStub }, { provide: DynamicFormLayoutService, useValue: {} }, { provide: DynamicFormValidationService, useValue: {} } ], @@ -134,17 +134,17 @@ describe('DsDynamicTypeaheadComponent test suite', () => { it('should search when 3+ characters typed', fakeAsync(() => { - spyOn((typeaheadComp as any).authorityService, 'getEntriesByName').and.callThrough(); + spyOn((typeaheadComp as any).vocabularyService, 'getVocabularyEntries').and.callThrough(); typeaheadComp.search(observableOf('test')).subscribe(); tick(300); typeaheadFixture.detectChanges(); - expect((typeaheadComp as any).authorityService.getEntriesByName).toHaveBeenCalled(); + expect((typeaheadComp as any).vocabularyService.getVocabularyEntries).toHaveBeenCalled(); })); - it('should set model.value on input type when AuthorityOptions.closed is false', () => { + it('should set model.value on input type when VocabularyOptions.closed is false', () => { const inputDe = typeaheadFixture.debugElement.query(By.css('input.form-control')); const inputElement = inputDe.nativeElement; @@ -155,8 +155,8 @@ describe('DsDynamicTypeaheadComponent test suite', () => { }); - it('should not set model.value on input type when AuthorityOptions.closed is true', () => { - typeaheadComp.model.authorityOptions.closed = true; + it('should not set model.value on input type when VocabularyOptions.closed is true', () => { + typeaheadComp.model.vocabularyOptions.closed = true; typeaheadFixture.detectChanges(); const inputDe = typeaheadFixture.debugElement.query(By.css('input.form-control')); const inputElement = inputDe.nativeElement; @@ -184,18 +184,18 @@ describe('DsDynamicTypeaheadComponent test suite', () => { expect(typeaheadComp.blur.emit).not.toHaveBeenCalled(); }); - it('should emit change Event onBlur when AuthorityOptions.closed is false and inputValue is changed', () => { + it('should emit change Event onBlur when VocabularyOptions.closed is false and inputValue is changed', () => { typeaheadComp.inputValue = 'test value'; typeaheadFixture.detectChanges(); spyOn(typeaheadComp.blur, 'emit'); spyOn(typeaheadComp.change, 'emit'); spyOn(typeaheadComp.instance, 'isPopupOpen').and.returnValue(false); - typeaheadComp.onBlur(new Event('blur', )); + typeaheadComp.onBlur(new Event('blur',)); expect(typeaheadComp.change.emit).toHaveBeenCalled(); expect(typeaheadComp.blur.emit).toHaveBeenCalled(); }); - it('should not emit change Event onBlur when AuthorityOptions.closed is false and inputValue is not changed', () => { + it('should not emit change Event onBlur when VocabularyOptions.closed is false and inputValue is not changed', () => { typeaheadComp.inputValue = 'test value'; typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); (typeaheadComp.model as any).value = 'test value'; @@ -203,12 +203,12 @@ describe('DsDynamicTypeaheadComponent test suite', () => { spyOn(typeaheadComp.blur, 'emit'); spyOn(typeaheadComp.change, 'emit'); spyOn(typeaheadComp.instance, 'isPopupOpen').and.returnValue(false); - typeaheadComp.onBlur(new Event('blur', )); + typeaheadComp.onBlur(new Event('blur',)); expect(typeaheadComp.change.emit).not.toHaveBeenCalled(); expect(typeaheadComp.blur.emit).toHaveBeenCalled(); }); - it('should not emit change Event onBlur when AuthorityOptions.closed is false and inputValue is null', () => { + it('should not emit change Event onBlur when VocabularyOptions.closed is false and inputValue is null', () => { typeaheadComp.inputValue = null; typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); (typeaheadComp.model as any).value = 'test value'; @@ -216,7 +216,7 @@ describe('DsDynamicTypeaheadComponent test suite', () => { spyOn(typeaheadComp.blur, 'emit'); spyOn(typeaheadComp.change, 'emit'); spyOn(typeaheadComp.instance, 'isPopupOpen').and.returnValue(false); - typeaheadComp.onBlur(new Event('blur', )); + typeaheadComp.onBlur(new Event('blur',)); expect(typeaheadComp.change.emit).not.toHaveBeenCalled(); expect(typeaheadComp.blur.emit).toHaveBeenCalled(); }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts index 791704a7ca..67030682a5 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts @@ -10,12 +10,16 @@ import { catchError, debounceTime, distinctUntilChanged, filter, map, merge, swi import { Observable, of as observableOf, Subject } from 'rxjs'; import { NgbTypeahead, NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; -import { AuthorityService } from '../../../../../../core/integration/authority.service'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; import { DynamicTypeaheadModel } from './dynamic-typeahead.model'; -import { IntegrationSearchOptions } from '../../../../../../core/integration/models/integration-options.model'; +import { VocabularyFindOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-find-options.model'; import { isEmpty, isNotEmpty, isNotNull } from '../../../../../empty.util'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; -import { ConfidenceType } from '../../../../../../core/integration/models/confidence-type'; +import { ConfidenceType } from '../../../../../../core/shared/confidence-type'; +import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; +import { PaginatedList } from '../../../../../../core/data/paginated-list'; +import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { PageInfo } from '../../../../../../core/shared/page-info.model'; @Component({ selector: 'ds-dynamic-typeahead', @@ -31,16 +35,24 @@ export class DsDynamicTypeaheadComponent extends DynamicFormControlComponent imp @Output() change: EventEmitter = new EventEmitter(); @Output() focus: EventEmitter = new EventEmitter(); - @ViewChild('instance', {static: false}) instance: NgbTypeahead; + @ViewChild('instance', { static: false }) instance: NgbTypeahead; searching = false; - searchOptions: IntegrationSearchOptions; + searchOptions: VocabularyFindOptions; searchFailed = false; hideSearchingWhenUnsubscribed$ = new Observable(() => () => this.changeSearchingStatus(false)); click$ = new Subject(); currentValue: any; inputValue: any; + constructor(private vocabularyService: VocabularyService, + private cdr: ChangeDetectorRef, + protected layoutService: DynamicFormLayoutService, + protected validationService: DynamicFormValidationService + ) { + super(layoutService, validationService); + } + formatter = (x: { display: string }) => { return (typeof x === 'object') ? x.display : x }; @@ -53,44 +65,33 @@ export class DsDynamicTypeaheadComponent extends DynamicFormControlComponent imp tap(() => this.changeSearchingStatus(true)), switchMap((term) => { if (term === '' || term.length < this.model.minChars) { - return observableOf({list: []}); + return observableOf({ list: [] }); } else { this.searchOptions.query = term; - return this.authorityService.getEntriesByName(this.searchOptions).pipe( - map((authorities) => { - // @TODO Pagination for authority is not working, to refactor when it will be fixed - return { - list: authorities.payload, - pageInfo: authorities.pageInfo - }; - }), + return this.vocabularyService.getVocabularyEntries(this.searchOptions).pipe( + getFirstSucceededRemoteDataPayload(), tap(() => this.searchFailed = false), catchError(() => { this.searchFailed = true; - return observableOf({list: []}); + return observableOf(new PaginatedList( + new PageInfo(), + [] + )); })); } }), - map((results) => results.list), + map((list: PaginatedList) => list.page), tap(() => this.changeSearchingStatus(false)), merge(this.hideSearchingWhenUnsubscribed$) ) }; - constructor(private authorityService: AuthorityService, - private cdr: ChangeDetectorRef, - protected layoutService: DynamicFormLayoutService, - protected validationService: DynamicFormValidationService - ) { - super(layoutService, validationService); - } - ngOnInit() { this.currentValue = this.model.value; - this.searchOptions = new IntegrationSearchOptions( - this.model.authorityOptions.scope, - this.model.authorityOptions.name, - this.model.authorityOptions.metadata); + this.searchOptions = new VocabularyFindOptions( + this.model.vocabularyOptions.scope, + this.model.vocabularyOptions.name, + this.model.vocabularyOptions.metadata); this.group.get(this.model.id).valueChanges.pipe( filter((value) => this.currentValue !== value)) .subscribe((value) => { @@ -104,14 +105,14 @@ export class DsDynamicTypeaheadComponent extends DynamicFormControlComponent imp } onInput(event) { - if (!this.model.authorityOptions.closed && isNotEmpty(event.target.value)) { + if (!this.model.vocabularyOptions.closed && isNotEmpty(event.target.value)) { this.inputValue = new FormFieldMetadataValueObject(event.target.value); } } onBlur(event: Event) { if (!this.instance.isPopupOpen()) { - if (!this.model.authorityOptions.closed && isNotEmpty(this.inputValue)) { + if (!this.model.vocabularyOptions.closed && isNotEmpty(this.inputValue)) { if (isNotNull(this.inputValue) && this.model.value !== this.inputValue) { this.model.valueUpdates.next(this.inputValue); this.change.emit(this.inputValue); diff --git a/src/app/shared/form/builder/form-builder.service.spec.ts b/src/app/shared/form/builder/form-builder.service.spec.ts index 972abb68b5..434fe6a2e1 100644 --- a/src/app/shared/form/builder/form-builder.service.spec.ts +++ b/src/app/shared/form/builder/form-builder.service.spec.ts @@ -36,7 +36,7 @@ import { DynamicLookupModel } from './ds-dynamic-form-ui/models/lookup/dynamic-l import { DynamicDsDatePickerModel } from './ds-dynamic-form-ui/models/date-picker/date-picker.model'; import { DynamicTypeaheadModel } from './ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.model'; import { DynamicListRadioGroupModel } from './ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model'; -import { AuthorityOptions } from '../../../core/integration/models/authority-options.model'; +import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model'; import { FormFieldModel } from './models/form-field.model'; import { SubmissionFormsModel @@ -78,7 +78,7 @@ describe('FormBuilderService test suite', () => { ] }); - const authorityOptions: AuthorityOptions = { + const vocabularyOptions: VocabularyOptions = { closed: false, metadata: 'list', name: 'type_programme', @@ -197,13 +197,13 @@ describe('FormBuilderService test suite', () => { new DynamicTypeaheadModel({id: 'testTypeahead', repeatable: false, metadataFields: [], submissionId: '1234'}), - new DynamicScrollableDropdownModel({id: 'testScrollableDropdown', authorityOptions: authorityOptions, repeatable: false, metadataFields: [], submissionId: '1234'}), + new DynamicScrollableDropdownModel({id: 'testScrollableDropdown', vocabularyOptions: vocabularyOptions, repeatable: false, metadataFields: [], submissionId: '1234'}), new DynamicTagModel({id: 'testTag', repeatable: false, metadataFields: [], submissionId: '1234'}), - new DynamicListCheckboxGroupModel({id: 'testCheckboxList', authorityOptions: authorityOptions, repeatable: true}), + new DynamicListCheckboxGroupModel({id: 'testCheckboxList', vocabularyOptions: vocabularyOptions, repeatable: true}), - new DynamicListRadioGroupModel({id: 'testRadioList', authorityOptions: authorityOptions, repeatable: false}), + new DynamicListRadioGroupModel({id: 'testRadioList', vocabularyOptions: vocabularyOptions, repeatable: false}), new DynamicRelationGroupModel({ submissionId, @@ -218,7 +218,7 @@ describe('FormBuilderService test suite', () => { mandatoryMessage: 'Required field!', repeatable: false, selectableMetadata: [{ - authority: 'RPAuthority', + controlledVocabulary: 'RPAuthority', closed: false, metadata: 'dc.contributor.author' }], @@ -232,7 +232,7 @@ describe('FormBuilderService test suite', () => { mandatory: 'false', repeatable: false, selectableMetadata: [{ - authority: 'OUAuthority', + controlledVocabulary: 'OUAuthority', closed: false, metadata: 'local.contributor.affiliation' }] @@ -284,7 +284,7 @@ describe('FormBuilderService test suite', () => { selectableMetadata: [ { metadata: 'journal', - authority: 'JOURNALAuthority', + controlledVocabulary: 'JOURNALAuthority', closed: false } ], @@ -364,7 +364,7 @@ describe('FormBuilderService test suite', () => { selectableMetadata: [ { metadata: 'conference', - authority: 'EVENTAuthority', + controlledVocabulary: 'EVENTAuthority', closed: false } ], diff --git a/src/app/shared/form/builder/models/form-field-metadata-value.model.ts b/src/app/shared/form/builder/models/form-field-metadata-value.model.ts index 45489e3618..ff0afe97fd 100644 --- a/src/app/shared/form/builder/models/form-field-metadata-value.model.ts +++ b/src/app/shared/form/builder/models/form-field-metadata-value.model.ts @@ -1,5 +1,5 @@ import { isEmpty, isNotEmpty, isNotNull } from '../../../empty.util'; -import { ConfidenceType } from '../../../../core/integration/models/confidence-type'; +import { ConfidenceType } from '../../../../core/shared/confidence-type'; import { PLACEHOLDER_PARENT_METADATA } from '../ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; import { MetadataValueInterface } from '../../../../core/shared/metadata.models'; @@ -7,15 +7,16 @@ export interface OtherInformation { [name: string]: string } +/** + * A class representing a specific input-form field's value + */ export class FormFieldMetadataValueObject implements MetadataValueInterface { - metadata?: string; value: any; display: string; language: any; authority: string; confidence: ConfidenceType; place: number; - closed: boolean; label: string; otherInformation: OtherInformation; @@ -42,9 +43,6 @@ export class FormFieldMetadataValueObject implements MetadataValueInterface { } this.place = place; - if (isNotEmpty(metadata)) { - this.metadata = metadata; - } this.otherInformation = otherInformation; } diff --git a/src/app/shared/form/builder/models/form-field.model.ts b/src/app/shared/form/builder/models/form-field.model.ts index 718b3f4f0d..95ee980aeb 100644 --- a/src/app/shared/form/builder/models/form-field.model.ts +++ b/src/app/shared/form/builder/models/form-field.model.ts @@ -1,50 +1,121 @@ import { autoserialize } from 'cerialize'; + import { LanguageCode } from './form-field-language-value.model'; -import { FormFieldMetadataValueObject } from './form-field-metadata-value.model'; import { RelationshipOptions } from './relationship-options.model'; import { FormRowModel } from '../../../../core/config/models/config-submission-form.model'; +/** + * Representing SelectableMetadata properties + */ +export interface SelectableMetadata { + /** + * The key of the metadata field to use to store the input + */ + metadata: string; + + /** + * The label of the metadata field to use to store the input + */ + label: string; + + /** + * The name of the controlled vocabulary used to retrieve value for the input see controlled vocabularies + */ + controlledVocabulary: string; + + /** + * A boolean representing if value is closely related to the controlled vocabulary entry or not + */ + closed: boolean; +} + +/** + * A class representing a specific input-form field + */ export class FormFieldModel { + /** + * The hints for this metadata field to display on form + */ @autoserialize hints: string; + /** + * The label for this metadata field to display on form + */ @autoserialize label: string; + /** + * The languages available for this metadata field to display on form + */ @autoserialize languageCodes: LanguageCode[]; + /** + * The error message for this metadata field to display on form in case of field is required + */ @autoserialize mandatoryMessage: string; + /** + * Representing if this metadata field is mandatory or not + */ @autoserialize mandatory: string; + /** + * Representing if this metadata field is repeatable or not + */ @autoserialize repeatable: boolean; + /** + * Containing additional properties for this metadata field + */ @autoserialize input: { + /** + * Representing the type for this metadata field + */ type: string; + + /** + * Containing regex to use for field validation + */ regex?: string; }; + /** + * Representing additional vocabulary configuration for this metadata field + */ @autoserialize - selectableMetadata: FormFieldMetadataValueObject[]; + selectableMetadata: SelectableMetadata[]; + /** + * Representing additional relationship configuration for this metadata field + */ @autoserialize selectableRelationship: RelationshipOptions; @autoserialize rows: FormRowModel[]; + /** + * Representing the scope for this metadata field + */ @autoserialize scope: string; + /** + * Containing additional css classes for this metadata field to use on form + */ @autoserialize style: string; + /** + * Containing the value for this metadata field + */ @autoserialize value: any; } diff --git a/src/app/shared/form/builder/parsers/date-field-parser.spec.ts b/src/app/shared/form/builder/parsers/date-field-parser.spec.ts index efa4f3cdb5..b9adf3ed65 100644 --- a/src/app/shared/form/builder/parsers/date-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/date-field-parser.spec.ts @@ -12,7 +12,7 @@ describe('DateFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: null, - authorityUuid: null + collectionUUID: null }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts b/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts index c885b737c2..e7e64feb7b 100644 --- a/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts @@ -12,7 +12,7 @@ describe('DisabledFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: null, - authorityUuid: null + collectionUUID: null }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts b/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts index 8dbd68e05a..82d2aeac63 100644 --- a/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts @@ -11,7 +11,7 @@ describe('DropdownFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - authorityUuid: null + collectionUUID: null }; beforeEach(() => { @@ -26,7 +26,7 @@ describe('DropdownFieldParser test suite', () => { selectableMetadata: [ { metadata: 'type', - authority: 'common_types_dataset', + controlledVocabulary: 'common_types_dataset', closed: false } ], @@ -50,7 +50,7 @@ describe('DropdownFieldParser test suite', () => { }); it('should throw when authority is not passed', () => { - field.selectableMetadata[0].authority = null; + field.selectableMetadata[0].controlledVocabulary = null; const parser = new DropdownFieldParser(submissionId, field, initFormValues, parserOptions); expect(() => parser.parse()) diff --git a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts index 4816a2a073..7fb5fb206c 100644 --- a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts +++ b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts @@ -31,8 +31,8 @@ export class DropdownFieldParser extends FieldParser { const dropdownModelConfig: DynamicScrollableDropdownModelConfig = this.initModel(null, label); let layout: DynamicFormControlLayout; - if (isNotEmpty(this.configData.selectableMetadata[0].authority)) { - this.setAuthorityOptions(dropdownModelConfig, this.parserOptions.authorityUuid); + if (isNotEmpty(this.configData.selectableMetadata[0].controlledVocabulary)) { + this.setVocabularyOptions(dropdownModelConfig, this.parserOptions.collectionUUID); if (isNotEmpty(fieldValue)) { dropdownModelConfig.value = fieldValue; } @@ -47,7 +47,7 @@ export class DropdownFieldParser extends FieldParser { const dropdownModel = new DynamicScrollableDropdownModel(dropdownModelConfig, layout); return dropdownModel; } else { - throw Error(`Authority name is not available. Please check the form configuration file.`); + throw Error(`Controlled Vocabulary name is not available. Please check the form configuration file.`); } } } diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index f218d442e1..540c9c7b4e 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -1,19 +1,20 @@ import { Inject, InjectionToken } from '@angular/core'; -import { hasValue, isNotEmpty, isNotNull, isNotUndefined, isEmpty } from '../../../empty.util'; -import { FormFieldModel } from '../models/form-field.model'; import { uniqueId } from 'lodash'; +import { DynamicFormControlLayout } from '@ng-dynamic-forms/core'; + +import { hasValue, isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../../empty.util'; +import { FormFieldModel } from '../models/form-field.model'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; import { DynamicRowArrayModel, DynamicRowArrayModelConfig } from '../ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; import { DsDynamicInputModel, DsDynamicInputModelConfig } from '../ds-dynamic-form-ui/models/ds-dynamic-input.model'; -import { DynamicFormControlLayout } from '@ng-dynamic-forms/core'; import { setLayout } from './parser.utils'; -import { AuthorityOptions } from '../../../../core/integration/models/authority-options.model'; import { ParserOptions } from './parser-options'; import { RelationshipOptions } from '../models/relationship-options.model'; +import { VocabularyOptions } from '../../../../core/submission/vocabularies/models/vocabulary-options.model'; export const SUBMISSION_ID: InjectionToken = new InjectionToken('submissionId'); export const CONFIG_DATA: InjectionToken = new InjectionToken('configData'); @@ -49,7 +50,7 @@ export abstract class FieldParser { label: this.configData.label, initialCount: this.getInitArrayIndex(), notRepeatable: !this.configData.repeatable, - required: JSON.parse( this.configData.mandatory), + required: JSON.parse(this.configData.mandatory), groupFactory: () => { let model; if ((arrayCounter === 0)) { @@ -92,6 +93,52 @@ export abstract class FieldParser { } } + public setVocabularyOptions(controlModel, scope) { + if (isNotEmpty(this.configData.selectableMetadata) && isNotEmpty(this.configData.selectableMetadata[0].controlledVocabulary)) { + controlModel.vocabularyOptions = new VocabularyOptions( + this.configData.selectableMetadata[0].controlledVocabulary, + this.configData.selectableMetadata[0].metadata, + scope, + this.configData.selectableMetadata[0].closed + ) + } + } + + public setValues(modelConfig: DsDynamicInputModelConfig, fieldValue: any, forceValueAsObj: boolean = false, groupModel?: boolean) { + if (isNotEmpty(fieldValue)) { + if (groupModel) { + // Array, values is an array + modelConfig.value = this.getInitGroupValues(); + if (Array.isArray(modelConfig.value) && modelConfig.value.length > 0 && modelConfig.value[0].language) { + // Array Item has language, ex. AuthorityModel + modelConfig.language = modelConfig.value[0].language; + } + return; + } + + if (typeof fieldValue === 'object') { + modelConfig.language = fieldValue.language; + if (forceValueAsObj) { + modelConfig.value = fieldValue; + } else { + modelConfig.value = fieldValue.value; + } + } else { + if (forceValueAsObj) { + // If value isn't an instance of FormFieldMetadataValueObject instantiate it + modelConfig.value = new FormFieldMetadataValueObject(fieldValue); + } else { + if (typeof fieldValue === 'string') { + // Case only string + modelConfig.value = fieldValue; + } + } + } + } + + return modelConfig; + } + protected getInitValueCount(index = 0, fieldId?): number { const fieldIds = fieldId || this.getAllFieldIds(); if (isNotEmpty(this.initFormValues) && isNotNull(fieldIds) && fieldIds.length === 1 && this.initFormValues.hasOwnProperty(fieldIds[0])) { @@ -135,7 +182,7 @@ export abstract class FieldParser { fieldIds.forEach((id) => { if (this.initFormValues.hasOwnProperty(id)) { const valueObj: FormFieldMetadataValueObject = Object.assign(new FormFieldMetadataValueObject(), this.initFormValues[id][innerIndex]); - valueObj.metadata = id; + // valueObj.metadata = id; // valueObj.value = this.initFormValues[id][innerIndex]; values.push(valueObj); } @@ -224,14 +271,6 @@ export abstract class FieldParser { if (this.configData.languageCodes && this.configData.languageCodes.length > 0) { (controlModel as DsDynamicInputModel).languageCodes = this.configData.languageCodes; } - /* (controlModel as DsDynamicInputModel).languageCodes = [{ - display: 'English', - code: 'en_US' - }, - { - display: 'Italian', - code: 'it_IT' - }];*/ return controlModel; } @@ -278,50 +317,4 @@ export abstract class FieldParser { } } - public setAuthorityOptions(controlModel, authorityUuid) { - if (isNotEmpty(this.configData.selectableMetadata) && isNotEmpty(this.configData.selectableMetadata[0].authority)) { - controlModel.authorityOptions = new AuthorityOptions( - this.configData.selectableMetadata[0].authority, - this.configData.selectableMetadata[0].metadata, - authorityUuid, - this.configData.selectableMetadata[0].closed - ) - } - } - - public setValues(modelConfig: DsDynamicInputModelConfig, fieldValue: any, forceValueAsObj: boolean = false, groupModel?: boolean) { - if (isNotEmpty(fieldValue)) { - if (groupModel) { - // Array, values is an array - modelConfig.value = this.getInitGroupValues(); - if (Array.isArray(modelConfig.value) && modelConfig.value.length > 0 && modelConfig.value[0].language) { - // Array Item has language, ex. AuthorityModel - modelConfig.language = modelConfig.value[0].language; - } - return; - } - - if (typeof fieldValue === 'object') { - modelConfig.language = fieldValue.language; - if (forceValueAsObj) { - modelConfig.value = fieldValue; - } else { - modelConfig.value = fieldValue.value; - } - } else { - if (forceValueAsObj) { - // If value isn't an instance of FormFieldMetadataValueObject instantiate it - modelConfig.value = new FormFieldMetadataValueObject(fieldValue); - } else { - if (typeof fieldValue === 'string') { - // Case only string - modelConfig.value = fieldValue; - } - } - } - } - - return modelConfig; - } - } diff --git a/src/app/shared/form/builder/parsers/list-field-parser.spec.ts b/src/app/shared/form/builder/parsers/list-field-parser.spec.ts index fab5ec3888..8a05b169fd 100644 --- a/src/app/shared/form/builder/parsers/list-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/list-field-parser.spec.ts @@ -13,7 +13,7 @@ describe('ListFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - authorityUuid: null + collectionUUID: null }; beforeEach(() => { @@ -28,7 +28,7 @@ describe('ListFieldParser test suite', () => { selectableMetadata: [ { metadata: 'type', - authority: 'type_programme', + controlledVocabulary: 'type_programme', closed: false } ], diff --git a/src/app/shared/form/builder/parsers/list-field-parser.ts b/src/app/shared/form/builder/parsers/list-field-parser.ts index 273c9d1665..d3c158f4b8 100644 --- a/src/app/shared/form/builder/parsers/list-field-parser.ts +++ b/src/app/shared/form/builder/parsers/list-field-parser.ts @@ -1,19 +1,19 @@ import { FieldParser } from './field-parser'; import { isNotEmpty } from '../../../empty.util'; -import { IntegrationSearchOptions } from '../../../../core/integration/models/integration-options.model'; +import { VocabularyFindOptions } from '../../../../core/submission/vocabularies/models/vocabulary-find-options.model'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; import { DynamicListCheckboxGroupModel } from '../ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model'; import { DynamicListRadioGroupModel } from '../ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model'; export class ListFieldParser extends FieldParser { - searchOptions: IntegrationSearchOptions; + searchOptions: VocabularyFindOptions; public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { const listModelConfig = this.initModel(null, label); listModelConfig.repeatable = this.configData.repeatable; - if (this.configData.selectableMetadata[0].authority - && this.configData.selectableMetadata[0].authority.length > 0) { + if (this.configData.selectableMetadata[0].controlledVocabulary + && this.configData.selectableMetadata[0].controlledVocabulary.length > 0) { if (isNotEmpty(this.getInitGroupValues())) { listModelConfig.value = []; @@ -26,7 +26,7 @@ export class ListFieldParser extends FieldParser { } }); } - this.setAuthorityOptions(listModelConfig, this.parserOptions.authorityUuid); + this.setVocabularyOptions(listModelConfig, this.parserOptions.collectionUUID); } let listModel; diff --git a/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts b/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts index 5e14e0c013..87cee9d950 100644 --- a/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts @@ -12,7 +12,7 @@ describe('LookupFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - authorityUuid: null + collectionUUID: null }; beforeEach(() => { @@ -27,7 +27,7 @@ describe('LookupFieldParser test suite', () => { selectableMetadata: [ { metadata: 'journal', - authority: 'JOURNALAuthority', + controlledVocabulary: 'JOURNALAuthority', closed: false } ], diff --git a/src/app/shared/form/builder/parsers/lookup-field-parser.ts b/src/app/shared/form/builder/parsers/lookup-field-parser.ts index 9e9c434c4f..6e28194dad 100644 --- a/src/app/shared/form/builder/parsers/lookup-field-parser.ts +++ b/src/app/shared/form/builder/parsers/lookup-field-parser.ts @@ -5,10 +5,10 @@ import { FormFieldMetadataValueObject } from '../models/form-field-metadata-valu export class LookupFieldParser extends FieldParser { public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { - if (this.configData.selectableMetadata[0].authority) { + if (this.configData.selectableMetadata[0].controlledVocabulary) { const lookupModelConfig: DynamicLookupModelConfig = this.initModel(null, label); - this.setAuthorityOptions(lookupModelConfig, this.parserOptions.authorityUuid); + this.setVocabularyOptions(lookupModelConfig, this.parserOptions.collectionUUID); this.setValues(lookupModelConfig, fieldValue, true); diff --git a/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts b/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts index adc1e90166..3d02b6952e 100644 --- a/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts @@ -12,7 +12,7 @@ describe('LookupNameFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - authorityUuid: null + collectionUUID: null }; beforeEach(() => { @@ -27,7 +27,7 @@ describe('LookupNameFieldParser test suite', () => { selectableMetadata: [ { metadata: 'author', - authority: 'RPAuthority', + controlledVocabulary: 'RPAuthority', closed: false } ], diff --git a/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts b/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts index 684e06bcb6..26806742c8 100644 --- a/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts +++ b/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts @@ -8,10 +8,10 @@ import { FormFieldMetadataValueObject } from '../models/form-field-metadata-valu export class LookupNameFieldParser extends FieldParser { public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { - if (this.configData.selectableMetadata[0].authority) { + if (this.configData.selectableMetadata[0].controlledVocabulary) { const lookupModelConfig: DynamicLookupNameModelConfig = this.initModel(null, label); - this.setAuthorityOptions(lookupModelConfig, this.parserOptions.authorityUuid); + this.setVocabularyOptions(lookupModelConfig, this.parserOptions.collectionUUID); this.setValues(lookupModelConfig, fieldValue, true); diff --git a/src/app/shared/form/builder/parsers/name-field-parser.spec.ts b/src/app/shared/form/builder/parsers/name-field-parser.spec.ts index 1b0c637030..363ff40926 100644 --- a/src/app/shared/form/builder/parsers/name-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/name-field-parser.spec.ts @@ -14,7 +14,7 @@ describe('NameFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - authorityUuid: null + collectionUUID: null }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts b/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts index 4668b3017d..c2d1b6f565 100644 --- a/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts @@ -15,7 +15,7 @@ describe('OneboxFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - authorityUuid: null + collectionUUID: null }; beforeEach(() => { @@ -28,7 +28,7 @@ describe('OneboxFieldParser test suite', () => { selectableMetadata: [ { metadata: 'title', - authority: 'EVENTAuthority', + controlledVocabulary: 'EVENTAuthority', closed: false } ], diff --git a/src/app/shared/form/builder/parsers/onebox-field-parser.ts b/src/app/shared/form/builder/parsers/onebox-field-parser.ts index d69c9d4677..598918ac2e 100644 --- a/src/app/shared/form/builder/parsers/onebox-field-parser.ts +++ b/src/app/shared/form/builder/parsers/onebox-field-parser.ts @@ -75,9 +75,9 @@ export class OneboxFieldParser extends FieldParser { inputSelectGroup.group.push(new DsDynamicInputModel(inputModelConfig, clsInput)); return new DynamicQualdropModel(inputSelectGroup, clsGroup); - } else if (this.configData.selectableMetadata[0].authority) { + } else if (this.configData.selectableMetadata[0].controlledVocabulary) { const typeaheadModelConfig: DsDynamicTypeaheadModelConfig = this.initModel(null, label); - this.setAuthorityOptions(typeaheadModelConfig, this.parserOptions.authorityUuid); + this.setVocabularyOptions(typeaheadModelConfig, this.parserOptions.collectionUUID); this.setValues(typeaheadModelConfig, fieldValue, true); return new DynamicTypeaheadModel(typeaheadModelConfig); diff --git a/src/app/shared/form/builder/parsers/parser-options.ts b/src/app/shared/form/builder/parsers/parser-options.ts index f96ce0f2f3..e8ac4b49cd 100644 --- a/src/app/shared/form/builder/parsers/parser-options.ts +++ b/src/app/shared/form/builder/parsers/parser-options.ts @@ -1,5 +1,5 @@ export interface ParserOptions { readOnly: boolean; submissionScope: string; - authorityUuid: string + collectionUUID: string } diff --git a/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts b/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts index 84f3df0365..111193a637 100644 --- a/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts @@ -12,7 +12,7 @@ describe('RelationGroupFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - authorityUuid: 'WORKSPACE' + collectionUUID: 'WORKSPACE' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/relation-group-field-parser.ts b/src/app/shared/form/builder/parsers/relation-group-field-parser.ts index 01699d9e78..99dfcf777a 100644 --- a/src/app/shared/form/builder/parsers/relation-group-field-parser.ts +++ b/src/app/shared/form/builder/parsers/relation-group-field-parser.ts @@ -16,7 +16,7 @@ export class RelationGroupFieldParser extends FieldParser { const modelConfiguration: DynamicRelationGroupModelConfig = this.initModel(null, label); modelConfiguration.submissionId = this.submissionId; - modelConfiguration.scopeUUID = this.parserOptions.authorityUuid; + modelConfiguration.scopeUUID = this.parserOptions.collectionUUID; modelConfiguration.submissionScope = this.parserOptions.submissionScope; if (this.configData && this.configData.rows && this.configData.rows.length > 0) { modelConfiguration.formConfiguration = this.configData.rows; diff --git a/src/app/shared/form/builder/parsers/row-parser.spec.ts b/src/app/shared/form/builder/parsers/row-parser.spec.ts index 435c6a6426..63f343e60d 100644 --- a/src/app/shared/form/builder/parsers/row-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/row-parser.spec.ts @@ -35,7 +35,7 @@ describe('RowParser test suite', () => { selectableMetadata: [ { metadata: 'journal', - authority: 'JOURNALAuthority', + controlledVocabulary: 'JOURNALAuthority', closed: false } ], @@ -83,7 +83,7 @@ describe('RowParser test suite', () => { selectableMetadata: [ { metadata: 'title', - authority: 'EVENTAuthority', + controlledVocabulary: 'EVENTAuthority', closed: false } ], @@ -103,7 +103,7 @@ describe('RowParser test suite', () => { selectableMetadata: [ { metadata: 'title', - authority: 'EVENTAuthority', + controlledVocabulary: 'EVENTAuthority', closed: false } ], @@ -119,7 +119,7 @@ describe('RowParser test suite', () => { selectableMetadata: [ { metadata: 'otherTitle', - authority: 'EVENTAuthority', + controlledVocabulary: 'EVENTAuthority', closed: false } ], @@ -141,7 +141,7 @@ describe('RowParser test suite', () => { selectableMetadata: [ { metadata: 'type', - authority: 'common_types_dataset', + controlledVocabulary: 'common_types_dataset', closed: false } ], @@ -176,7 +176,7 @@ describe('RowParser test suite', () => { selectableMetadata: [ { metadata: 'author', - authority: 'RPAuthority', + controlledVocabulary: 'RPAuthority', closed: false } ], @@ -198,7 +198,7 @@ describe('RowParser test suite', () => { selectableMetadata: [ { metadata: 'type', - authority: 'type_programme', + controlledVocabulary: 'type_programme', closed: false } ], @@ -241,7 +241,7 @@ describe('RowParser test suite', () => { selectableMetadata: [ { metadata: 'subject', - authority: 'JOURNALAuthority', + controlledVocabulary: 'JOURNALAuthority', closed: false } ], diff --git a/src/app/shared/form/builder/parsers/row-parser.ts b/src/app/shared/form/builder/parsers/row-parser.ts index 4938b9859e..a7ea023569 100644 --- a/src/app/shared/form/builder/parsers/row-parser.ts +++ b/src/app/shared/form/builder/parsers/row-parser.ts @@ -5,7 +5,7 @@ import { } from '@ng-dynamic-forms/core'; import { uniqueId } from 'lodash'; -import { IntegrationSearchOptions } from '../../../../core/integration/models/integration-options.model'; +import { VocabularyFindOptions } from '../../../../core/submission/vocabularies/models/vocabulary-find-options.model'; import { isEmpty } from '../../../empty.util'; import { DynamicRowGroupModel } from '../ds-dynamic-form-ui/models/ds-dynamic-row-group-model'; import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from '../ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; @@ -48,7 +48,7 @@ export class RowParser { group: [], }; - const authorityOptions = new IntegrationSearchOptions(scopeUUID); + const vocabularyOptions = new VocabularyFindOptions(scopeUUID); const scopedFields: FormFieldModel[] = this.filterScopedFields(rowData.fields, submissionScope); @@ -58,7 +58,7 @@ export class RowParser { const parserOptions: ParserOptions = { readOnly: readOnly, submissionScope: submissionScope, - authorityUuid: authorityOptions.uuid + collectionUUID: vocabularyOptions.collection }; // Iterate over row's fields diff --git a/src/app/shared/form/builder/parsers/series-field-parser.spec.ts b/src/app/shared/form/builder/parsers/series-field-parser.spec.ts index ceb4e96320..ca58dfb50a 100644 --- a/src/app/shared/form/builder/parsers/series-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/series-field-parser.spec.ts @@ -12,7 +12,7 @@ describe('SeriesFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - authorityUuid: null + collectionUUID: null }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts b/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts index 90449e62e5..7c63235f67 100644 --- a/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts @@ -12,7 +12,7 @@ describe('TagFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - authorityUuid: null + collectionUUID: null }; beforeEach(() => { @@ -27,7 +27,7 @@ describe('TagFieldParser test suite', () => { selectableMetadata: [ { metadata: 'subject', - authority: 'JOURNALAuthority', + controlledVocabulary: 'JOURNALAuthority', closed: false } ], diff --git a/src/app/shared/form/builder/parsers/tag-field-parser.ts b/src/app/shared/form/builder/parsers/tag-field-parser.ts index c1c39feb2b..08685e0e35 100644 --- a/src/app/shared/form/builder/parsers/tag-field-parser.ts +++ b/src/app/shared/form/builder/parsers/tag-field-parser.ts @@ -6,9 +6,9 @@ export class TagFieldParser extends FieldParser { public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { const tagModelConfig: DynamicTagModelConfig = this.initModel(null, label); - if (this.configData.selectableMetadata[0].authority - && this.configData.selectableMetadata[0].authority.length > 0) { - this.setAuthorityOptions(tagModelConfig, this.parserOptions.authorityUuid); + if (this.configData.selectableMetadata[0].controlledVocabulary + && this.configData.selectableMetadata[0].controlledVocabulary.length > 0) { + this.setVocabularyOptions(tagModelConfig, this.parserOptions.collectionUUID); } this.setValues(tagModelConfig, fieldValue, null, true); diff --git a/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts b/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts index 167f126cf2..84acc9f239 100644 --- a/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts @@ -12,7 +12,7 @@ describe('TextareaFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: null, - authorityUuid: null + collectionUUID: null }; beforeEach(() => { diff --git a/src/app/shared/mocks/form-models.mock.ts b/src/app/shared/mocks/form-models.mock.ts index e4f9ec3131..1965f8d709 100644 --- a/src/app/shared/mocks/form-models.mock.ts +++ b/src/app/shared/mocks/form-models.mock.ts @@ -1,16 +1,19 @@ +import { DynamicSelectModel } from '@ng-dynamic-forms/core'; + import { DsDynamicInputModel } from '../form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model'; import { DynamicQualdropModel } from '../form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; -import { DynamicRowArrayModel, DynamicRowArrayModelConfig } from '../form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; -import { DynamicSelectModel } from '@ng-dynamic-forms/core'; +import { + DynamicRowArrayModel, + DynamicRowArrayModelConfig +} from '../form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; import { SubmissionScopeType } from '../../core/submission/submission-scope-type'; import { DynamicRelationGroupModel } from '../form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; import { FormFieldModel } from '../form/builder/models/form-field.model'; -import { AuthorityOptions } from '../../core/integration/models/authority-options.model'; -import { AuthorityValue } from '../../core/integration/models/authority.value'; +import { VocabularyOptions } from '../../core/submission/vocabularies/models/vocabulary-options.model'; +import { VocabularyEntry } from '../../core/submission/vocabularies/models/vocabulary-entry.model'; import { FormFieldMetadataValueObject } from '../form/builder/models/form-field-metadata-value.model'; import { DynamicRowGroupModel } from '../form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-group-model'; import { FormRowModel } from '../../core/config/models/config-submission-form.model'; -import { WorkspaceItem } from '../../core/submission/models/workspaceitem.model'; export const qualdropSelectConfig = { name: 'dc.identifier_QUALDROP_METADATA', @@ -87,7 +90,7 @@ export const MockRowArrayQualdropModel: DynamicRowArrayModel = new DynamicRowArr const mockFormRowModel = { fields: [ { - input: {type: 'lookup'}, + input: { type: 'lookup' }, label: 'Journal', mandatory: 'false', repeatable: false, @@ -95,14 +98,14 @@ const mockFormRowModel = { selectableMetadata: [ { metadata: 'journal', - authority: 'JOURNALAuthority', + controlledVocabulary: 'JOURNALAuthority', closed: false } ], languageCodes: [] } as FormFieldModel, { - input: {type: 'onebox'}, + input: { type: 'onebox' }, label: 'Issue', mandatory: 'false', repeatable: false, @@ -142,7 +145,7 @@ const relationGroupConfig = { export const MockRelationModel: DynamicRelationGroupModel = new DynamicRelationGroupModel(relationGroupConfig); export const inputWithLanguageAndAuthorityConfig = { - authorityOptions: new AuthorityOptions('testAuthority', 'testWithAuthority', 'scope'), + vocabularyOptions: new VocabularyOptions('testAuthority', 'testWithAuthority', 'scope'), languageCodes: [ { display: 'English', @@ -159,10 +162,10 @@ export const inputWithLanguageAndAuthorityConfig = { readOnly: false, disabled: false, repeatable: false, - value: { + value: { value: 'testWithLanguageAndAuthority', display: 'testWithLanguageAndAuthority', - id: 'testWithLanguageAndAuthority', + authority: 'testWithLanguageAndAuthority', }, submissionId: '1234', metadataFields: [] @@ -195,7 +198,7 @@ export const inputWithLanguageConfig = { export const mockInputWithLanguageModel = new DsDynamicInputModel(inputWithLanguageConfig); export const inputWithLanguageAndAuthorityArrayConfig = { - authorityOptions: new AuthorityOptions('testAuthority', 'testWithAuthority', 'scope'), + vocabularyOptions: new VocabularyOptions('testAuthority', 'testWithAuthority', 'scope'), languageCodes: [ { display: 'English', @@ -215,7 +218,7 @@ export const inputWithLanguageAndAuthorityArrayConfig = { value: [{ value: 'testLanguageAndAuthorityArray', display: 'testLanguageAndAuthorityArray', - id: 'testLanguageAndAuthorityArray', + authority: 'testLanguageAndAuthorityArray', }], submissionId: '1234', metadataFields: [] @@ -242,7 +245,11 @@ export const inputWithAuthorityValueConfig = { readOnly: false, disabled: false, repeatable: false, - value: Object.assign({}, new AuthorityValue(), { value: 'testWithAuthorityValue', id: 'testWithAuthorityValue', display: 'testWithAuthorityValue' }), + value: Object.assign({}, new VocabularyEntry(), { + value: 'testWithAuthorityValue', + authority: 'testWithAuthorityValue', + display: 'testWithAuthorityValue' + }), submissionId: '1234', metadataFields: [] }; @@ -255,7 +262,7 @@ export const inputWithObjectValueConfig = { readOnly: false, disabled: false, repeatable: false, - value: { value: 'testWithObjectValue', id: 'testWithObjectValue', display: 'testWithObjectValue' }, + value: { value: 'testWithObjectValue', authority: 'testWithObjectValue', display: 'testWithObjectValue' }, submissionId: '1234', metadataFields: [] }; diff --git a/src/app/shared/mocks/submission.mock.ts b/src/app/shared/mocks/submission.mock.ts index 082eec4c71..8965a17eef 100644 --- a/src/app/shared/mocks/submission.mock.ts +++ b/src/app/shared/mocks/submission.mock.ts @@ -1254,7 +1254,7 @@ export const mockUploadConfigResponse = { { metadata: 'dc.title', label: null, - authority: null, + controlledVocabulary: null, closed: null } ], @@ -1276,7 +1276,7 @@ export const mockUploadConfigResponse = { { metadata: 'dc.description', label: null, - authority: null, + controlledVocabulary: null, closed: null } ], diff --git a/src/app/shared/testing/authority-service.stub.ts b/src/app/shared/testing/authority-service.stub.ts deleted file mode 100644 index 3a5d31ab0d..0000000000 --- a/src/app/shared/testing/authority-service.stub.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {of as observableOf, Observable } from 'rxjs'; -import { IntegrationSearchOptions } from '../../core/integration/models/integration-options.model'; -import { IntegrationData } from '../../core/integration/integration-data'; -import { PageInfo } from '../../core/shared/page-info.model'; -import { AuthorityValue } from '../../core/integration/models/authority.value'; - -export class AuthorityServiceStub { - - private _payload = [ - Object.assign(new AuthorityValue(),{id: 1, display: 'one', value: 1}), - Object.assign(new AuthorityValue(),{id: 2, display: 'two', value: 2}), - ]; - - setNewPayload(payload) { - this._payload = payload; - } - - getEntriesByName(options: IntegrationSearchOptions) { - return observableOf(new IntegrationData(new PageInfo(), this._payload)); - } -} diff --git a/src/app/shared/testing/vocabulary-service.stub.ts b/src/app/shared/testing/vocabulary-service.stub.ts new file mode 100644 index 0000000000..15528653c2 --- /dev/null +++ b/src/app/shared/testing/vocabulary-service.stub.ts @@ -0,0 +1,29 @@ +import { Observable } from 'rxjs'; + +import { VocabularyFindOptions } from '../../core/submission/vocabularies/models/vocabulary-find-options.model'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { VocabularyEntry } from '../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { RemoteData } from '../../core/data/remote-data'; + + +export class VocabularyServiceStub { + + private _payload = [ + Object.assign(new VocabularyEntry(),{authority: 1, display: 'one', value: 1}), + Object.assign(new VocabularyEntry(),{authority: 2, display: 'two', value: 2}), + ]; + + setNewPayload(payload) { + this._payload = payload; + } + + getList() { + return this._payload + } + + getVocabularyEntries(options: VocabularyFindOptions): Observable>> { + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), this._payload)); + } +} diff --git a/src/app/submission/sections/form/section-form-operations.service.spec.ts b/src/app/submission/sections/form/section-form-operations.service.spec.ts index de8e7da7f9..bc1c17ddbd 100644 --- a/src/app/submission/sections/form/section-form-operations.service.spec.ts +++ b/src/app/submission/sections/form/section-form-operations.service.spec.ts @@ -27,7 +27,7 @@ import { mockRowGroupModel } from '../../../shared/mocks/form-models.mock'; import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; -import { AuthorityValue } from '../../../core/integration/models/authority.value'; +import { VocabularyEntry } from '../../../core/submission/vocabularies/models/vocabulary-entry.model'; describe('SectionFormOperationsService test suite', () => { let formBuilderService: any; @@ -365,7 +365,7 @@ describe('SectionFormOperationsService test suite', () => { event = Object.assign({}, dynamicFormControlChangeEvent, { model: mockInputWithLanguageAndAuthorityModel }); - expectedValue = Object.assign(new AuthorityValue(), mockInputWithLanguageAndAuthorityModel.value, {language: mockInputWithLanguageAndAuthorityModel.language}); + expectedValue = Object.assign(new VocabularyEntry(), mockInputWithLanguageAndAuthorityModel.value, {language: mockInputWithLanguageAndAuthorityModel.language}); expect(service.getFieldValueFromChangeEvent(event)).toEqual(expectedValue); @@ -373,7 +373,7 @@ describe('SectionFormOperationsService test suite', () => { model: mockInputWithLanguageAndAuthorityArrayModel }); expectedValue = [ - Object.assign(new AuthorityValue(), mockInputWithLanguageAndAuthorityArrayModel.value[0], + Object.assign(new VocabularyEntry(), mockInputWithLanguageAndAuthorityArrayModel.value[0], { language: mockInputWithLanguageAndAuthorityArrayModel.language } ) ]; diff --git a/src/app/submission/sections/form/section-form-operations.service.ts b/src/app/submission/sections/form/section-form-operations.service.ts index 2d6b1c5477..a28536a0a7 100644 --- a/src/app/submission/sections/form/section-form-operations.service.ts +++ b/src/app/submission/sections/form/section-form-operations.service.ts @@ -15,11 +15,12 @@ import { FormFieldPreviousValueObject } from '../../../shared/form/builder/model import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model'; import { DsDynamicInputModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model'; -import { AuthorityValue } from '../../../core/integration/models/authority.value'; +import { VocabularyEntry } from '../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; import { DynamicQualdropModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; import { DynamicRelationGroupModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; +import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; /** * The service handling all form section operations @@ -221,18 +222,19 @@ export class SectionFormOperationsService { if ((event.model as DsDynamicInputModel).hasAuthority) { if (Array.isArray(value)) { value.forEach((authority, index) => { - authority = Object.assign(new AuthorityValue(), authority, { language }); + authority = Object.assign(new VocabularyEntry(), authority, { language }); value[index] = authority; }); fieldValue = value; } else { - fieldValue = Object.assign(new AuthorityValue(), value, { language }); + fieldValue = Object.assign(new VocabularyEntry(), value, { language }); } } else { // Language without Authority (input, textArea) fieldValue = new FormFieldMetadataValueObject(value, language); } - } else if (value instanceof FormFieldLanguageValueObject || value instanceof AuthorityValue || isObject(value)) { + } else if (value instanceof FormFieldLanguageValueObject || value instanceof VocabularyEntry + || value instanceof VocabularyEntryDetail || isObject(value)) { fieldValue = value; } else { fieldValue = new FormFieldMetadataValueObject(value); From f71fd5737a82be56dcbf8cd8929e47073a166224 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 25 Jun 2020 15:22:20 +0200 Subject: [PATCH 006/126] [CST-3088] Removed authority and integration services --- src/app/core/cache/response.models.ts | 14 +- src/app/core/data/request.models.ts | 38 ++- src/app/core/integration/authority.service.ts | 21 -- src/app/core/integration/integration-data.ts | 12 - ...tegration-response-parsing.service.spec.ts | 221 ------------------ .../integration-response-parsing.service.ts | 50 ---- .../integration/integration.service.spec.ts | 96 -------- .../core/integration/integration.service.ts | 121 ---------- .../models/authority-options.model.ts | 16 -- .../models/authority.resource-type.ts | 10 - .../integration/models/authority.value.ts | 92 -------- .../models/integration-options.model.ts | 14 -- .../integration/models/integration.model.ts | 22 -- 13 files changed, 16 insertions(+), 711 deletions(-) delete mode 100644 src/app/core/integration/authority.service.ts delete mode 100644 src/app/core/integration/integration-data.ts delete mode 100644 src/app/core/integration/integration-response-parsing.service.spec.ts delete mode 100644 src/app/core/integration/integration-response-parsing.service.ts delete mode 100644 src/app/core/integration/integration.service.spec.ts delete mode 100644 src/app/core/integration/integration.service.ts delete mode 100644 src/app/core/integration/models/authority-options.model.ts delete mode 100644 src/app/core/integration/models/authority.resource-type.ts delete mode 100644 src/app/core/integration/models/authority.value.ts delete mode 100644 src/app/core/integration/models/integration-options.model.ts delete mode 100644 src/app/core/integration/models/integration.model.ts diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 3f46ecf647..00f1bffdeb 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -5,11 +5,9 @@ import { PageInfo } from '../shared/page-info.model'; import { ConfigObject } from '../config/models/config.model'; import { FacetValue } from '../../shared/search/facet-value.model'; import { SearchFilterConfig } from '../../shared/search/search-filter-config.model'; -import { IntegrationModel } from '../integration/models/integration.model'; import { RegistryMetadataschemasResponse } from '../registry/registry-metadataschemas-response.model'; import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model'; import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model'; -import { PaginatedList } from '../data/paginated-list'; import { SubmissionObject } from '../submission/models/submission-object.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { MetadataSchema } from '../metadata/metadata-schema.model'; @@ -211,17 +209,6 @@ export class AuthStatusResponse extends RestResponse { } } -export class IntegrationSuccessResponse extends RestResponse { - constructor( - public dataDefinition: PaginatedList, - public statusCode: number, - public statusText: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode, statusText); - } -} - export class PostPatchSuccessResponse extends RestResponse { constructor( public dataDefinition: any, @@ -302,4 +289,5 @@ export class ContentSourceSuccessResponse extends RestResponse { super(true, statusCode, statusText); } } + /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index c9fe1fe0ce..a1f77d9cbb 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -9,7 +9,6 @@ import { ConfigResponseParsingService } from '../config/config-response-parsing. import { AuthResponseParsingService } from '../auth/auth-response-parsing.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { SubmissionResponseParsingService } from '../submission/submission-response-parsing.service'; -import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service'; import { RestRequestMethod } from './rest-request-method'; import { RequestParam } from '../cache/models/request-param.model'; import { EpersonResponseParsingService } from '../eperson/eperson-response-parsing.service'; @@ -26,13 +25,14 @@ import { VocabularyEntriesResponseParsingService } from '../submission/vocabular // uuid and handle requests have separate endpoints export enum IdentifierType { - UUID ='uuid', + UUID = 'uuid', HANDLE = 'handle' } export abstract class RestRequest { public responseMsToLive = 10 * 1000; public forceBypassCache = false; + constructor( public uuid: string, public href: string, @@ -42,13 +42,13 @@ export abstract class RestRequest { ) { } - getResponseParser(): GenericConstructor { - return DSOResponseParsingService; - } - get toCache(): boolean { return this.responseMsToLive > 0; } + + getResponseParser(): GenericConstructor { + return DSOResponseParsingService; + } } export class GetRequest extends RestRequest { @@ -59,7 +59,7 @@ export class GetRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.GET, body, options) } } @@ -70,7 +70,7 @@ export class PostRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.POST, body) } } @@ -81,7 +81,7 @@ export class PutRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.PUT, body) } } @@ -92,7 +92,7 @@ export class DeleteRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.DELETE, body) } } @@ -103,7 +103,7 @@ export class OptionsRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.OPTIONS, body) } } @@ -114,7 +114,7 @@ export class HeadRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.HEAD, body) } } @@ -127,7 +127,7 @@ export class PatchRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.PATCH, body) } } @@ -242,16 +242,6 @@ export class AuthGetRequest extends GetRequest { } } -export class IntegrationRequest extends GetRequest { - constructor(uuid: string, href: string) { - super(uuid, href); - } - - getResponseParser(): GenericConstructor { - return IntegrationResponseParsingService; - } -} - /** * Request to create a MetadataSchema */ @@ -309,6 +299,7 @@ export class UpdateMetadataFieldRequest extends PutRequest { */ export class SubmissionRequest extends GetRequest { forceBypassCache = true; + constructor(uuid: string, href: string) { super(uuid, href); } @@ -456,4 +447,5 @@ export class RequestError extends Error { statusCode: number; statusText: string; } + /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/integration/authority.service.ts b/src/app/core/integration/authority.service.ts deleted file mode 100644 index f0a1759be6..0000000000 --- a/src/app/core/integration/authority.service.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Injectable } from '@angular/core'; - -import { RequestService } from '../data/request.service'; -import { IntegrationService } from './integration.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; - -@Injectable() -export class AuthorityService extends IntegrationService { - protected linkPath = 'authorities'; - protected entriesEndpoint = 'entries'; - protected entryValueEndpoint = 'entryValues'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected halService: HALEndpointService) { - super(); - } - -} diff --git a/src/app/core/integration/integration-data.ts b/src/app/core/integration/integration-data.ts deleted file mode 100644 index b93ce36dad..0000000000 --- a/src/app/core/integration/integration-data.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { PageInfo } from '../shared/page-info.model'; -import { IntegrationModel } from './models/integration.model'; - -/** - * A class to represent the data retrieved by an Integration service - */ -export class IntegrationData { - constructor( - public pageInfo: PageInfo, - public payload: IntegrationModel[] - ) { } -} diff --git a/src/app/core/integration/integration-response-parsing.service.spec.ts b/src/app/core/integration/integration-response-parsing.service.spec.ts deleted file mode 100644 index b5cb8c4dc4..0000000000 --- a/src/app/core/integration/integration-response-parsing.service.spec.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { Store } from '@ngrx/store'; - -import { ObjectCacheService } from '../cache/object-cache.service'; -import { ErrorResponse, IntegrationSuccessResponse } from '../cache/response.models'; -import { CoreState } from '../core.reducers'; -import { PaginatedList } from '../data/paginated-list'; -import { IntegrationRequest } from '../data/request.models'; -import { PageInfo } from '../shared/page-info.model'; -import { IntegrationResponseParsingService } from './integration-response-parsing.service'; -import { AuthorityValue } from './models/authority.value'; - -describe('IntegrationResponseParsingService', () => { - let service: IntegrationResponseParsingService; - - const store = {} as Store; - const objectCacheService = new ObjectCacheService(store, undefined); - const name = 'type'; - const metadata = 'dc.type'; - const query = ''; - const uuid = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; - const integrationEndpoint = 'https://rest.api/integration/authorities'; - const entriesEndpoint = `${integrationEndpoint}/${name}/entries?query=${query}&metadata=${metadata}&uuid=${uuid}`; - let validRequest; - - let validResponse; - - let invalidResponse1; - let invalidResponse2; - let pageInfo; - let definitions; - - function initVars() { - pageInfo = Object.assign(new PageInfo(), { - elementsPerPage: 5, - totalElements: 5, - totalPages: 1, - currentPage: 1, - _links: { - self: { href: 'https://rest.api/integration/authorities/type/entries' } - } - }); - definitions = new PaginatedList(pageInfo, [ - Object.assign(new AuthorityValue(), { - type: 'authority', - display: 'One', - id: 'One', - otherInformation: undefined, - value: 'One' - }), - Object.assign(new AuthorityValue(), { - type: 'authority', - display: 'Two', - id: 'Two', - otherInformation: undefined, - value: 'Two' - }), - Object.assign(new AuthorityValue(), { - type: 'authority', - display: 'Three', - id: 'Three', - otherInformation: undefined, - value: 'Three' - }), - Object.assign(new AuthorityValue(), { - type: 'authority', - display: 'Four', - id: 'Four', - otherInformation: undefined, - value: 'Four' - }), - Object.assign(new AuthorityValue(), { - type: 'authority', - display: 'Five', - id: 'Five', - otherInformation: undefined, - value: 'Five' - }) - ]); - validRequest = new IntegrationRequest('69f375b5-19f4-4453-8c7a-7dc5c55aafbb', entriesEndpoint); - - validResponse = { - payload: { - page: { - number: 0, - size: 5, - totalElements: 5, - totalPages: 1 - }, - _embedded: { - authorityEntries: [ - { - display: 'One', - id: 'One', - otherInformation: {}, - type: 'authority', - value: 'One' - }, - { - display: 'Two', - id: 'Two', - otherInformation: {}, - type: 'authority', - value: 'Two' - }, - { - display: 'Three', - id: 'Three', - otherInformation: {}, - type: 'authority', - value: 'Three' - }, - { - display: 'Four', - id: 'Four', - otherInformation: {}, - type: 'authority', - value: 'Four' - }, - { - display: 'Five', - id: 'Five', - otherInformation: {}, - type: 'authority', - value: 'Five' - }, - ], - - }, - _links: { - self: { href: 'https://rest.api/integration/authorities/type/entries' } - } - }, - statusCode: 200, - statusText: 'OK' - }; - - invalidResponse1 = { - payload: {}, - statusCode: 400, - statusText: 'Bad Request' - }; - - invalidResponse2 = { - payload: { - page: { - number: 0, - size: 5, - totalElements: 5, - totalPages: 1 - }, - _embedded: { - authorityEntries: [ - { - display: 'One', - id: 'One', - otherInformation: {}, - type: 'authority', - value: 'One' - }, - { - display: 'Two', - id: 'Two', - otherInformation: {}, - type: 'authority', - value: 'Two' - }, - { - display: 'Three', - id: 'Three', - otherInformation: {}, - type: 'authority', - value: 'Three' - }, - { - display: 'Four', - id: 'Four', - otherInformation: {}, - type: 'authority', - value: 'Four' - }, - { - display: 'Five', - id: 'Five', - otherInformation: {}, - type: 'authority', - value: 'Five' - }, - ], - - }, - _links: {} - }, - statusCode: 500, - statusText: 'Internal Server Error' - }; - } - beforeEach(() => { - initVars(); - service = new IntegrationResponseParsingService(objectCacheService); - }); - - describe('parse', () => { - it('should return a IntegrationSuccessResponse if data contains a valid endpoint response', () => { - const response = service.parse(validRequest, validResponse); - expect(response.constructor).toBe(IntegrationSuccessResponse); - }); - - it('should return an ErrorResponse if data contains an invalid config endpoint response', () => { - const response1 = service.parse(validRequest, invalidResponse1); - const response2 = service.parse(validRequest, invalidResponse2); - expect(response1.constructor).toBe(ErrorResponse); - expect(response2.constructor).toBe(ErrorResponse); - }); - - it('should return a IntegrationSuccessResponse with data definition', () => { - const response = service.parse(validRequest, validResponse); - expect((response as any).dataDefinition).toEqual(definitions); - }); - - }); -}); diff --git a/src/app/core/integration/integration-response-parsing.service.ts b/src/app/core/integration/integration-response-parsing.service.ts deleted file mode 100644 index 2719669bae..0000000000 --- a/src/app/core/integration/integration-response-parsing.service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Inject, Injectable } from '@angular/core'; -import { RestRequest } from '../data/request.models'; -import { ResponseParsingService } from '../data/parsing.service'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { ErrorResponse, IntegrationSuccessResponse, RestResponse } from '../cache/response.models'; -import { isNotEmpty } from '../../shared/empty.util'; - -import { BaseResponseParsingService } from '../data/base-response-parsing.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { IntegrationModel } from './models/integration.model'; -import { AuthorityValue } from './models/authority.value'; -import { PaginatedList } from '../data/paginated-list'; - -@Injectable() -export class IntegrationResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { - - protected toCache = true; - - constructor( - protected objectCache: ObjectCacheService, - ) { - super(); - } - - parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links)) { - const dataDefinition = this.process(data.payload, request); - return new IntegrationSuccessResponse(this.processResponse(dataDefinition), data.statusCode, data.statusText, this.processPageInfo(data.payload)); - } else { - return new ErrorResponse( - Object.assign( - new Error('Unexpected response from Integration endpoint'), - {statusCode: data.statusCode, statusText: data.statusText} - ) - ); - } - } - - protected processResponse(data: PaginatedList): any { - const returnList = Array.of(); - data.page.forEach((item, index) => { - if (item.type === AuthorityValue.type.value) { - data.page[index] = Object.assign(new AuthorityValue(), item); - } - }); - - return data; - } - -} diff --git a/src/app/core/integration/integration.service.spec.ts b/src/app/core/integration/integration.service.spec.ts deleted file mode 100644 index 148a5df7b8..0000000000 --- a/src/app/core/integration/integration.service.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { cold, getTestScheduler } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/testing'; -import { getMockRequestService } from '../../shared/mocks/request.service.mock'; - -import { RequestService } from '../data/request.service'; -import { IntegrationRequest } from '../data/request.models'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { IntegrationService } from './integration.service'; -import { IntegrationSearchOptions } from './models/integration-options.model'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; - -const LINK_NAME = 'authorities'; -const ENTRIES = 'entries'; -const ENTRY_VALUE = 'entryValue'; - -class TestService extends IntegrationService { - protected linkPath = LINK_NAME; - protected entriesEndpoint = ENTRIES; - protected entryValueEndpoint = ENTRY_VALUE; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected halService: HALEndpointService) { - super(); - } -} - -describe('IntegrationService', () => { - let scheduler: TestScheduler; - let service: TestService; - let requestService: RequestService; - let rdbService: RemoteDataBuildService; - let halService: any; - let findOptions: IntegrationSearchOptions; - - const name = 'type'; - const metadata = 'dc.type'; - const query = ''; - const value = 'test'; - const uuid = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; - const integrationEndpoint = 'https://rest.api/integration'; - const serviceEndpoint = `${integrationEndpoint}/${LINK_NAME}`; - const entriesEndpoint = `${serviceEndpoint}/${name}/entries?query=${query}&metadata=${metadata}&uuid=${uuid}`; - const entryValueEndpoint = `${serviceEndpoint}/${name}/entryValue/${value}?metadata=${metadata}`; - - findOptions = new IntegrationSearchOptions(uuid, name, metadata); - - function initTestService(): TestService { - return new TestService( - requestService, - rdbService, - halService - ); - } - - beforeEach(() => { - requestService = getMockRequestService(); - rdbService = getMockRemoteDataBuildService(); - scheduler = getTestScheduler(); - halService = new HALEndpointServiceStub(integrationEndpoint); - findOptions = new IntegrationSearchOptions(uuid, name, metadata, query); - service = initTestService(); - - }); - - describe('getEntriesByName', () => { - - it('should configure a new IntegrationRequest', () => { - const expected = new IntegrationRequest(requestService.generateRequestId(), entriesEndpoint); - scheduler.schedule(() => service.getEntriesByName(findOptions).subscribe()); - scheduler.flush(); - - expect(requestService.configure).toHaveBeenCalledWith(expected); - }); - }); - - describe('getEntryByValue', () => { - - it('should configure a new IntegrationRequest', () => { - findOptions = new IntegrationSearchOptions( - null, - name, - metadata, - value); - - const expected = new IntegrationRequest(requestService.generateRequestId(), entryValueEndpoint); - scheduler.schedule(() => service.getEntryByValue(findOptions).subscribe()); - scheduler.flush(); - - expect(requestService.configure).toHaveBeenCalledWith(expected); - }); - }); -}); diff --git a/src/app/core/integration/integration.service.ts b/src/app/core/integration/integration.service.ts deleted file mode 100644 index 5826f4646d..0000000000 --- a/src/app/core/integration/integration.service.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; -import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; -import { RequestService } from '../data/request.service'; -import { IntegrationSuccessResponse } from '../cache/response.models'; -import { GetRequest, IntegrationRequest } from '../data/request.models'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { IntegrationData } from './integration-data'; -import { IntegrationSearchOptions } from './models/integration-options.model'; -import { getResponseFromEntry } from '../shared/operators'; - -export abstract class IntegrationService { - protected request: IntegrationRequest; - protected abstract requestService: RequestService; - protected abstract linkPath: string; - protected abstract entriesEndpoint: string; - protected abstract entryValueEndpoint: string; - protected abstract halService: HALEndpointService; - - protected getData(request: GetRequest): Observable { - return this.requestService.getByHref(request.href).pipe( - getResponseFromEntry(), - mergeMap((response: IntegrationSuccessResponse) => { - if (response.isSuccessful && isNotEmpty(response)) { - return observableOf(new IntegrationData( - response.pageInfo, - (response.dataDefinition) ? response.dataDefinition.page : [] - )); - } else if (!response.isSuccessful) { - return observableThrowError(new Error(`Couldn't retrieve the integration data`)); - } - }), - distinctUntilChanged() - ); - } - - protected getEntriesHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string { - let result; - const args = []; - - if (hasValue(options.name)) { - result = `${endpoint}/${options.name}/${this.entriesEndpoint}`; - } else { - result = endpoint; - } - - if (hasValue(options.query)) { - args.push(`query=${options.query}`); - } - - if (hasValue(options.metadata)) { - args.push(`metadata=${options.metadata}`); - } - - if (hasValue(options.uuid)) { - args.push(`uuid=${options.uuid}`); - } - - if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { - /* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */ - args.push(`page=${options.currentPage - 1}`); - } - - if (hasValue(options.elementsPerPage)) { - args.push(`size=${options.elementsPerPage}`); - } - - if (hasValue(options.sort)) { - args.push(`sort=${options.sort.field},${options.sort.direction}`); - } - - if (isNotEmpty(args)) { - result = `${result}?${args.join('&')}`; - } - return result; - } - - protected getEntryValueHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string { - let result; - const args = []; - - if (hasValue(options.name) && hasValue(options.query)) { - result = `${endpoint}/${options.name}/${this.entryValueEndpoint}/${options.query}`; - } else { - result = endpoint; - } - - if (hasValue(options.metadata)) { - args.push(`metadata=${options.metadata}`); - } - - if (isNotEmpty(args)) { - result = `${result}?${args.join('&')}`; - } - - return result; - } - - public getEntriesByName(options: IntegrationSearchOptions): Observable { - return this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getEntriesHref(endpoint, options)), - filter((href: string) => isNotEmpty(href)), - distinctUntilChanged(), - map((endpointURL: string) => new IntegrationRequest(this.requestService.generateRequestId(), endpointURL)), - tap((request: GetRequest) => this.requestService.configure(request)), - mergeMap((request: GetRequest) => this.getData(request)), - distinctUntilChanged()); - } - - public getEntryByValue(options: IntegrationSearchOptions): Observable { - return this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getEntryValueHref(endpoint, options)), - filter((href: string) => isNotEmpty(href)), - distinctUntilChanged(), - map((endpointURL: string) => new IntegrationRequest(this.requestService.generateRequestId(), endpointURL)), - tap((request: GetRequest) => this.requestService.configure(request)), - mergeMap((request: GetRequest) => this.getData(request)), - distinctUntilChanged()); - } - -} diff --git a/src/app/core/integration/models/authority-options.model.ts b/src/app/core/integration/models/authority-options.model.ts deleted file mode 100644 index 0b826f7f9c..0000000000 --- a/src/app/core/integration/models/authority-options.model.ts +++ /dev/null @@ -1,16 +0,0 @@ -export class AuthorityOptions { - name: string; - metadata: string; - scope: string; - closed: boolean; - - constructor(name: string, - metadata: string, - scope: string, - closed: boolean = false) { - this.name = name; - this.metadata = metadata; - this.scope = scope; - this.closed = closed; - } -} diff --git a/src/app/core/integration/models/authority.resource-type.ts b/src/app/core/integration/models/authority.resource-type.ts deleted file mode 100644 index ec87ddc85f..0000000000 --- a/src/app/core/integration/models/authority.resource-type.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ResourceType } from '../../shared/resource-type'; - -/** - * The resource type for AuthorityValue - * - * Needs to be in a separate file to prevent circular - * dependencies in webpack. - */ - -export const AUTHORITY_VALUE = new ResourceType('authority'); diff --git a/src/app/core/integration/models/authority.value.ts b/src/app/core/integration/models/authority.value.ts deleted file mode 100644 index 4e0183603b..0000000000 --- a/src/app/core/integration/models/authority.value.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; -import { isNotEmpty } from '../../../shared/empty.util'; -import { PLACEHOLDER_PARENT_METADATA } from '../../../shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; -import { OtherInformation } from '../../../shared/form/builder/models/form-field-metadata-value.model'; -import { typedObject } from '../../cache/builders/build-decorators'; -import { HALLink } from '../../shared/hal-link.model'; -import { MetadataValueInterface } from '../../shared/metadata.models'; -import { AUTHORITY_VALUE } from './authority.resource-type'; -import { IntegrationModel } from './integration.model'; - -/** - * Class representing an authority object - */ -@typedObject -@inheritSerialization(IntegrationModel) -export class AuthorityValue extends IntegrationModel implements MetadataValueInterface { - static type = AUTHORITY_VALUE; - - /** - * The identifier of this authority - */ - @autoserialize - id: string; - - /** - * The display value of this authority - */ - @autoserialize - display: string; - - /** - * The value of this authority - */ - @autoserialize - value: string; - - /** - * An object containing additional information related to this authority - */ - @autoserialize - otherInformation: OtherInformation; - - /** - * The language code of this authority value - */ - @autoserialize - language: string; - - /** - * The {@link HALLink}s for this AuthorityValue - */ - @deserialize - _links: { - self: HALLink, - }; - - /** - * This method checks if authority has an identifier value - * - * @return boolean - */ - hasAuthority(): boolean { - return isNotEmpty(this.id); - } - - /** - * This method checks if authority has a value - * - * @return boolean - */ - hasValue(): boolean { - return isNotEmpty(this.value); - } - - /** - * This method checks if authority has related information object - * - * @return boolean - */ - hasOtherInformation(): boolean { - return isNotEmpty(this.otherInformation); - } - - /** - * This method checks if authority has a placeholder as value - * - * @return boolean - */ - hasPlaceholder(): boolean { - return this.hasValue() && this.value === PLACEHOLDER_PARENT_METADATA; - } -} diff --git a/src/app/core/integration/models/integration-options.model.ts b/src/app/core/integration/models/integration-options.model.ts deleted file mode 100644 index 5f158bd47c..0000000000 --- a/src/app/core/integration/models/integration-options.model.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { SortOptions } from '../../cache/models/sort-options.model'; - -export class IntegrationSearchOptions { - - constructor(public uuid: string = '', - public name: string = '', - public metadata: string = '', - public query: string = '', - public elementsPerPage?: number, - public currentPage?: number, - public sort?: SortOptions) { - - } -} diff --git a/src/app/core/integration/models/integration.model.ts b/src/app/core/integration/models/integration.model.ts deleted file mode 100644 index d2f21a70c0..0000000000 --- a/src/app/core/integration/models/integration.model.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { autoserialize, deserialize } from 'cerialize'; -import { CacheableObject } from '../../cache/object-cache.reducer'; -import { HALLink } from '../../shared/hal-link.model'; - -export abstract class IntegrationModel implements CacheableObject { - - @autoserialize - self: string; - - @autoserialize - uuid: string; - - @autoserialize - public type: any; - - @deserialize - public _links: { - self: HALLink, - [name: string]: HALLink - } - -} From df26b85c5018374edb8c1c22c9a84376a1b6ac86 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Mon, 29 Jun 2020 22:15:02 +0200 Subject: [PATCH 007/126] [CST-3088] Create abstract response parsing for entries --- ...browse-entries-response-parsing.service.ts | 34 ++++----------- .../data/entries-response-parsing.service.ts | 42 +++++++++++++++++++ ...bulary-entries-response-parsing.service.ts | 11 ++++- 3 files changed, 59 insertions(+), 28 deletions(-) create mode 100644 src/app/core/data/entries-response-parsing.service.ts diff --git a/src/app/core/data/browse-entries-response-parsing.service.ts b/src/app/core/data/browse-entries-response-parsing.service.ts index 98385f0237..1009a07bca 100644 --- a/src/app/core/data/browse-entries-response-parsing.service.ts +++ b/src/app/core/data/browse-entries-response-parsing.service.ts @@ -1,40 +1,22 @@ -import { Inject, Injectable } from '@angular/core'; -import { isNotEmpty } from '../../shared/empty.util'; +import { Injectable } from '@angular/core'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; import { BrowseEntry } from '../shared/browse-entry.model'; -import { BaseResponseParsingService } from './base-response-parsing.service'; -import { ResponseParsingService } from './parsing.service'; -import { RestRequest } from './request.models'; +import { EntriesResponseParsingService } from './entries-response-parsing.service'; +import { GenericConstructor } from '../shared/generic-constructor'; @Injectable() -export class BrowseEntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { +export class BrowseEntriesResponseParsingService extends EntriesResponseParsingService { protected toCache = false; constructor( protected objectCache: ObjectCacheService, - ) { super(); + ) { + super(objectCache); } - parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - if (isNotEmpty(data.payload)) { - let browseEntries = []; - if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { - const serializer = new DSpaceSerializer(BrowseEntry); - browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); - } - return new GenericSuccessResponse(browseEntries, data.statusCode, data.statusText, this.processPageInfo(data.payload)); - } else { - return new ErrorResponse( - Object.assign( - new Error('Unexpected response from browse endpoint'), - { statusCode: data.statusCode, statusText: data.statusText } - ) - ); - } + getSerializerModel(): GenericConstructor { + return BrowseEntry; } } diff --git a/src/app/core/data/entries-response-parsing.service.ts b/src/app/core/data/entries-response-parsing.service.ts new file mode 100644 index 0000000000..2f49eb3871 --- /dev/null +++ b/src/app/core/data/entries-response-parsing.service.ts @@ -0,0 +1,42 @@ +import { isNotEmpty } from '../../shared/empty.util'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { GenericConstructor } from '../shared/generic-constructor'; + +export abstract class EntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected toCache = false; + + constructor( + protected objectCache: ObjectCacheService, + ) { + super(); + } + + abstract getSerializerModel(): GenericConstructor; + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload)) { + let browseEntries = []; + if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { + const serializer = new DSpaceSerializer(this.getSerializerModel()); + browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); + } + return new GenericSuccessResponse(browseEntries, data.statusCode, data.statusText, this.processPageInfo(data.payload)); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from browse endpoint'), + { statusCode: data.statusCode, statusText: data.statusText } + ) + ); + } + } + +} diff --git a/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.ts b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.ts index 680b949733..1e5b67b261 100644 --- a/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.ts @@ -1,13 +1,15 @@ import { Injectable } from '@angular/core'; import { ObjectCacheService } from '../../cache/object-cache.service'; -import { BrowseEntriesResponseParsingService } from '../../data/browse-entries-response-parsing.service'; +import { VocabularyEntry } from './models/vocabulary-entry.model'; +import { EntriesResponseParsingService } from '../../data/entries-response-parsing.service'; +import { GenericConstructor } from '../../shared/generic-constructor'; /** * A service responsible for parsing data for a vocabulary entries response */ @Injectable() -export class VocabularyEntriesResponseParsingService extends BrowseEntriesResponseParsingService { +export class VocabularyEntriesResponseParsingService extends EntriesResponseParsingService { protected toCache = false; @@ -17,4 +19,9 @@ export class VocabularyEntriesResponseParsingService extends BrowseEntriesRespon super(objectCache); } + getSerializerModel(): GenericConstructor { + return VocabularyEntry; + } + + } From 4cc1a3eecdc2eab2f8fde5ac0cf55c0d51958824 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Mon, 29 Jun 2020 22:18:10 +0200 Subject: [PATCH 008/126] [CST-3088] Refactored VocabularyService to use VocabularyOptions and added more methods --- .../submission-response-parsing.service.ts | 6 +- .../models/vocabulary-find-options.model.ts | 15 +- .../vocabularies/vocabulary.service.spec.ts | 116 ++++++++++++-- .../vocabularies/vocabulary.service.ts | 143 ++++++++++++++++-- .../shared/testing/vocabulary-service.stub.ts | 22 ++- 5 files changed, 255 insertions(+), 47 deletions(-) diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts index afabde831a..b740cde639 100644 --- a/src/app/core/submission/submission-response-parsing.service.ts +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@angular/core'; +import { Injectable } from '@angular/core'; import { deepClone } from 'fast-json-patch'; import { DSOResponseParsingService } from '../data/dso-response-parsing.service'; @@ -113,7 +113,7 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService return new ErrorResponse( Object.assign( new Error('Unexpected response from server'), - {statusCode: data.statusCode, statusText: data.statusText} + { statusCode: data.statusCode, statusText: data.statusText } ) ); } @@ -133,7 +133,7 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService processedList.forEach((item) => { - item = Object.assign({}, item); + // item = Object.assign({}, item); // In case data is an Instance of WorkspaceItem normalize field value of all the section of type form if (item instanceof WorkspaceItem || item instanceof WorkflowItem) { diff --git a/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts index 18309ed1fe..9dc12fad57 100644 --- a/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts +++ b/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts @@ -8,16 +8,15 @@ import { isNotEmpty } from '../../../../shared/empty.util'; */ export class VocabularyFindOptions extends FindListOptions { - constructor(public collection: string = '', - public name: string = '', - public metadata: string = '', + constructor(public collection, + public metadata, public query: string = '', + public filter?: string, + public exact?: boolean, + public entryID?: string, public elementsPerPage?: number, public currentPage?: number, - public sort?: SortOptions, - public filter?: string, - public exact?: string, - public entryID?: string, + public sort?: SortOptions ) { super(); @@ -35,7 +34,7 @@ export class VocabularyFindOptions extends FindListOptions { searchParams.push(new RequestParam('filter', filter)) } if (isNotEmpty(exact)) { - searchParams.push(new RequestParam('exact', exact)) + searchParams.push(new RequestParam('exact', exact.toString())) } if (isNotEmpty(entryID)) { searchParams.push(new RequestParam('entryID', entryID)) diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts index da496899d1..9e6d3b200e 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -9,7 +9,7 @@ import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.s import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { RequestService } from '../../data/request.service'; -import { VocabularyEntriesRequest } from '../../data/request.models'; +import { FindListOptions, VocabularyEntriesRequest } from '../../data/request.models'; import { RequestParam } from '../../cache/models/request-param.model'; import { PageInfo } from '../../shared/page-info.model'; import { PaginatedList } from '../../data/paginated-list'; @@ -19,7 +19,7 @@ import { RestResponse } from '../../cache/response.models'; import { VocabularyService } from './vocabulary.service'; import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; -import { VocabularyFindOptions } from './models/vocabulary-find-options.model'; +import { VocabularyOptions } from './models/vocabulary-options.model'; describe('VocabularyService', () => { let scheduler: TestScheduler; @@ -143,10 +143,18 @@ describe('VocabularyService', () => { const vocabularyId = 'types'; const metadata = 'dc.type'; const collectionUUID = '8b39g7ya-5a4b-438b-851f-be1d5b4a1c5a'; - const vocabularyOptions = new VocabularyFindOptions(collectionUUID, vocabularyId, metadata); + const entryID = 'dsfsfsdf-5a4b-438b-851f-be1d5b4a1c5a'; const searchRequestURL = `https://rest.api/rest/api/submission/vocabularies/search/byMetadataAndCollection?metadata=${metadata}&collection=${collectionUUID}`; const entriesRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?metadata=${metadata}&collection=${collectionUUID}`; - + const entriesByValueRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?metadata=${metadata}&collection=${collectionUUID}&filter=test&exact=false`; + const entryByValueRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?metadata=${metadata}&collection=${collectionUUID}&filter=test&exact=true`; + const entryByIDRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?metadata=${metadata}&collection=${collectionUUID}&entryID=${entryID}`; + const vocabularyOptions: VocabularyOptions = { + name: vocabularyId, + metadata: metadata, + scope: collectionUUID, + closed: false + } const pageInfo = new PageInfo(); const array = [vocabulary, hierarchicalVocabulary]; const arrayEntries = [vocabularyEntryDetail, anotherVocabularyEntryDetail]; @@ -274,11 +282,6 @@ describe('VocabularyService', () => { describe('searchVocabularyByMetadataAndCollection', () => { it('should proxy the call to vocabularyDataService.findVocabularyByHref', () => { - const options = new VocabularyFindOptions(); - options.searchParams = [ - new RequestParam('metadata', metadata), - new RequestParam('collection', collectionUUID) - ]; scheduler.schedule(() => service.searchVocabularyByMetadataAndCollection(vocabularyOptions).subscribe()); scheduler.flush(); @@ -307,20 +310,103 @@ describe('VocabularyService', () => { it('should configure a new VocabularyEntriesRequest', () => { const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entriesRequestURL); - scheduler.schedule(() => service.getVocabularyEntries(vocabularyOptions).subscribe()); + scheduler.schedule(() => service.getVocabularyEntries(vocabularyOptions, pageInfo).subscribe()); scheduler.flush(); expect(requestService.configure).toHaveBeenCalledWith(expected); }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { - service.getVocabularyEntries(vocabularyOptions); + service.getVocabularyEntries(vocabularyOptions, pageInfo); expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); }); }); + + describe('', () => { + + beforeEach(() => { + requestService = getMockRequestService(getRequestEntry$(true)); + rdbService = getMockRemoteDataBuildService(); + spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); + service = initTestService(); + }); + + describe('getVocabularyEntries', () => { + it('should configure a new VocabularyEntriesRequest', () => { + const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entriesRequestURL); + + scheduler.schedule(() => service.getVocabularyEntries(vocabularyOptions, pageInfo).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + service.getVocabularyEntries(vocabularyOptions, pageInfo); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + + }); + }); + + describe('getVocabularyEntriesByValue', () => { + it('should configure a new VocabularyEntriesRequest', () => { + const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entriesByValueRequestURL); + + scheduler.schedule(() => service.getVocabularyEntriesByValue('test', false, vocabularyOptions, pageInfo).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + service.getVocabularyEntriesByValue('test', false, vocabularyOptions, pageInfo); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + + }); + }); + + describe('getVocabularyEntryByValue', () => { + it('should configure a new VocabularyEntriesRequest', () => { + const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entryByValueRequestURL); + + scheduler.schedule(() => service.getVocabularyEntryByValue('test', vocabularyOptions).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + service.getVocabularyEntryByValue('test', vocabularyOptions); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + + }); + }); + + describe('getVocabularyEntryByID', () => { + it('should configure a new VocabularyEntriesRequest', () => { + const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entryByIDRequestURL); + + scheduler.schedule(() => service.getVocabularyEntryByID(entryID, vocabularyOptions).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + service.getVocabularyEntryByID('test', vocabularyOptions); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + + }); + }); + + }); }); describe('vocabularyEntryDetails endpoint', () => { @@ -400,16 +486,16 @@ describe('VocabularyService', () => { describe('searchByGroup', () => { it('should proxy the call to vocabularyEntryDetailDataService.searchBy', () => { - const options = new VocabularyFindOptions(); - options.searchParams.push(new RequestParam('vocabulary', 'srsc')); - scheduler.schedule(() => service.searchTopEntries('srsc', new VocabularyFindOptions())); + const options = new FindListOptions(); + options.searchParams = [new RequestParam('vocabulary', 'srsc')]; + scheduler.schedule(() => service.searchTopEntries('srsc', pageInfo)); scheduler.flush(); expect((service as any).vocabularyEntryDetailDataService.searchBy).toHaveBeenCalledWith((service as any).searchTopMethod, options); }); it('should return a RemoteData) for the search', () => { - const result = service.searchTopEntries('srsc', new VocabularyFindOptions()); + const result = service.searchTopEntries('srsc', pageInfo); const expected = cold('a|', { a: entriesPaginatedListRD }); diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index bd5c5b6c48..5a3f357881 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -22,12 +22,19 @@ import { PaginatedList } from '../../data/paginated-list'; import { Vocabulary } from './models/vocabulary.model'; import { VOCABULARY } from './models/vocabularies.resource-type'; import { VocabularyEntry } from './models/vocabulary-entry.model'; -import { hasValue, isNotEmptyOperator } from '../../../shared/empty.util'; -import { configureRequest, filterSuccessfulResponses, getRequestFromRequestHref } from '../../shared/operators'; +import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; +import { + configureRequest, + filterSuccessfulResponses, + getFirstSucceededRemoteListPayload, + getRequestFromRequestHref +} from '../../shared/operators'; import { GenericSuccessResponse } from '../../cache/response.models'; import { VocabularyFindOptions } from './models/vocabulary-find-options.model'; import { VocabularyEntryDetail } from './models/vocabulary-entry-detail.model'; import { RequestParam } from '../../cache/models/request-param.model'; +import { VocabularyOptions } from './models/vocabulary-options.model'; +import { PageInfo } from '../../shared/page-info.model'; /* tslint:disable:max-classes-per-file */ @@ -110,20 +117,20 @@ export class VocabularyService { /** * Returns an observable of {@link RemoteData} of a {@link Vocabulary}, based on its ID, with a list of {@link FollowLinkConfig}, * to automatically resolve {@link HALLink}s of the object - * @param id ID of {@link Vocabulary} we want to retrieve + * @param name The name of {@link Vocabulary} we want to retrieve * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved * @return {Observable>} * Return an observable that emits vocabulary object */ - findVocabularyById(id: string, ...linksToFollow: Array>): Observable> { - return this.vocabularyDataService.findById(id, ...linksToFollow); + findVocabularyById(name: string, ...linksToFollow: Array>): Observable> { + return this.vocabularyDataService.findById(name, ...linksToFollow); } /** * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded * info should be added to the objects * - * @param options Find list options object + * @param options Find list options object * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved * @return {Observable>>} * Return an observable that emits object list @@ -135,28 +142,131 @@ export class VocabularyService { /** * Return the {@link VocabularyEntry} list for a given {@link Vocabulary} * - * @param options The {@link VocabularyFindOptions} for the request + * @param vocabularyOptions The {@link VocabularyOptions} for the request to which the entries belong + * @param pageInfo The {@link PageInfo} for the request * @return {Observable>>} * Return an observable that emits object list */ - getVocabularyEntries(options: VocabularyFindOptions): Observable>> { + getVocabularyEntries(vocabularyOptions: VocabularyOptions, pageInfo: PageInfo): Observable>> { - return this.vocabularyDataService.getFindAllHref(options, `${options.name}/entries`).pipe( + const options: VocabularyFindOptions = new VocabularyFindOptions( + vocabularyOptions.scope, + vocabularyOptions.metadata, + null, + null, + null, + null, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + + return this.vocabularyDataService.getFindAllHref(options, `${vocabularyOptions.name}/entries`).pipe( isNotEmptyOperator(), distinctUntilChanged(), getVocabularyEntriesFor(this.requestService, this.rdbService) ); } + /** + * Return the {@link VocabularyEntry} list for a given {@link Vocabulary} + * + * @param value The entry value to retrieve + * @param exact If true force the vocabulary to provide only entries that match exactly with the value + * @param vocabularyOptions The {@link VocabularyOptions} for the request to which the entries belong + * @param pageInfo The {@link PageInfo} for the request + * @return {Observable>>} + * Return an observable that emits object list + */ + getVocabularyEntriesByValue(value: string, exact: boolean, vocabularyOptions: VocabularyOptions, pageInfo: PageInfo): Observable>> { + const options: VocabularyFindOptions = new VocabularyFindOptions( + vocabularyOptions.scope, + vocabularyOptions.metadata, + null, + value, + exact, + null, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + + return this.vocabularyDataService.getFindAllHref(options, `${vocabularyOptions.name}/entries`).pipe( + isNotEmptyOperator(), + distinctUntilChanged(), + getVocabularyEntriesFor(this.requestService, this.rdbService) + ); + } + + /** + * Return the {@link VocabularyEntry} list for a given value + * + * @param value The entry value to retrieve + * @param vocabularyOptions The {@link VocabularyOptions} for the request to which the entry belongs + * @return {Observable>>} + * Return an observable that emits {@link VocabularyEntry} object + */ + getVocabularyEntryByValue(value: string, vocabularyOptions: VocabularyOptions): Observable { + + return this.getVocabularyEntriesByValue(value, true, vocabularyOptions, new PageInfo()).pipe( + getFirstSucceededRemoteListPayload(), + map((list: VocabularyEntry[]) => { + if (isNotEmpty(list)) { + return list[0] + } else { + return null; + } + }) + ); + } + + /** + * Return the {@link VocabularyEntry} list for a given ID + * + * @param ID The entry ID to retrieve + * @param vocabularyOptions The {@link VocabularyOptions} for the request to which the entry belongs + * @return {Observable>>} + * Return an observable that emits {@link VocabularyEntry} object + */ + getVocabularyEntryByID(ID: string, vocabularyOptions: VocabularyOptions): Observable { + const pageInfo = new PageInfo() + const options: VocabularyFindOptions = new VocabularyFindOptions( + vocabularyOptions.scope, + vocabularyOptions.metadata, + null, + null, + null, + ID, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + + return this.vocabularyDataService.getFindAllHref(options, `${vocabularyOptions.name}/entries`).pipe( + isNotEmptyOperator(), + distinctUntilChanged(), + getVocabularyEntriesFor(this.requestService, this.rdbService), + getFirstSucceededRemoteListPayload(), + map((list: VocabularyEntry[]) => { + if (isNotEmpty(list)) { + return list[0] + } else { + return null; + } + }) + ); + } + /** * Return the controlled {@link Vocabulary} configured for the specified metadata and collection if any. * - * @param options The {@link VocabularyFindOptions} for the request - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @param vocabularyOptions The {@link VocabularyOptions} for the request + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved * @return {Observable>>} * Return an observable that emits object list */ - searchVocabularyByMetadataAndCollection(options: VocabularyFindOptions, ...linksToFollow: Array>): Observable> { + searchVocabularyByMetadataAndCollection(vocabularyOptions: VocabularyOptions, ...linksToFollow: Array>): Observable> { + const options: VocabularyFindOptions = new VocabularyFindOptions( + vocabularyOptions.scope, + vocabularyOptions.metadata + ); return this.vocabularyDataService.getSearchByHref(this.searchByMetadataAndCollectionMethod, options).pipe( first((href: string) => hasValue(href)), @@ -193,12 +303,13 @@ export class VocabularyService { /** * Return the top level {@link VocabularyEntryDetail} list for a given hierarchical vocabulary * - * @param name The name of hierarchical {@link Vocabulary} to which the entries belongs - * @param options The {@link VocabularyFindOptions} for the request + * @param name The name of hierarchical {@link Vocabulary} to which the entries belongs + * @param pageInfo The {@link PageInfo} for the request * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - searchTopEntries(name: string, options: VocabularyFindOptions, ...linksToFollow: Array>): Observable>> { - options.searchParams.push(new RequestParam('vocabulary', name)); + searchTopEntries(name: string, pageInfo: PageInfo, ...linksToFollow: Array>): Observable>> { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('vocabulary', name)]; return this.vocabularyEntryDetailDataService.searchBy(this.searchTopMethod, options, ...linksToFollow) } } diff --git a/src/app/shared/testing/vocabulary-service.stub.ts b/src/app/shared/testing/vocabulary-service.stub.ts index 15528653c2..fef9b72557 100644 --- a/src/app/shared/testing/vocabulary-service.stub.ts +++ b/src/app/shared/testing/vocabulary-service.stub.ts @@ -1,18 +1,18 @@ -import { Observable } from 'rxjs'; +import { Observable, of as observableOf } from 'rxjs'; -import { VocabularyFindOptions } from '../../core/submission/vocabularies/models/vocabulary-find-options.model'; import { PageInfo } from '../../core/shared/page-info.model'; import { VocabularyEntry } from '../../core/submission/vocabularies/models/vocabulary-entry.model'; import { PaginatedList } from '../../core/data/paginated-list'; import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; import { RemoteData } from '../../core/data/remote-data'; +import { VocabularyOptions } from '../../core/submission/vocabularies/models/vocabulary-options.model'; export class VocabularyServiceStub { private _payload = [ - Object.assign(new VocabularyEntry(),{authority: 1, display: 'one', value: 1}), - Object.assign(new VocabularyEntry(),{authority: 2, display: 'two', value: 2}), + Object.assign(new VocabularyEntry(), { authority: 1, display: 'one', value: 1 }), + Object.assign(new VocabularyEntry(), { authority: 2, display: 'two', value: 2 }), ]; setNewPayload(payload) { @@ -23,7 +23,19 @@ export class VocabularyServiceStub { return this._payload } - getVocabularyEntries(options: VocabularyFindOptions): Observable>> { + getVocabularyEntries(vocabularyOptions: VocabularyOptions, pageInfo: PageInfo): Observable>> { return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), this._payload)); } + + getVocabularyEntriesByValue(value: string, exact: boolean, vocabularyOptions: VocabularyOptions, pageInfo: PageInfo): Observable>> { + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), this._payload)); + } + + getVocabularyEntryByValue(value: string, vocabularyOptions: VocabularyOptions): Observable { + return observableOf(Object.assign(new VocabularyEntry(), { authority: 1, display: 'one', value: 1 })); + } + + getVocabularyEntryByID(id: string, vocabularyOptions: VocabularyOptions): Observable { + return observableOf(Object.assign(new VocabularyEntry(), { authority: 1, display: 'one', value: 1 })); + } } From 3225966600b03107f542390e2620361e226cfd08 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Mon, 29 Jun 2020 22:20:52 +0200 Subject: [PATCH 009/126] [CST-3088] Created abstract form component to handle vocabularies --- .../models/dynamic-vocabulary.component.ts | 119 ++++++++++ .../models/list/dynamic-list.component.ts | 20 +- .../lookup/dynamic-lookup.component.html | 8 +- .../lookup/dynamic-lookup.component.spec.ts | 110 +++++++++- .../models/lookup/dynamic-lookup.component.ts | 205 +++++++++++------- .../dynamic-relation-group.components.ts | 4 +- ...dynamic-scrollable-dropdown.component.html | 10 +- ...amic-scrollable-dropdown.component.spec.ts | 4 +- .../dynamic-scrollable-dropdown.component.ts | 131 ++++++----- .../models/tag/dynamic-tag.component.spec.ts | 8 +- .../models/tag/dynamic-tag.component.ts | 91 +++++--- .../dynamic-typeahead.component.spec.ts | 60 ++++- .../typeahead/dynamic-typeahead.component.ts | 108 ++++++--- .../form/builder/parsers/list-field-parser.ts | 2 - .../shared/form/builder/parsers/row-parser.ts | 19 +- 15 files changed, 649 insertions(+), 250 deletions(-) create mode 100644 src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts new file mode 100644 index 0000000000..bf09e237a5 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts @@ -0,0 +1,119 @@ +import { EventEmitter, Input, Output } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { + DynamicFormControlComponent, + DynamicFormLayoutService, + DynamicFormValidationService +} from '@ng-dynamic-forms/core'; +import { map } from 'rxjs/operators'; +import { Observable, of as observableOf } from 'rxjs'; + +import { VocabularyService } from '../../../../../core/submission/vocabularies/vocabulary.service'; +import { isNotEmpty } from '../../../../empty.util'; +import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; +import { VocabularyEntry } from '../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { DsDynamicInputModel } from './ds-dynamic-input.model'; +import { PageInfo } from '../../../../../core/shared/page-info.model'; + +/** + * An abstract class to be extended by form components that handle vocabulary + */ +export abstract class DsDynamicVocabularyComponent extends DynamicFormControlComponent { + + @Input() abstract bindId = true; + @Input() abstract group: FormGroup; + @Input() abstract model: DsDynamicInputModel; + + @Output() abstract blur: EventEmitter = new EventEmitter(); + @Output() abstract change: EventEmitter = new EventEmitter(); + @Output() abstract focus: EventEmitter = new EventEmitter(); + + public abstract pageInfo: PageInfo; + + constructor(protected vocabularyService: VocabularyService, + protected layoutService: DynamicFormLayoutService, + protected validationService: DynamicFormValidationService + ) { + super(layoutService, validationService); + } + + /** + * Sets the current value with the given value. + * @param value The value to set. + * @param init Representing if is init value or not. + */ + public abstract setCurrentValue(value: any, init?: boolean); + + /** + * Retrieves the init form value from model + */ + getInitValueFromModel(): Observable { + let initValue$: Observable; + if (isNotEmpty(this.model.value) && (this.model.value instanceof FormFieldMetadataValueObject)) { + let initEntry$: Observable; + if (this.model.value.hasAuthority()) { + initEntry$ = this.vocabularyService.getVocabularyEntryByID(this.model.value.authority, this.model.vocabularyOptions) + } else { + initEntry$ = this.vocabularyService.getVocabularyEntryByValue(this.model.value.value, this.model.vocabularyOptions) + } + initValue$ = initEntry$.pipe(map((initEntry: VocabularyEntry) => { + if (isNotEmpty(initEntry)) { + return new FormFieldMetadataValueObject( + initEntry.value, + null, + initEntry.authority, + initEntry.display + ); + } else { + return this.model.value as any; + } + })); + } else { + initValue$ = observableOf(new FormFieldMetadataValueObject(this.model.value)); + } + return initValue$; + } + + + /** + * Emits a blur event containing a given value. + * @param event The value to emit. + */ + onBlur(event: Event) { + this.blur.emit(event); + } + + /** + * Emits a focus event containing a given value. + * @param event The value to emit. + */ + onFocus(event) { + this.focus.emit(event); + } + + /** + * Emits a change event and updates model value. + * @param updateValue + */ + dispatchUpdate(updateValue: any) { + this.model.valueUpdates.next(updateValue); + this.change.emit(updateValue); + } + + /** + * Update the page info object + * @param elementsPerPage + * @param currentPage + * @param totalElements + * @param totalPages + */ + protected updatePageInfo(elementsPerPage: number, currentPage: number, totalElements?: number, totalPages?: number) { + this.pageInfo = Object.assign(new PageInfo(), { + elementsPerPage: elementsPerPage, + currentPage: currentPage, + totalElements: totalElements, + totalPages: totalPages + }); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts index 3c5a86f362..d7c492ccde 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts @@ -9,7 +9,6 @@ import { } from '@ng-dynamic-forms/core'; import { findKey } from 'lodash'; -import { VocabularyFindOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-find-options.model'; import { hasValue, isNotEmpty } from '../../../../../empty.util'; import { DynamicListCheckboxGroupModel } from './dynamic-list-checkbox-group.model'; import { FormBuilderService } from '../../../form-builder.service'; @@ -18,6 +17,7 @@ import { VocabularyService } from '../../../../../../core/submission/vocabularie import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; import { PaginatedList } from '../../../../../../core/data/paginated-list'; import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { PageInfo } from '../../../../../../core/shared/page-info.model'; export interface ListItem { id: string, @@ -26,12 +26,14 @@ export interface ListItem { index: number } +/** + * Component representing a list input field + */ @Component({ selector: 'ds-dynamic-list', styleUrls: ['./dynamic-list.component.scss'], templateUrl: './dynamic-list.component.html' }) - export class DsDynamicListComponent extends DynamicFormControlComponent implements OnInit { @Input() bindId = true; @Input() group: FormGroup; @@ -43,7 +45,6 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen public items: ListItem[][] = []; protected optionsList: VocabularyEntry[]; - protected searchOptions: VocabularyFindOptions; constructor(private vocabularyService: VocabularyService, private cdr: ChangeDetectorRef, @@ -56,14 +57,6 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen ngOnInit() { if (this.hasAuthorityOptions()) { - // TODO Replace max elements 1000 with a paginated request when pagination bug is resolved - this.searchOptions = new VocabularyFindOptions( - this.model.vocabularyOptions.scope, - this.model.vocabularyOptions.name, - this.model.vocabularyOptions.metadata, - '', - 1000, // Max elements - 1);// Current Page this.setOptionsFromAuthority(); } } @@ -99,7 +92,10 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen protected setOptionsFromAuthority() { if (this.model.vocabularyOptions.name && this.model.vocabularyOptions.name.length > 0) { const listGroup = this.group.controls[this.model.id] as FormGroup; - this.vocabularyService.getVocabularyEntries(this.searchOptions).pipe( + const pageInfo: PageInfo = new PageInfo({ + elementsPerPage: Number.MAX_VALUE, currentPage: 1 + } as PageInfo); + this.vocabularyService.getVocabularyEntries(this.model.vocabularyOptions, pageInfo).pipe( getFirstSucceededRemoteDataPayload() ).subscribe((entries: PaginatedList) => { let groupCounter = 0; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.html index 254b15411d..b274197f13 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.html @@ -21,8 +21,8 @@ [placeholder]="model.placeholder | translate" [readonly]="model.readOnly" (change)="onChange($event)" - (blur)="onBlurEvent($event); $event.stopPropagation(); sdRef.close();" - (focus)="onFocusEvent($event); $event.stopPropagation(); sdRef.close();" + (blur)="onBlur($event); $event.stopPropagation(); sdRef.close();" + (focus)="onFocus($event); $event.stopPropagation(); sdRef.close();" (click)="$event.stopPropagation(); $event.stopPropagation(); sdRef.close();"> @@ -40,8 +40,8 @@ [placeholder]="model.secondPlaceholder | translate" [readonly]="model.readOnly" (change)="onChange($event)" - (blur)="onBlurEvent($event); $event.stopPropagation(); sdRef.close();" - (focus)="onFocusEvent($event); $event.stopPropagation(); sdRef.close();" + (blur)="onBlur($event); $event.stopPropagation(); sdRef.close();" + (focus)="onFocus($event); $event.stopPropagation(); sdRef.close();" (click)="$event.stopPropagation(); sdRef.close();">
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts index ce45e113a0..66eca2393b 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts @@ -4,6 +4,7 @@ import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angul import { async, ComponentFixture, fakeAsync, inject, TestBed, tick, } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { of as observableOf } from 'rxjs'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; import { InfiniteScrollModule } from 'ngx-infinite-scroll'; @@ -309,7 +310,13 @@ describe('Dynamic Lookup component', () => { lookupComp = lookupFixture.componentInstance; // FormComponent test instance lookupComp.group = LOOKUP_TEST_GROUP; lookupComp.model = new DynamicLookupModel(LOOKUP_TEST_MODEL_CONFIG); - lookupComp.model.value = new FormFieldMetadataValueObject('test', null, 'test001'); + const entry = observableOf(Object.assign(new VocabularyEntry(), { + authority: null, + value: 'test', + display: 'testDisplay' + })); + spyOn((lookupComp as any).vocabularyService, 'getVocabularyEntryByValue').and.returnValue(entry); + (lookupComp.model as any).value = new FormFieldMetadataValueObject('test', null, null, 'testDisplay'); lookupFixture.detectChanges(); // spyOn(store, 'dispatch'); @@ -318,9 +325,52 @@ describe('Dynamic Lookup component', () => { lookupFixture.destroy(); lookupComp = null; }); - it('should init component properly', () => { - expect(lookupComp.firstInputValue).toBe('test'); + it('should init component properly', fakeAsync(() => { + tick(); + expect(lookupComp.firstInputValue).toBe('testDisplay'); + expect((lookupComp as any).vocabularyService.getVocabularyEntryByValue).toHaveBeenCalled(); + })); + + it('should have search button disabled on edit mode', () => { + lookupComp.editMode = true; + lookupFixture.detectChanges(); + + const de = lookupFixture.debugElement.queryAll(By.css('button')); + const searchBtnEl = de[0].nativeElement; + const saveBtnEl = de[1].nativeElement; + expect(searchBtnEl.disabled).toBe(true); + expect(saveBtnEl.disabled).toBe(false); + expect(saveBtnEl.textContent.trim()).toBe('form.save'); + }); + }); + describe('and init model value is not empty with authority', () => { + beforeEach(() => { + + lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); + lookupComp = lookupFixture.componentInstance; // FormComponent test instance + lookupComp.group = LOOKUP_TEST_GROUP; + lookupComp.model = new DynamicLookupModel(LOOKUP_TEST_MODEL_CONFIG); + const entry = observableOf(Object.assign(new VocabularyEntry(), { + authority: 'test001', + value: 'test', + display: 'testDisplay' + })); + spyOn((lookupComp as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry); + lookupComp.model.value = new FormFieldMetadataValueObject('test', null, 'test001', 'testDisplay'); + lookupFixture.detectChanges(); + + // spyOn(store, 'dispatch'); + }); + afterEach(() => { + lookupFixture.destroy(); + lookupComp = null; + }); + it('should init component properly', fakeAsync(() => { + tick(); + expect(lookupComp.firstInputValue).toBe('testDisplay'); + expect((lookupComp as any).vocabularyService.getVocabularyEntryByID).toHaveBeenCalled(); + })); it('should have search button disabled on edit mode', () => { lookupComp.editMode = true; @@ -430,6 +480,13 @@ describe('Dynamic Lookup component', () => { lookupComp.group = LOOKUP_TEST_GROUP; lookupComp.model = new DynamicLookupNameModel(LOOKUP_NAME_TEST_MODEL_CONFIG); lookupComp.model.value = new FormFieldMetadataValueObject('Name, Lastname', null, 'test001'); + const entry = observableOf(Object.assign(new VocabularyEntry(), { + authority: null, + value: 'Name, Lastname', + display: 'Name, Lastname' + })); + spyOn((lookupComp as any).vocabularyService, 'getVocabularyEntryByValue').and.returnValue(entry); + (lookupComp.model as any).value = new FormFieldMetadataValueObject('Name, Lastname', null, null, 'Name, Lastname'); lookupFixture.detectChanges(); }); @@ -437,10 +494,55 @@ describe('Dynamic Lookup component', () => { lookupFixture.destroy(); lookupComp = null; }); - it('should init component properly', () => { + it('should init component properly', fakeAsync(() => { + tick(); expect(lookupComp.firstInputValue).toBe('Name'); expect(lookupComp.secondInputValue).toBe('Lastname'); + expect((lookupComp as any).vocabularyService.getVocabularyEntryByValue).toHaveBeenCalled(); + })); + + it('should have search button disabled on edit mode', () => { + lookupComp.editMode = true; + lookupFixture.detectChanges(); + + const de = lookupFixture.debugElement.queryAll(By.css('button')); + const searchBtnEl = de[0].nativeElement; + const saveBtnEl = de[1].nativeElement; + expect(searchBtnEl.disabled).toBe(true); + expect(saveBtnEl.disabled).toBe(false); + expect(saveBtnEl.textContent.trim()).toBe('form.save'); + }); + }); + + describe('and init model value is not empty with authority', () => { + beforeEach(() => { + + lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); + lookupComp = lookupFixture.componentInstance; // FormComponent test instance + lookupComp.group = LOOKUP_TEST_GROUP; + lookupComp.model = new DynamicLookupNameModel(LOOKUP_NAME_TEST_MODEL_CONFIG); + lookupComp.model.value = new FormFieldMetadataValueObject('Name, Lastname', null, 'test001'); + const entry = observableOf(Object.assign(new VocabularyEntry(), { + authority: 'test001', + value: 'Name, Lastname', + display: 'Name, Lastname' + })); + spyOn((lookupComp as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry); + lookupComp.model.value = new FormFieldMetadataValueObject('Name, Lastname', null, 'test001', 'Name, Lastname'); + lookupFixture.detectChanges(); + + }); + afterEach(() => { + lookupFixture.destroy(); + lookupComp = null; + }); + it('should init component properly', fakeAsync(() => { + tick(); + expect(lookupComp.firstInputValue).toBe('Name'); + expect(lookupComp.secondInputValue).toBe('Lastname'); + expect((lookupComp as any).vocabularyService.getVocabularyEntryByID).toHaveBeenCalled(); + })); it('should have search button disabled on edit mode', () => { lookupComp.editMode = true; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts index 24422e891a..9ae4ad0737 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts @@ -4,15 +4,10 @@ import { FormGroup } from '@angular/forms'; import { of as observableOf, Subscription } from 'rxjs'; import { catchError, distinctUntilChanged } from 'rxjs/operators'; import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'; -import { - DynamicFormControlComponent, - DynamicFormLayoutService, - DynamicFormValidationService -} from '@ng-dynamic-forms/core'; +import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; -import { VocabularyFindOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-find-options.model'; -import { hasValue, isEmpty, isNotEmpty, isNull, isUndefined } from '../../../../../empty.util'; +import { hasValue, isEmpty, isNotEmpty } from '../../../../../empty.util'; import { PageInfo } from '../../../../../../core/shared/page-info.model'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; @@ -20,16 +15,21 @@ import { DynamicLookupNameModel } from './dynamic-lookup-name.model'; import { ConfidenceType } from '../../../../../../core/shared/confidence-type'; import { PaginatedList } from '../../../../../../core/data/paginated-list'; import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; +import { DsDynamicVocabularyComponent } from '../dynamic-vocabulary.component'; +import { DynamicLookupModel } from './dynamic-lookup.model'; +/** + * Component representing a lookup or lookup-name input field + */ @Component({ selector: 'ds-dynamic-lookup', styleUrls: ['./dynamic-lookup.component.scss'], templateUrl: './dynamic-lookup.component.html' }) -export class DsDynamicLookupComponent extends DynamicFormControlComponent implements OnDestroy, OnInit { +export class DsDynamicLookupComponent extends DsDynamicVocabularyComponent implements OnDestroy, OnInit { @Input() bindId = true; @Input() group: FormGroup; - @Input() model: any; + @Input() model: DynamicLookupModel | DynamicLookupNameModel; @Output() blur: EventEmitter = new EventEmitter(); @Output() change: EventEmitter = new EventEmitter(); @@ -42,89 +42,97 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem public pageInfo: PageInfo; public optionsList: any; - protected searchOptions: VocabularyFindOptions; protected subs: Subscription[] = []; - constructor(private vocabularyService: VocabularyService, + constructor(protected vocabularyService: VocabularyService, private cdr: ChangeDetectorRef, protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService ) { - super(layoutService, validationService); + super(vocabularyService, layoutService, validationService); } + /** + * Converts an item from the result list to a `string` to display in the `` field. + */ inputFormatter = (x: { display: string }, y: number) => { return y === 1 ? this.firstInputValue : this.secondInputValue; }; + /** + * Initialize the component, setting up the init form value + */ ngOnInit() { - this.searchOptions = new VocabularyFindOptions( - this.model.vocabularyOptions.scope, - this.model.vocabularyOptions.name, - this.model.vocabularyOptions.metadata, - '', - this.model.maxOptions, - 1); - - this.setInputsValue(this.model.value); + if (isNotEmpty(this.model.value)) { + this.setCurrentValue(this.model.value, true); + } this.subs.push(this.model.valueUpdates .subscribe((value) => { if (isEmpty(value)) { this.resetFields(); } else if (!this.editMode) { - this.setInputsValue(this.model.value); + this.setCurrentValue(this.model.value); } })); } - public formatItemForInput(item: any, field: number): string { - if (isUndefined(item) || isNull(item)) { - return ''; - } - return (typeof item === 'string') ? item : this.inputFormatter(item, field); - } - + /** + * Check if model value has an authority + */ public hasAuthorityValue() { return hasValue(this.model.value) && this.model.value.hasAuthority(); } + /** + * Check if current value has an authority + */ public hasEmptyValue() { return isNotEmpty(this.getCurrentValue()); } + /** + * Clear inputs whether there is no results and authority is closed + */ public clearFields() { - // Clear inputs whether there is no results and authority is closed if (this.model.vocabularyOptions.closed) { this.resetFields(); } } + /** + * Check if edit button is disabled + */ public isEditDisabled() { return !this.hasAuthorityValue(); } + /** + * Check if input is disabled + */ public isInputDisabled() { return (this.model.vocabularyOptions.closed && this.hasAuthorityValue() && !this.editMode); } + /** + * Check if model is instanceof DynamicLookupNameModel + */ public isLookupName() { return (this.model instanceof DynamicLookupNameModel); } + /** + * Check if search button is disabled + */ public isSearchDisabled() { return isEmpty(this.firstInputValue) || this.editMode; } - public onBlurEvent(event: Event) { - this.blur.emit(event); - } - - public onFocusEvent(event) { - this.focus.emit(event); - } - + /** + * Update model value with the typed text if vocabulary is not closed + * @param event the typed text + */ public onChange(event) { event.preventDefault(); if (!this.model.vocabularyOptions.closed) { @@ -139,31 +147,51 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem } } + /** + * Load more result entries + */ public onScroll() { if (!this.loading && this.pageInfo.currentPage <= this.pageInfo.totalPages) { - this.searchOptions.currentPage++; + this.updatePageInfo( + this.pageInfo.elementsPerPage, + this.pageInfo.currentPage + 1, + this.pageInfo.totalElements, + this.pageInfo.totalPages + ); this.search(); } } + /** + * Update model value with selected entry + * @param event the selected entry + */ public onSelect(event) { this.updateModel(event); } + /** + * Reset the current value when dropdown toggle + */ public openChange(isOpened: boolean) { if (!isOpened) { if (this.model.vocabularyOptions.closed && !this.hasAuthorityValue()) { - this.setInputsValue(''); + this.setCurrentValue(''); } } } + /** + * Reset the model value + */ public remove() { this.group.markAsPristine(); - this.model.valueUpdates.next(null); - this.change.emit(null); + this.dispatchUpdate(null) } + /** + * Saves all changes + */ public saveChanges() { if (isNotEmpty(this.getCurrentValue())) { const newValue = Object.assign(new VocabularyEntry(), this.model.value, { @@ -177,15 +205,21 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem this.switchEditMode(); } + /** + * Converts a stream of text values from the `` element to the stream of the array of items + * to display in the result list. + */ public search() { this.optionsList = null; - this.pageInfo = null; - - // Query - this.searchOptions.query = this.getCurrentValue(); - + this.updatePageInfo(this.model.maxOptions, 1); this.loading = true; - this.subs.push(this.vocabularyService.getVocabularyEntries(this.searchOptions).pipe( + + this.subs.push(this.vocabularyService.getVocabularyEntriesByValue( + this.getCurrentValue(), + false, + this.model.vocabularyOptions, + this.pageInfo + ).pipe( getFirstSucceededRemoteDataPayload(), catchError(() => observableOf(new PaginatedList( @@ -195,18 +229,28 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem ), distinctUntilChanged()) .subscribe((list: PaginatedList) => { - console.log(list); this.optionsList = list.page; - this.pageInfo = list.pageInfo; + this.updatePageInfo( + list.pageInfo.elementsPerPage, + list.pageInfo.currentPage, + list.pageInfo.totalElements, + list.pageInfo.totalPages + ); this.loading = false; this.cdr.detectChanges(); })); } + /** + * Changes the edit mode flag + */ public switchEditMode() { this.editMode = !this.editMode; } + /** + * Callback functions for whenClickOnConfidenceNotAccepted event + */ public whenClickOnConfidenceNotAccepted(sdRef: NgbDropdown, confidence: ConfidenceType) { if (!this.model.readOnly) { sdRef.open(); @@ -220,6 +264,38 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem .forEach((sub) => sub.unsubscribe()); } + /** + * Sets the current value with the given value. + * @param value The value to set. + * @param init Representing if is init value or not. + */ + public setCurrentValue(value: any, init = false) { + if (init) { + this.getInitValueFromModel() + .subscribe((value: FormFieldMetadataValueObject) => this.setDisplayInputValue(value.display)); + } else if (hasValue(value)) { + if (value instanceof FormFieldMetadataValueObject || value instanceof VocabularyEntry) { + this.setDisplayInputValue(value.display); + } + } + } + + protected setDisplayInputValue(displayValue: string) { + if (hasValue(displayValue)) { + if (this.isLookupName()) { + const values = displayValue.split((this.model as DynamicLookupNameModel).separator); + + this.firstInputValue = (values[0] || '').trim(); + this.secondInputValue = (values[1] || '').trim(); + } else { + this.firstInputValue = displayValue || ''; + } + } + } + + /** + * Gets the current text present in the input field(s) + */ protected getCurrentValue(): string { let result = ''; if (!this.isLookupName()) { @@ -237,6 +313,9 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem return result; } + /** + * Clear text present in the input field(s) + */ protected resetFields() { this.firstInputValue = ''; if (this.isLookupName()) { @@ -244,32 +323,12 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem } } - protected setInputsValue(value) { - if (hasValue(value)) { - let displayValue = value; - if (value instanceof FormFieldMetadataValueObject || value instanceof VocabularyEntry) { - displayValue = value.display; - } - - if (hasValue(displayValue)) { - if (this.isLookupName()) { - const values = displayValue.split((this.model as DynamicLookupNameModel).separator); - - this.firstInputValue = (values[0] || '').trim(); - this.secondInputValue = (values[1] || '').trim(); - } else { - this.firstInputValue = displayValue || ''; - } - } - } - } - protected updateModel(value) { this.group.markAsDirty(); - this.model.valueUpdates.next(value); - this.setInputsValue(value); - this.change.emit(value); + this.dispatchUpdate(value); + this.setCurrentValue(value); this.optionsList = null; this.pageInfo = null; } + } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts index 21606d7abc..51cf69d560 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts @@ -23,13 +23,15 @@ import { hasValue, isEmpty, isNotEmpty, isNotNull } from '../../../../../empty.u import { shrinkInOut } from '../../../../../animations/shrink'; import { ChipsItem } from '../../../../../chips/models/chips-item.model'; import { hasOnlyEmptyProperties } from '../../../../../object.util'; -import { VocabularyFindOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-find-options.model'; import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; import { environment } from '../../../../../../../environments/environment'; import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; import { VocabularyEntryDetail } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +/** + * Component representing a group input field + */ @Component({ selector: 'ds-dynamic-relation-group', styleUrls: ['./dynamic-relation-group.component.scss'], diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html index cfe50def98..7a35287a99 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html @@ -1,5 +1,5 @@ -
- + -
- -

- {{'treeview.search.no-result' | translate}} + +

+ {{'vocabulary-treeview.search.no-result' | translate}}

@@ -62,13 +62,13 @@ diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.spec.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.spec.ts index 39f4274280..9af00b6be7 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.spec.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.spec.ts @@ -16,6 +16,7 @@ import { TreeviewFlatNode } from './vocabulary-treeview-node.model'; import { FormFieldMetadataValueObject } from '../form/builder/models/form-field-metadata-value.model'; import { VocabularyOptions } from '../../core/submission/vocabularies/models/vocabulary-options.model'; import { PageInfo } from '../../core/shared/page-info.model'; +import { VocabularyEntry } from '../../core/submission/vocabularies/models/vocabulary-entry.model'; describe('VocabularyTreeviewComponent test suite', () => { @@ -37,7 +38,7 @@ describe('VocabularyTreeviewComponent test suite', () => { getData: jasmine.createSpy('getData'), loadMore: jasmine.createSpy('loadMore'), loadMoreRoot: jasmine.createSpy('loadMoreRoot'), - isSearching: jasmine.createSpy('isSearching'), + isLoading: jasmine.createSpy('isLoading'), searchByQuery: jasmine.createSpy('searchByQuery'), restoreNodes: jasmine.createSpy('restoreNodes'), cleanTree: jasmine.createSpy('cleanTree'), @@ -100,7 +101,7 @@ describe('VocabularyTreeviewComponent test suite', () => { comp = fixture.componentInstance; compAsAny = comp; vocabularyTreeviewServiceStub.getData.and.returnValue(observableOf([])); - vocabularyTreeviewServiceStub.isSearching.and.returnValue(observableOf(false)); + vocabularyTreeviewServiceStub.isLoading.and.returnValue(observableOf(false)); comp.vocabularyOptions = vocabularyOptions; comp.selectedItem = null; }); @@ -118,19 +119,27 @@ describe('VocabularyTreeviewComponent test suite', () => { }); it('should should init component properly with init value as FormFieldMetadataValueObject', () => { - comp.selectedItem = new FormFieldMetadataValueObject('test', null, 'auth001'); + const currentValue = new FormFieldMetadataValueObject(); + currentValue.value = 'testValue'; + currentValue.otherInformation = { + id: 'entryID' + }; + comp.selectedItem = currentValue; fixture.detectChanges(); expect(comp.dataSource.data).toEqual([]); - expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalledWith(comp.vocabularyOptions, new PageInfo(), 'auth001'); + expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalledWith(comp.vocabularyOptions, new PageInfo(), 'entryID'); }); - it('should should init component properly with init value as AuthorityEntry', () => { - const authority = new VocabularyEntryDetail(); - authority.id = 'auth001'; - comp.selectedItem = authority; + it('should should init component properly with init value as VocabularyEntry', () => { + const currentValue = new VocabularyEntry(); + currentValue.value = 'testValue'; + currentValue.otherInformation = { + id: 'entryID' + }; + comp.selectedItem = currentValue; fixture.detectChanges(); expect(comp.dataSource.data).toEqual([]); - expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalledWith(comp.vocabularyOptions, new PageInfo(), 'auth001'); + expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalledWith(comp.vocabularyOptions, new PageInfo(), 'entryID'); }); it('should call loadMore function', () => { diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts index 34c9a52c60..8c7543849d 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts @@ -16,6 +16,7 @@ import { VocabularyTreeviewService } from './vocabulary-treeview.service'; import { LOAD_MORE, LOAD_MORE_ROOT, TreeviewFlatNode, TreeviewNode } from './vocabulary-treeview-node.model'; import { VocabularyOptions } from '../../core/submission/vocabularies/models/vocabulary-options.model'; import { PageInfo } from '../../core/shared/page-info.model'; +import { VocabularyEntry } from '../../core/submission/vocabularies/models/vocabulary-entry.model'; /** * Component that show a hierarchical vocabulary in a tree view @@ -78,9 +79,9 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit { searchText: string; /** - * A boolean representing if a search operation is pending + * A boolean representing if tree is loading */ - searching: Observable; + loading: Observable; /** * An event fired when a vocabulary entry is selected. @@ -198,7 +199,7 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit { }) ); - const descriptionLabel = 'tree.description.' + this.vocabularyOptions.name; + const descriptionLabel = 'vocabulary-treeview.tree.description.' + this.vocabularyOptions.name; this.description = this.translate.get(descriptionLabel).pipe( filter((msg) => msg !== descriptionLabel), startWith('') @@ -207,13 +208,13 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit { // set isAuthenticated this.isAuthenticated = this.store.pipe(select(isAuthenticated)); - this.searching = this.vocabularyTreeviewService.isSearching(); + this.loading = this.vocabularyTreeviewService.isLoading(); this.isAuthenticated.pipe( find((isAuth) => isAuth) ).subscribe(() => { - const valueId: string = (this.selectedItem) ? (this.selectedItem.authority || this.selectedItem.id) : null; - this.vocabularyTreeviewService.initialize(this.vocabularyOptions, new PageInfo(), valueId); + const entryId: string = (this.selectedItem) ? this.getEntryId(this.selectedItem) : null; + this.vocabularyTreeviewService.initialize(this.vocabularyOptions, new PageInfo(), entryId); }); } @@ -292,4 +293,11 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit { .filter((sub) => hasValue(sub)) .forEach((sub) => sub.unsubscribe()); } + + /** + * Return an id for a given {@link VocabularyEntry} + */ + private getEntryId(entry: VocabularyEntry): string { + return entry.authority || entry.otherInformation.id || undefined; + } } diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts index 32ef635792..2f03549898 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts @@ -50,39 +50,47 @@ describe('VocabularyTreeviewService test suite', () => { let nodeMapWithChildren: Map; let searchNodeMap: Map; let vocabularyOptions; + let pageInfo: PageInfo; const vocabularyServiceStub = jasmine.createSpyObj('VocabularyService', { getVocabularyEntriesByValue: jasmine.createSpy('getVocabularyEntriesByValue'), getEntryDetailParent: jasmine.createSpy('getEntryDetailParent'), findEntryDetailByValue: jasmine.createSpy('findEntryDetailByValue'), searchTopEntries: jasmine.createSpy('searchTopEntries'), - getEntryDetailChildren: jasmine.createSpy('getEntryDetailChildren') + getEntryDetailChildren: jasmine.createSpy('getEntryDetailChildren'), + clearSearchTopRequests: jasmine.createSpy('clearSearchTopRequests') }); function init() { + pageInfo = Object.assign(new PageInfo(), { + elementsPerPage: 1, + totalElements: 3, + totalPages: 1, + currentPage: 1 + }); loadMoreNode = new TreeviewNode(LOAD_MORE_NODE, false, new PageInfo(), item); loadMoreRootNode = new TreeviewNode(LOAD_MORE_ROOT_NODE, false, new PageInfo(), null); loadMoreRootFlatNode = new TreeviewFlatNode(LOAD_MORE_ROOT_NODE, 1, false, new PageInfo(), null); item = new VocabularyEntryDetail(); item.id = item.value = item.display = 'root1'; - item.otherInformation = { children: 'root1-child1::root1-child2', id: 'root1' }; - itemNode = new TreeviewNode(item, true); + item.otherInformation = { hasChildren: 'true', id: 'root1' }; + itemNode = new TreeviewNode(item, true, pageInfo); searchItemNode = new TreeviewNode(item, true, new PageInfo(), null, true); item2 = new VocabularyEntryDetail(); item2.id = item2.value = item2.display = 'root2'; item2.otherInformation = { id: 'root2' }; - itemNode2 = new TreeviewNode(item2); + itemNode2 = new TreeviewNode(item2, false, pageInfo); item3 = new VocabularyEntryDetail(); item3.id = item3.value = item3.display = 'root3'; item3.otherInformation = { id: 'root3' }; - itemNode3 = new TreeviewNode(item3); + itemNode3 = new TreeviewNode(item3, false, pageInfo); child = new VocabularyEntryDetail(); child.id = child.value = child.display = 'root1-child1'; - child.otherInformation = { parent: 'root1', children: 'root1-child1-child1', id: 'root1-child1' }; + child.otherInformation = { parent: 'root1', hasChildren: 'true', id: 'root1-child1' }; childNode = new TreeviewNode(child); searchChildNode = new TreeviewNode(child, true, new PageInfo(), item, true); @@ -167,12 +175,6 @@ describe('VocabularyTreeviewService test suite', () => { describe('initialize', () => { it('should set vocabularyName and call retrieveTopNodes method', () => { - const pageInfo = Object.assign(new PageInfo(), { - elementsPerPage: 1, - totalElements: 3, - totalPages: 1, - currentPage: 1 - }); serviceAsAny.vocabularyService.searchTopEntries.and.returnValue(hot('-a', { a: createSuccessfulRemoteDataObject(new PaginatedList(pageInfo, [item, item2, item3])) })); @@ -181,16 +183,12 @@ describe('VocabularyTreeviewService test suite', () => { scheduler.flush(); expect(serviceAsAny.vocabularyName).toEqual(vocabularyOptions.name); + expect(serviceAsAny.pageInfo).toEqual(pageInfo); + console.log(serviceAsAny.dataChange.value[0].pageInfo, itemNode.pageInfo); expect(serviceAsAny.dataChange.value).toEqual([itemNode, itemNode2, itemNode3]); }); it('should set initValueHierarchy', () => { - const pageInfo = Object.assign(new PageInfo(), { - elementsPerPage: 1, - totalElements: 3, - totalPages: 1, - currentPage: 1 - }); serviceAsAny.vocabularyService.searchTopEntries.and.returnValue(hot('-c', { a: createSuccessfulRemoteDataObject(new PaginatedList(pageInfo, [item, item2, item3])) })); @@ -239,7 +237,7 @@ describe('VocabularyTreeviewService test suite', () => { }); it('should add children nodes properly', () => { - const pageInfo = Object.assign(new PageInfo(), { + pageInfo = Object.assign(new PageInfo(), { elementsPerPage: 1, totalElements: 2, totalPages: 2, @@ -260,7 +258,7 @@ describe('VocabularyTreeviewService test suite', () => { }); it('should add loadMore node properly', () => { - const pageInfo = Object.assign(new PageInfo(), { + pageInfo = Object.assign(new PageInfo(), { elementsPerPage: 1, totalElements: 2, totalPages: 2, @@ -285,7 +283,7 @@ describe('VocabularyTreeviewService test suite', () => { describe('searchByQuery', () => { it('should set tree data properly after a search', () => { - const pageInfo = Object.assign(new PageInfo(), { + pageInfo = Object.assign(new PageInfo(), { elementsPerPage: 1, totalElements: 1, totalPages: 1, diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts index bfaddf414e..8beead5419 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; -import { flatMap, map, merge, scan, take, tap } from 'rxjs/operators'; +import { flatMap, map, merge, scan } from 'rxjs/operators'; import { findIndex } from 'lodash'; import { LOAD_MORE_NODE, LOAD_MORE_ROOT_NODE, TreeviewFlatNode, TreeviewNode } from './vocabulary-treeview-node.model'; @@ -56,14 +56,18 @@ export class VocabularyTreeviewService { private initValueHierarchy: string[] = []; /** - * A boolean representing if a search operation is pending + * A boolean representing if any operation is pending */ - private searching = new BehaviorSubject(false); + private loading = new BehaviorSubject(false); /** - * An observable to change the searching status + * The {@link PageInfo} object */ - private hideSearchingWhenUnsubscribed$ = new Observable(() => () => this.searching.next(false)); + private pageInfo: PageInfo; + /** + * An observable to change the loading status + */ + private hideSearchingWhenUnsubscribed$ = new Observable(() => () => this.loading.next(false)); /** * Initialize instance variables @@ -92,8 +96,10 @@ export class VocabularyTreeviewService { * @param initValueId The entry id of the node to mark as selected, if any */ initialize(options: VocabularyOptions, pageInfo: PageInfo, initValueId?: string): void { + this.loading.next(true); this.vocabularyOptions = options; this.vocabularyName = options.name; + this.pageInfo = pageInfo; if (isNotEmpty(initValueId)) { this.getNodeHierarchyById(initValueId) .subscribe((hierarchy: string[]) => { @@ -160,17 +166,17 @@ export class VocabularyTreeviewService { } /** - * Check if a search operation is pending + * Check if any operation is pending */ - isSearching(): Observable { - return this.searching; + isLoading(): Observable { + return this.loading; } /** * Perform a search operation by query */ searchByQuery(query: string) { - this.searching.next(true); + this.loading.next(true); if (isEmpty(this.storedNodes)) { this.storedNodes = this.dataChange.value; this.storedNodeMap = this.nodeMap; @@ -192,7 +198,7 @@ export class VocabularyTreeviewService { merge(this.hideSearchingWhenUnsubscribed$) ).subscribe((nodes: TreeviewNode[]) => { this.dataChange.next(nodes); - this.searching.next(false); + this.loading.next(false); }) } @@ -200,7 +206,7 @@ export class VocabularyTreeviewService { * Reset tree state with the one before the search */ restoreNodes() { - this.searching.next(false); + this.loading.next(false); this.dataChange.next(this.storedNodes); this.nodeMap = this.storedNodeMap; @@ -224,8 +230,8 @@ export class VocabularyTreeviewService { const entryDetail: VocabularyEntryDetail = Object.assign(new VocabularyEntryDetail(), entry, { id: entryId }); - const hasChildren = entry.hasOtherInformation() && isNotEmpty((entry.otherInformation as any).children); - const pageInfo: PageInfo = new PageInfo(); + const hasChildren = entry.hasOtherInformation() && (entry.otherInformation as any)!.hasChildren == 'true'; + const pageInfo: PageInfo = this.pageInfo; const isInInitValueHierarchy = this.initValueHierarchy.includes(entryId); const result = new TreeviewNode( entryDetail, @@ -295,8 +301,10 @@ export class VocabularyTreeviewService { this.vocabularyService.searchTopEntries(this.vocabularyName, pageInfo).pipe( getFirstSucceededRemoteDataPayload() ).subscribe((list: PaginatedList) => { - const newNodes: TreeviewNode[] = list.page.map((entry: VocabularyEntryDetail) => this._generateNode(entry)) + this.vocabularyService.clearSearchTopRequests(); + const newNodes: TreeviewNode[] = list.page.map((entry: VocabularyEntryDetail) => this._generateNode(entry)); nodes.push(...newNodes); + if ((list.pageInfo.currentPage + 1) <= list.pageInfo.totalPages) { // Need a new load more node const newPageInfo: PageInfo = Object.assign(new PageInfo(), list.pageInfo, { @@ -306,6 +314,7 @@ export class VocabularyTreeviewService { loadMoreNode.updatePageInfo(newPageInfo); nodes.push(loadMoreNode); } + this.loading.next(false); // Notify the change. this.dataChange.next(nodes); }); @@ -328,8 +337,7 @@ export class VocabularyTreeviewService { if (isNotEmpty(children)) { const newChildren = children .filter((entry: TreeviewNode) => { - const ii = findIndex(node.children, (nodeEntry) => nodeEntry.item.id === entry.item.id); - return ii === -1; + return findIndex(node.children, (nodeEntry) => nodeEntry.item.id === entry.item.id) === -1; }); newChildren.forEach((entry: TreeviewNode) => { entry.loadMoreParentItem = node.item diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 3a79984ec3..26c4829e4c 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2712,6 +2712,24 @@ "title": "DSpace", + + + "vocabulary-treeview.header": "Hierarchical tree view", + + "vocabulary-treeview.load-more": "Load more", + + "vocabulary-treeview.search.form.reset": "Reset", + + "vocabulary-treeview.search.form.search": "Search", + + "vocabulary-treeview.search.no-result": "There were no items to show", + + "vocabulary-treeview.tree.description.nsi": "The Norwegian Science Index", + + "vocabulary-treeview.tree.description.srsc": "Research Subject Categories", + + + "administrativeView.search.results.head": "Administrative Search", "menu.section.admin_search": "Admin Search", From e8237f196f796e365eacb9a6b3945ca0cbcc3a14 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 2 Jul 2020 11:42:30 +0200 Subject: [PATCH 015/126] [CST-3088] added test for hierarchical vocabulary --- .../models/dynamic-vocabulary.component.ts | 2 +- .../dynamic-typeahead.component.spec.ts | 112 ++++++++++++++++-- .../typeahead/dynamic-typeahead.component.ts | 4 + 3 files changed, 106 insertions(+), 12 deletions(-) diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts index 14ae2128b5..5e6acf8581 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts @@ -64,7 +64,7 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom value: initEntry.value, authority: initEntry.authority, display: initEntry.display, - otherInformation: initEntry.otherInformation + otherInformation: initEntry.otherInformation || null }); } else { return this.model.value as any; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts index 72fc7fcba0..d837af5402 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts @@ -5,7 +5,7 @@ import { async, ComponentFixture, fakeAsync, inject, TestBed, tick, } from '@ang import { By } from '@angular/platform-browser'; import { of as observableOf } from 'rxjs'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { DynamicFormLayoutService, DynamicFormsCoreModule, DynamicFormValidationService } from '@ng-dynamic-forms/core'; import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; @@ -21,6 +21,10 @@ import { AuthorityConfidenceStateDirective } from '../../../../../authority-conf import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { createSuccessfulRemoteDataObject$ } from '../../../../../remote-data.utils'; +import { VocabularyTreeviewComponent } from '../../../../../vocabulary-treeview/vocabulary-treeview.component'; +import { CdkTreeModule } from '@angular/cdk/tree'; +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'jasmine-marbles'; export let TYPEAHEAD_TEST_GROUP; @@ -51,14 +55,16 @@ function init() { }; } -fdescribe('DsDynamicTypeaheadComponent test suite', () => { +describe('DsDynamicTypeaheadComponent test suite', () => { + let scheduler: TestScheduler; let testComp: TestComponent; let typeaheadComp: DsDynamicTypeaheadComponent; let testFixture: ComponentFixture; let typeaheadFixture: ComponentFixture; - let service: any; + let vocabularyServiceStub: any; let html; + let modal; let vocabulary = { id: 'vocabulary', name: 'vocabulary', @@ -95,7 +101,15 @@ fdescribe('DsDynamicTypeaheadComponent test suite', () => { // async beforeEach beforeEach(async(() => { - const vocabularyServiceStub = new VocabularyServiceStub(); + vocabularyServiceStub = new VocabularyServiceStub(); + modal = jasmine.createSpyObj('modal', ['open', 'close', 'dismiss']); +/* jasmine.createSpyObj('modal', + { + open: jasmine.createSpy('open'), + close: jasmine.createSpy('close'), + dismiss: jasmine.createSpy('dismiss'), + } + );*/ init(); TestBed.configureTestingModule({ imports: [ @@ -104,20 +118,23 @@ fdescribe('DsDynamicTypeaheadComponent test suite', () => { FormsModule, NgbModule, ReactiveFormsModule, - TranslateModule.forRoot() + TranslateModule.forRoot(), + CdkTreeModule ], declarations: [ DsDynamicTypeaheadComponent, TestComponent, AuthorityConfidenceStateDirective, - ObjNgFor + ObjNgFor, + VocabularyTreeviewComponent ], // declare the test component providers: [ ChangeDetectorRef, DsDynamicTypeaheadComponent, { provide: VocabularyService, useValue: vocabularyServiceStub }, { provide: DynamicFormLayoutService, useValue: {} }, - { provide: DynamicFormValidationService, useValue: {} } + { provide: DynamicFormValidationService, useValue: {} }, + { provide: NgbModal, useValue: modal } ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); @@ -135,6 +152,7 @@ fdescribe('DsDynamicTypeaheadComponent test suite', () => { (change)="onValueChange($event)" (focus)="onFocus($event)">`; + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); testFixture = createTestComponent(html, TestComponent) as ComponentFixture; testComp = testFixture.componentInstance; }); @@ -143,12 +161,15 @@ fdescribe('DsDynamicTypeaheadComponent test suite', () => { testFixture.destroy(); }); it('should create DsDynamicTypeaheadComponent', inject([DsDynamicTypeaheadComponent], (app: DsDynamicTypeaheadComponent) => { - expect(app).toBeDefined(); })); }); - describe('not hiearchical', () => { + describe('Has not hierarchical vocabulary', () => { + beforeEach(() => { + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); + }); + describe('when init model value is empty', () => { beforeEach(() => { @@ -156,8 +177,6 @@ fdescribe('DsDynamicTypeaheadComponent test suite', () => { typeaheadComp = typeaheadFixture.componentInstance; // FormComponent test instance typeaheadComp.group = TYPEAHEAD_TEST_GROUP; typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); - service = (typeaheadComp as any).vocabularyService; - spyOn(service, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); typeaheadFixture.detectChanges(); }); @@ -341,6 +360,77 @@ fdescribe('DsDynamicTypeaheadComponent test suite', () => { }); }); }); + + describe('Has hierarchical vocabulary', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(hierarchicalVocabulary)); + }); + + describe('when init model value is empty', () => { + beforeEach(() => { + + typeaheadFixture = TestBed.createComponent(DsDynamicTypeaheadComponent); + typeaheadComp = typeaheadFixture.componentInstance; // FormComponent test instance + typeaheadComp.group = TYPEAHEAD_TEST_GROUP; + typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); + typeaheadFixture.detectChanges(); + }); + + afterEach(() => { + typeaheadFixture.destroy(); + typeaheadComp = null; + }); + + it('should init component properly', () => { + expect(typeaheadComp.currentValue).not.toBeDefined(); + }); + + it('should open tree properly', () => { + scheduler.schedule(() => typeaheadComp.openTree(new Event('click'))); + scheduler.flush(); + + expect((typeaheadComp as any).modalService.open).toHaveBeenCalled(); + }); + }); + + describe('when init model value is not empty', () => { + beforeEach(() => { + typeaheadFixture = TestBed.createComponent(DsDynamicTypeaheadComponent); + typeaheadComp = typeaheadFixture.componentInstance; // FormComponent test instance + typeaheadComp.group = TYPEAHEAD_TEST_GROUP; + typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); + const entry = observableOf(Object.assign(new VocabularyEntry(), { + authority: null, + value: 'test', + display: 'testDisplay' + })); + spyOn((typeaheadComp as any).vocabularyService, 'getVocabularyEntryByValue').and.returnValue(entry); + spyOn((typeaheadComp as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry); + (typeaheadComp.model as any).value = new FormFieldMetadataValueObject('test', null, null, 'testDisplay'); + typeaheadFixture.detectChanges(); + }); + + afterEach(() => { + typeaheadFixture.destroy(); + typeaheadComp = null; + }); + + it('should init component properly', fakeAsync(() => { + tick(); + expect(typeaheadComp.currentValue).toEqual(new FormFieldMetadataValueObject('test', null, null, 'testDisplay')); + expect((typeaheadComp as any).vocabularyService.getVocabularyEntryByValue).toHaveBeenCalled(); + })); + + it('should open tree properly', () => { + scheduler.schedule(() => typeaheadComp.openTree(new Event('click'))); + scheduler.flush(); + + expect((typeaheadComp as any).modalService.open).toHaveBeenCalled(); + }); + }); + + }); }); // declare a test component diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts index 93e5b228e7..5d7413e4dd 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts @@ -201,6 +201,10 @@ export class DsDynamicTypeaheadComponent extends DsDynamicVocabularyComponent im this.dispatchUpdate(event.item); } + /** + * Open modal to show tree for hierarchical vocabulary + * @param event The click event fired + */ openTree(event) { event.preventDefault(); event.stopImmediatePropagation(); From 30d53c5954832b2d1948f5bb1b0b420c71fb51ac Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 2 Jul 2020 12:12:56 +0200 Subject: [PATCH 016/126] [CST-3088] Renamed DsDynamicTypeaheadComponent to DsDynamicOneboxComponent --- ...c-form-control-container.component.spec.ts | 8 +- ...ynamic-form-control-container.component.ts | 25 +- .../models/ds-dynamic-input.model.ts | 2 +- .../dynamic-onebox.component.html} | 0 .../dynamic-onebox.component.scss} | 0 .../dynamic-onebox.component.spec.ts} | 270 +++++++++--------- .../dynamic-onebox.component.ts} | 14 +- .../dynamic-onebox.model.ts} | 10 +- .../form/builder/form-builder.service.spec.ts | 14 +- .../parsers/onebox-field-parser.spec.ts | 6 +- .../builder/parsers/onebox-field-parser.ts | 14 +- src/app/shared/shared.module.ts | 6 +- 12 files changed, 188 insertions(+), 181 deletions(-) rename src/app/shared/form/builder/ds-dynamic-form-ui/models/{typeahead/dynamic-typeahead.component.html => onebox/dynamic-onebox.component.html} (100%) rename src/app/shared/form/builder/ds-dynamic-form-ui/models/{typeahead/dynamic-typeahead.component.scss => onebox/dynamic-onebox.component.scss} (100%) rename src/app/shared/form/builder/ds-dynamic-form-ui/models/{typeahead/dynamic-typeahead.component.spec.ts => onebox/dynamic-onebox.component.spec.ts} (50%) rename src/app/shared/form/builder/ds-dynamic-form-ui/models/{typeahead/dynamic-typeahead.component.ts => onebox/dynamic-onebox.component.ts} (95%) rename src/app/shared/form/builder/ds-dynamic-form-ui/models/{typeahead/dynamic-typeahead.model.ts => onebox/dynamic-onebox.model.ts} (58%) diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts index 171ad69f64..e4e6ac6e90 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts @@ -49,10 +49,10 @@ import { DynamicListRadioGroupModel } from './models/list/dynamic-list-radio-gro import { DynamicLookupModel } from './models/lookup/dynamic-lookup.model'; import { DynamicScrollableDropdownModel } from './models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; import { DynamicTagModel } from './models/tag/dynamic-tag.model'; -import { DynamicTypeaheadModel } from './models/typeahead/dynamic-typeahead.model'; +import { DynamicOneboxModel } from './models/onebox/dynamic-onebox.model'; import { DynamicQualdropModel } from './models/ds-dynamic-qualdrop.model'; import { DynamicLookupNameModel } from './models/lookup/dynamic-lookup-name.model'; -import { DsDynamicTypeaheadComponent } from './models/typeahead/dynamic-typeahead.component'; +import { DsDynamicOneboxComponent } from './models/onebox/dynamic-onebox.component'; import { DsDynamicScrollableDropdownComponent } from './models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; import { DsDynamicTagComponent } from './models/tag/dynamic-tag.component'; import { DsDynamicListComponent } from './models/list/dynamic-list.component'; @@ -101,7 +101,7 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { new DynamicSwitchModel({ id: 'switch' }), new DynamicTextAreaModel({ id: 'textarea' }), new DynamicTimePickerModel({ id: 'timepicker' }), - new DynamicTypeaheadModel({ id: 'typeahead', metadataFields: [], repeatable: false, submissionId: '1234' }), + new DynamicOneboxModel({ id: 'onebox', metadataFields: [], repeatable: false, submissionId: '1234' }), new DynamicScrollableDropdownModel({ id: 'scrollableDropdown', vocabularyOptions: vocabularyOptions, @@ -312,7 +312,7 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { expect(testFn(formModel[13])).toBeNull(); expect(testFn(formModel[14])).toEqual(DynamicNGBootstrapTextAreaComponent); expect(testFn(formModel[15])).toEqual(DynamicNGBootstrapTimePickerComponent); - expect(testFn(formModel[16])).toEqual(DsDynamicTypeaheadComponent); + expect(testFn(formModel[16])).toEqual(DsDynamicOneboxComponent); expect(testFn(formModel[17])).toEqual(DsDynamicScrollableDropdownComponent); expect(testFn(formModel[18])).toEqual(DsDynamicTagComponent); expect(testFn(formModel[19])).toEqual(DsDynamicListComponent); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index 2089ce8bca..3eaca6a67a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -1,5 +1,6 @@ import { - ChangeDetectionStrategy, ChangeDetectorRef, + ChangeDetectionStrategy, + ChangeDetectorRef, Component, ComponentFactoryResolver, ContentChildren, @@ -29,13 +30,15 @@ import { DYNAMIC_FORM_CONTROL_TYPE_SELECT, DYNAMIC_FORM_CONTROL_TYPE_TEXTAREA, DYNAMIC_FORM_CONTROL_TYPE_TIMEPICKER, - DynamicDatePickerModel, DynamicFormComponentService, + DynamicDatePickerModel, + DynamicFormComponentService, DynamicFormControl, DynamicFormControlContainerComponent, DynamicFormControlEvent, DynamicFormControlModel, DynamicFormLayout, - DynamicFormLayoutService, DynamicFormRelationService, + DynamicFormLayoutService, + DynamicFormRelationService, DynamicFormValidationService, DynamicTemplateDirective, } from '@ng-dynamic-forms/core'; @@ -56,7 +59,7 @@ import { ReorderableRelationship } from './existing-metadata-list-element/existing-metadata-list-element.component'; -import { DYNAMIC_FORM_CONTROL_TYPE_TYPEAHEAD } from './models/typeahead/dynamic-typeahead.model'; +import { DYNAMIC_FORM_CONTROL_TYPE_ONEBOX } from './models/onebox/dynamic-onebox.model'; import { DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN } from './models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; import { DYNAMIC_FORM_CONTROL_TYPE_TAG } from './models/tag/dynamic-tag.model'; import { DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER } from './models/date-picker/date-picker.model'; @@ -68,7 +71,7 @@ import { DYNAMIC_FORM_CONTROL_TYPE_LOOKUP_NAME } from './models/lookup/dynamic-l import { DsDynamicTagComponent } from './models/tag/dynamic-tag.component'; import { DsDatePickerComponent } from './models/date-picker/date-picker.component'; import { DsDynamicListComponent } from './models/list/dynamic-list.component'; -import { DsDynamicTypeaheadComponent } from './models/typeahead/dynamic-typeahead.component'; +import { DsDynamicOneboxComponent } from './models/onebox/dynamic-onebox.component'; import { DsDynamicScrollableDropdownComponent } from './models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; import { DsDynamicLookupComponent } from './models/lookup/dynamic-lookup.component'; import { DsDynamicFormGroupComponent } from './models/form-group/dynamic-form-group.component'; @@ -78,7 +81,7 @@ import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from './models/relation-grou import { DsDatePickerInlineComponent } from './models/date-picker-inline/dynamic-date-picker-inline.component'; import { DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH } from './models/custom-switch/custom-switch.model'; import { CustomSwitchComponent } from './models/custom-switch/custom-switch.component'; -import { map, startWith, switchMap, find } from 'rxjs/operators'; +import { map, startWith, switchMap } from 'rxjs/operators'; import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; import { SearchResult } from '../../../search/search-result.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; @@ -88,7 +91,11 @@ import { SelectableListService } from '../../../object-list/selectable-list/sele import { DsDynamicDisabledComponent } from './models/disabled/dynamic-disabled.component'; import { DYNAMIC_FORM_CONTROL_TYPE_DISABLED } from './models/disabled/dynamic-disabled.model'; import { DsDynamicLookupRelationModalComponent } from './relation-lookup-modal/dynamic-lookup-relation-modal.component'; -import { getAllSucceededRemoteData, getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; +import { + getAllSucceededRemoteData, + getRemoteDataPayload, + getSucceededRemoteData +} from '../../../../core/shared/operators'; import { RemoteData } from '../../../../core/data/remote-data'; import { Item } from '../../../../core/shared/item.model'; import { ItemDataService } from '../../../../core/data/item-data.service'; @@ -136,8 +143,8 @@ export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type< case DYNAMIC_FORM_CONTROL_TYPE_TIMEPICKER: return DynamicNGBootstrapTimePickerComponent; - case DYNAMIC_FORM_CONTROL_TYPE_TYPEAHEAD: - return DsDynamicTypeaheadComponent; + case DYNAMIC_FORM_CONTROL_TYPE_ONEBOX: + return DsDynamicOneboxComponent; case DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN: return DsDynamicScrollableDropdownComponent; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts index b723f7aa40..90c26b69cf 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts @@ -41,7 +41,7 @@ export class DsDynamicInputModel extends DynamicInputModel { this.language = config.language; if (!this.language) { - // TypeAhead + // Onebox if (config.value instanceof FormFieldMetadataValueObject) { this.language = config.value.language; } else if (Array.isArray(config.value)) { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html similarity index 100% rename from src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.html rename to src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss similarity index 100% rename from src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.scss rename to src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts similarity index 50% rename from src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts rename to src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts index d837af5402..3982f659af 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts @@ -13,8 +13,8 @@ import { TranslateModule } from '@ngx-translate/core'; import { VocabularyOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-options.model'; import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; import { VocabularyServiceStub } from '../../../../../testing/vocabulary-service.stub'; -import { DsDynamicTypeaheadComponent } from './dynamic-typeahead.component'; -import { DynamicTypeaheadModel } from './dynamic-typeahead.model'; +import { DsDynamicOneboxComponent } from './dynamic-onebox.component'; +import { DynamicOneboxModel } from './dynamic-onebox.model'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; import { createTestComponent } from '../../../../../testing/utils.test'; import { AuthorityConfidenceStateDirective } from '../../../../../authority-confidence/authority-confidence-state.directive'; @@ -26,27 +26,27 @@ import { CdkTreeModule } from '@angular/cdk/tree'; import { TestScheduler } from 'rxjs/testing'; import { getTestScheduler } from 'jasmine-marbles'; -export let TYPEAHEAD_TEST_GROUP; +export let ONEBOX_TEST_GROUP; -export let TYPEAHEAD_TEST_MODEL_CONFIG; +export let ONEBOX_TEST_MODEL_CONFIG; function init() { - TYPEAHEAD_TEST_GROUP = new FormGroup({ - typeahead: new FormControl(), + ONEBOX_TEST_GROUP = new FormGroup({ + onebox: new FormControl(), }); - TYPEAHEAD_TEST_MODEL_CONFIG = { + ONEBOX_TEST_MODEL_CONFIG = { vocabularyOptions: { closed: false, - metadata: 'typeahead', + metadata: 'onebox', name: 'vocabulary', scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' } as VocabularyOptions, disabled: false, - id: 'typeahead', + id: 'onebox', label: 'Conference', minChars: 3, - name: 'typeahead', + name: 'onebox', placeholder: 'Conference', readOnly: false, required: false, @@ -55,13 +55,13 @@ function init() { }; } -describe('DsDynamicTypeaheadComponent test suite', () => { +describe('DsDynamicOneboxComponent test suite', () => { let scheduler: TestScheduler; let testComp: TestComponent; - let typeaheadComp: DsDynamicTypeaheadComponent; + let oneboxComponent: DsDynamicOneboxComponent; let testFixture: ComponentFixture; - let typeaheadFixture: ComponentFixture; + let oneboxCompFixture: ComponentFixture; let vocabularyServiceStub: any; let html; let modal; @@ -122,7 +122,7 @@ describe('DsDynamicTypeaheadComponent test suite', () => { CdkTreeModule ], declarations: [ - DsDynamicTypeaheadComponent, + DsDynamicOneboxComponent, TestComponent, AuthorityConfidenceStateDirective, ObjNgFor, @@ -130,7 +130,7 @@ describe('DsDynamicTypeaheadComponent test suite', () => { ], // declare the test component providers: [ ChangeDetectorRef, - DsDynamicTypeaheadComponent, + DsDynamicOneboxComponent, { provide: VocabularyService, useValue: vocabularyServiceStub }, { provide: DynamicFormLayoutService, useValue: {} }, { provide: DynamicFormValidationService, useValue: {} }, @@ -145,12 +145,12 @@ describe('DsDynamicTypeaheadComponent test suite', () => { // synchronous beforeEach beforeEach(() => { html = ` - `; + (focus)="onFocus($event)">`; spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); testFixture = createTestComponent(html, TestComponent) as ComponentFixture; @@ -160,7 +160,7 @@ describe('DsDynamicTypeaheadComponent test suite', () => { afterEach(() => { testFixture.destroy(); }); - it('should create DsDynamicTypeaheadComponent', inject([DsDynamicTypeaheadComponent], (app: DsDynamicTypeaheadComponent) => { + it('should create DsDynamicOneboxComponent', inject([DsDynamicOneboxComponent], (app: DsDynamicOneboxComponent) => { expect(app).toBeDefined(); })); }); @@ -173,190 +173,190 @@ describe('DsDynamicTypeaheadComponent test suite', () => { describe('when init model value is empty', () => { beforeEach(() => { - typeaheadFixture = TestBed.createComponent(DsDynamicTypeaheadComponent); - typeaheadComp = typeaheadFixture.componentInstance; // FormComponent test instance - typeaheadComp.group = TYPEAHEAD_TEST_GROUP; - typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); - typeaheadFixture.detectChanges(); + oneboxCompFixture = TestBed.createComponent(DsDynamicOneboxComponent); + oneboxComponent = oneboxCompFixture.componentInstance; // FormComponent test instance + oneboxComponent.group = ONEBOX_TEST_GROUP; + oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); + oneboxCompFixture.detectChanges(); }); afterEach(() => { - typeaheadFixture.destroy(); - typeaheadComp = null; + oneboxCompFixture.destroy(); + oneboxComponent = null; }); it('should init component properly', () => { - expect(typeaheadComp.currentValue).not.toBeDefined(); + expect(oneboxComponent.currentValue).not.toBeDefined(); }); it('should search when 3+ characters typed', fakeAsync(() => { - spyOn((typeaheadComp as any).vocabularyService, 'getVocabularyEntriesByValue').and.callThrough(); + spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntriesByValue').and.callThrough(); - typeaheadComp.search(observableOf('test')).subscribe(); + oneboxComponent.search(observableOf('test')).subscribe(); tick(300); - typeaheadFixture.detectChanges(); + oneboxCompFixture.detectChanges(); - expect((typeaheadComp as any).vocabularyService.getVocabularyEntriesByValue).toHaveBeenCalled(); + expect((oneboxComponent as any).vocabularyService.getVocabularyEntriesByValue).toHaveBeenCalled(); })); it('should set model.value on input type when VocabularyOptions.closed is false', () => { - const inputDe = typeaheadFixture.debugElement.query(By.css('input.form-control')); + const inputDe = oneboxCompFixture.debugElement.query(By.css('input.form-control')); const inputElement = inputDe.nativeElement; inputElement.value = 'test value'; inputElement.dispatchEvent(new Event('input')); - expect(typeaheadComp.inputValue).toEqual(new FormFieldMetadataValueObject('test value')) + expect(oneboxComponent.inputValue).toEqual(new FormFieldMetadataValueObject('test value')) }); it('should not set model.value on input type when VocabularyOptions.closed is true', () => { - typeaheadComp.model.vocabularyOptions.closed = true; - typeaheadFixture.detectChanges(); - const inputDe = typeaheadFixture.debugElement.query(By.css('input.form-control')); + oneboxComponent.model.vocabularyOptions.closed = true; + oneboxCompFixture.detectChanges(); + const inputDe = oneboxCompFixture.debugElement.query(By.css('input.form-control')); const inputElement = inputDe.nativeElement; inputElement.value = 'test value'; inputElement.dispatchEvent(new Event('input')); - expect(typeaheadComp.model.value).not.toBeDefined(); + expect(oneboxComponent.model.value).not.toBeDefined(); }); it('should emit blur Event onBlur when popup is closed', () => { - spyOn(typeaheadComp.blur, 'emit'); - spyOn(typeaheadComp.instance, 'isPopupOpen').and.returnValue(false); - typeaheadComp.onBlur(new Event('blur')); - expect(typeaheadComp.blur.emit).toHaveBeenCalled(); + spyOn(oneboxComponent.blur, 'emit'); + spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); + oneboxComponent.onBlur(new Event('blur')); + expect(oneboxComponent.blur.emit).toHaveBeenCalled(); }); it('should not emit blur Event onBlur when popup is opened', () => { - spyOn(typeaheadComp.blur, 'emit'); - spyOn(typeaheadComp.instance, 'isPopupOpen').and.returnValue(true); - const input = typeaheadFixture.debugElement.query(By.css('input')); + spyOn(oneboxComponent.blur, 'emit'); + spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(true); + const input = oneboxCompFixture.debugElement.query(By.css('input')); input.nativeElement.blur(); - expect(typeaheadComp.blur.emit).not.toHaveBeenCalled(); + expect(oneboxComponent.blur.emit).not.toHaveBeenCalled(); }); it('should emit change Event onBlur when VocabularyOptions.closed is false and inputValue is changed', () => { - typeaheadComp.inputValue = 'test value'; - typeaheadFixture.detectChanges(); - spyOn(typeaheadComp.blur, 'emit'); - spyOn(typeaheadComp.change, 'emit'); - spyOn(typeaheadComp.instance, 'isPopupOpen').and.returnValue(false); - typeaheadComp.onBlur(new Event('blur',)); - expect(typeaheadComp.change.emit).toHaveBeenCalled(); - expect(typeaheadComp.blur.emit).toHaveBeenCalled(); + oneboxComponent.inputValue = 'test value'; + oneboxCompFixture.detectChanges(); + spyOn(oneboxComponent.blur, 'emit'); + spyOn(oneboxComponent.change, 'emit'); + spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); + oneboxComponent.onBlur(new Event('blur',)); + expect(oneboxComponent.change.emit).toHaveBeenCalled(); + expect(oneboxComponent.blur.emit).toHaveBeenCalled(); }); it('should not emit change Event onBlur when VocabularyOptions.closed is false and inputValue is not changed', () => { - typeaheadComp.inputValue = 'test value'; - typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); - (typeaheadComp.model as any).value = 'test value'; - typeaheadFixture.detectChanges(); - spyOn(typeaheadComp.blur, 'emit'); - spyOn(typeaheadComp.change, 'emit'); - spyOn(typeaheadComp.instance, 'isPopupOpen').and.returnValue(false); - typeaheadComp.onBlur(new Event('blur',)); - expect(typeaheadComp.change.emit).not.toHaveBeenCalled(); - expect(typeaheadComp.blur.emit).toHaveBeenCalled(); + oneboxComponent.inputValue = 'test value'; + oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); + (oneboxComponent.model as any).value = 'test value'; + oneboxCompFixture.detectChanges(); + spyOn(oneboxComponent.blur, 'emit'); + spyOn(oneboxComponent.change, 'emit'); + spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); + oneboxComponent.onBlur(new Event('blur',)); + expect(oneboxComponent.change.emit).not.toHaveBeenCalled(); + expect(oneboxComponent.blur.emit).toHaveBeenCalled(); }); it('should not emit change Event onBlur when VocabularyOptions.closed is false and inputValue is null', () => { - typeaheadComp.inputValue = null; - typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); - (typeaheadComp.model as any).value = 'test value'; - typeaheadFixture.detectChanges(); - spyOn(typeaheadComp.blur, 'emit'); - spyOn(typeaheadComp.change, 'emit'); - spyOn(typeaheadComp.instance, 'isPopupOpen').and.returnValue(false); - typeaheadComp.onBlur(new Event('blur',)); - expect(typeaheadComp.change.emit).not.toHaveBeenCalled(); - expect(typeaheadComp.blur.emit).toHaveBeenCalled(); + oneboxComponent.inputValue = null; + oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); + (oneboxComponent.model as any).value = 'test value'; + oneboxCompFixture.detectChanges(); + spyOn(oneboxComponent.blur, 'emit'); + spyOn(oneboxComponent.change, 'emit'); + spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); + oneboxComponent.onBlur(new Event('blur',)); + expect(oneboxComponent.change.emit).not.toHaveBeenCalled(); + expect(oneboxComponent.blur.emit).toHaveBeenCalled(); }); it('should emit focus Event onFocus', () => { - spyOn(typeaheadComp.focus, 'emit'); - typeaheadComp.onFocus(new Event('focus')); - expect(typeaheadComp.focus.emit).toHaveBeenCalled(); + spyOn(oneboxComponent.focus, 'emit'); + oneboxComponent.onFocus(new Event('focus')); + expect(oneboxComponent.focus.emit).toHaveBeenCalled(); }); }); describe('when init model value is not empty', () => { beforeEach(() => { - typeaheadFixture = TestBed.createComponent(DsDynamicTypeaheadComponent); - typeaheadComp = typeaheadFixture.componentInstance; // FormComponent test instance - typeaheadComp.group = TYPEAHEAD_TEST_GROUP; - typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); + oneboxCompFixture = TestBed.createComponent(DsDynamicOneboxComponent); + oneboxComponent = oneboxCompFixture.componentInstance; // FormComponent test instance + oneboxComponent.group = ONEBOX_TEST_GROUP; + oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); const entry = observableOf(Object.assign(new VocabularyEntry(), { authority: null, value: 'test', display: 'testDisplay' })); - spyOn((typeaheadComp as any).vocabularyService, 'getVocabularyEntryByValue').and.returnValue(entry); - spyOn((typeaheadComp as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry); - (typeaheadComp.model as any).value = new FormFieldMetadataValueObject('test', null, null, 'testDisplay'); - typeaheadFixture.detectChanges(); + spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByValue').and.returnValue(entry); + spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry); + (oneboxComponent.model as any).value = new FormFieldMetadataValueObject('test', null, null, 'testDisplay'); + oneboxCompFixture.detectChanges(); }); afterEach(() => { - typeaheadFixture.destroy(); - typeaheadComp = null; + oneboxCompFixture.destroy(); + oneboxComponent = null; }); it('should init component properly', fakeAsync(() => { tick(); - expect(typeaheadComp.currentValue).toEqual(new FormFieldMetadataValueObject('test', null, null, 'testDisplay')); - expect((typeaheadComp as any).vocabularyService.getVocabularyEntryByValue).toHaveBeenCalled(); + expect(oneboxComponent.currentValue).toEqual(new FormFieldMetadataValueObject('test', null, null, 'testDisplay')); + expect((oneboxComponent as any).vocabularyService.getVocabularyEntryByValue).toHaveBeenCalled(); })); it('should emit change Event onChange and currentValue is empty', () => { - typeaheadComp.currentValue = null; - spyOn(typeaheadComp.change, 'emit'); - typeaheadComp.onChange(new Event('change')); - expect(typeaheadComp.change.emit).toHaveBeenCalled(); - expect(typeaheadComp.model.value).toBeNull(); + oneboxComponent.currentValue = null; + spyOn(oneboxComponent.change, 'emit'); + oneboxComponent.onChange(new Event('change')); + expect(oneboxComponent.change.emit).toHaveBeenCalled(); + expect(oneboxComponent.model.value).toBeNull(); }); }); describe('when init model value is not empty and has authority', () => { beforeEach(() => { - typeaheadFixture = TestBed.createComponent(DsDynamicTypeaheadComponent); - typeaheadComp = typeaheadFixture.componentInstance; // FormComponent test instance - typeaheadComp.group = TYPEAHEAD_TEST_GROUP; - typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); + oneboxCompFixture = TestBed.createComponent(DsDynamicOneboxComponent); + oneboxComponent = oneboxCompFixture.componentInstance; // FormComponent test instance + oneboxComponent.group = ONEBOX_TEST_GROUP; + oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); const entry = observableOf(Object.assign(new VocabularyEntry(), { authority: 'test001', value: 'test001', display: 'test' })); - spyOn((typeaheadComp as any).vocabularyService, 'getVocabularyEntryByValue').and.returnValue(entry); - spyOn((typeaheadComp as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry); - (typeaheadComp.model as any).value = new FormFieldMetadataValueObject('test', null, 'test001'); - typeaheadFixture.detectChanges(); + spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByValue').and.returnValue(entry); + spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry); + (oneboxComponent.model as any).value = new FormFieldMetadataValueObject('test', null, 'test001'); + oneboxCompFixture.detectChanges(); }); afterEach(() => { - typeaheadFixture.destroy(); - typeaheadComp = null; + oneboxCompFixture.destroy(); + oneboxComponent = null; }); it('should init component properly', fakeAsync(() => { tick(); - expect(typeaheadComp.currentValue).toEqual(new FormFieldMetadataValueObject('test001', null, 'test001', 'test')); - expect((typeaheadComp as any).vocabularyService.getVocabularyEntryByID).toHaveBeenCalled(); + expect(oneboxComponent.currentValue).toEqual(new FormFieldMetadataValueObject('test001', null, 'test001', 'test')); + expect((oneboxComponent as any).vocabularyService.getVocabularyEntryByID).toHaveBeenCalled(); })); it('should emit change Event onChange and currentValue is empty', () => { - typeaheadComp.currentValue = null; - spyOn(typeaheadComp.change, 'emit'); - typeaheadComp.onChange(new Event('change')); - expect(typeaheadComp.change.emit).toHaveBeenCalled(); - expect(typeaheadComp.model.value).toBeNull(); + oneboxComponent.currentValue = null; + spyOn(oneboxComponent.change, 'emit'); + oneboxComponent.onChange(new Event('change')); + expect(oneboxComponent.change.emit).toHaveBeenCalled(); + expect(oneboxComponent.model.value).toBeNull(); }); }); }); @@ -370,63 +370,63 @@ describe('DsDynamicTypeaheadComponent test suite', () => { describe('when init model value is empty', () => { beforeEach(() => { - typeaheadFixture = TestBed.createComponent(DsDynamicTypeaheadComponent); - typeaheadComp = typeaheadFixture.componentInstance; // FormComponent test instance - typeaheadComp.group = TYPEAHEAD_TEST_GROUP; - typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); - typeaheadFixture.detectChanges(); + oneboxCompFixture = TestBed.createComponent(DsDynamicOneboxComponent); + oneboxComponent = oneboxCompFixture.componentInstance; // FormComponent test instance + oneboxComponent.group = ONEBOX_TEST_GROUP; + oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); + oneboxCompFixture.detectChanges(); }); afterEach(() => { - typeaheadFixture.destroy(); - typeaheadComp = null; + oneboxCompFixture.destroy(); + oneboxComponent = null; }); it('should init component properly', () => { - expect(typeaheadComp.currentValue).not.toBeDefined(); + expect(oneboxComponent.currentValue).not.toBeDefined(); }); it('should open tree properly', () => { - scheduler.schedule(() => typeaheadComp.openTree(new Event('click'))); + scheduler.schedule(() => oneboxComponent.openTree(new Event('click'))); scheduler.flush(); - expect((typeaheadComp as any).modalService.open).toHaveBeenCalled(); + expect((oneboxComponent as any).modalService.open).toHaveBeenCalled(); }); }); describe('when init model value is not empty', () => { beforeEach(() => { - typeaheadFixture = TestBed.createComponent(DsDynamicTypeaheadComponent); - typeaheadComp = typeaheadFixture.componentInstance; // FormComponent test instance - typeaheadComp.group = TYPEAHEAD_TEST_GROUP; - typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); + oneboxCompFixture = TestBed.createComponent(DsDynamicOneboxComponent); + oneboxComponent = oneboxCompFixture.componentInstance; // FormComponent test instance + oneboxComponent.group = ONEBOX_TEST_GROUP; + oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); const entry = observableOf(Object.assign(new VocabularyEntry(), { authority: null, value: 'test', display: 'testDisplay' })); - spyOn((typeaheadComp as any).vocabularyService, 'getVocabularyEntryByValue').and.returnValue(entry); - spyOn((typeaheadComp as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry); - (typeaheadComp.model as any).value = new FormFieldMetadataValueObject('test', null, null, 'testDisplay'); - typeaheadFixture.detectChanges(); + spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByValue').and.returnValue(entry); + spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry); + (oneboxComponent.model as any).value = new FormFieldMetadataValueObject('test', null, null, 'testDisplay'); + oneboxCompFixture.detectChanges(); }); afterEach(() => { - typeaheadFixture.destroy(); - typeaheadComp = null; + oneboxCompFixture.destroy(); + oneboxComponent = null; }); it('should init component properly', fakeAsync(() => { tick(); - expect(typeaheadComp.currentValue).toEqual(new FormFieldMetadataValueObject('test', null, null, 'testDisplay')); - expect((typeaheadComp as any).vocabularyService.getVocabularyEntryByValue).toHaveBeenCalled(); + expect(oneboxComponent.currentValue).toEqual(new FormFieldMetadataValueObject('test', null, null, 'testDisplay')); + expect((oneboxComponent as any).vocabularyService.getVocabularyEntryByValue).toHaveBeenCalled(); })); it('should open tree properly', () => { - scheduler.schedule(() => typeaheadComp.openTree(new Event('click'))); + scheduler.schedule(() => oneboxComponent.openTree(new Event('click'))); scheduler.flush(); - expect((typeaheadComp as any).modalService.open).toHaveBeenCalled(); + expect((oneboxComponent as any).modalService.open).toHaveBeenCalled(); }); }); @@ -440,8 +440,8 @@ describe('DsDynamicTypeaheadComponent test suite', () => { }) class TestComponent { - group: FormGroup = TYPEAHEAD_TEST_GROUP; + group: FormGroup = ONEBOX_TEST_GROUP; - model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); + model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts similarity index 95% rename from src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts rename to src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts index 5d7413e4dd..fa9fe2103a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts @@ -17,7 +17,7 @@ import { Observable, of as observableOf, Subject, Subscription } from 'rxjs'; import { NgbModal, NgbModalRef, NgbTypeahead, NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; -import { DynamicTypeaheadModel } from './dynamic-typeahead.model'; +import { DynamicOneboxModel } from './dynamic-onebox.model'; import { hasValue, isEmpty, isNotEmpty, isNotNull } from '../../../../../empty.util'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; import { ConfidenceType } from '../../../../../../core/shared/confidence-type'; @@ -31,14 +31,14 @@ import { VocabularyTreeviewComponent } from '../../../../../vocabulary-treeview/ import { VocabularyEntryDetail } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; @Component({ - selector: 'ds-dynamic-typeahead', - styleUrls: ['./dynamic-typeahead.component.scss'], - templateUrl: './dynamic-typeahead.component.html' + selector: 'ds-dynamic-onebox', + styleUrls: ['./dynamic-onebox.component.scss'], + templateUrl: './dynamic-onebox.component.html' }) -export class DsDynamicTypeaheadComponent extends DsDynamicVocabularyComponent implements OnInit { +export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent implements OnInit { @Input() bindId = true; @Input() group: FormGroup; - @Input() model: DynamicTypeaheadModel; + @Input() model: DynamicOneboxModel; @Output() blur: EventEmitter = new EventEmitter(); @Output() change: EventEmitter = new EventEmitter(); @@ -76,7 +76,7 @@ export class DsDynamicTypeaheadComponent extends DsDynamicVocabularyComponent im /** * Converts a stream of text values from the `` element to the stream of the array of items - * to display in the typeahead popup. + * to display in the onebox popup. */ search = (text$: Observable) => { return text$.pipe( diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model.ts similarity index 58% rename from src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.model.ts rename to src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model.ts index 866055ed04..4b973e3058 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model.ts @@ -1,19 +1,19 @@ import { AUTOCOMPLETE_OFF, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; import { DsDynamicInputModel, DsDynamicInputModelConfig } from '../ds-dynamic-input.model'; -export const DYNAMIC_FORM_CONTROL_TYPE_TYPEAHEAD = 'TYPEAHEAD'; +export const DYNAMIC_FORM_CONTROL_TYPE_ONEBOX = 'ONEBOX'; -export interface DsDynamicTypeaheadModelConfig extends DsDynamicInputModelConfig { +export interface DsDynamicOneboxModelConfig extends DsDynamicInputModelConfig { minChars?: number; value?: any; } -export class DynamicTypeaheadModel extends DsDynamicInputModel { +export class DynamicOneboxModel extends DsDynamicInputModel { @serializable() minChars: number; - @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_TYPEAHEAD; + @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_ONEBOX; - constructor(config: DsDynamicTypeaheadModelConfig, layout?: DynamicFormControlLayout) { + constructor(config: DsDynamicOneboxModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); diff --git a/src/app/shared/form/builder/form-builder.service.spec.ts b/src/app/shared/form/builder/form-builder.service.spec.ts index 434fe6a2e1..ae3aa782b9 100644 --- a/src/app/shared/form/builder/form-builder.service.spec.ts +++ b/src/app/shared/form/builder/form-builder.service.spec.ts @@ -34,7 +34,7 @@ import { DynamicScrollableDropdownModel } from './ds-dynamic-form-ui/models/scro import { DynamicRelationGroupModel } from './ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; import { DynamicLookupModel } from './ds-dynamic-form-ui/models/lookup/dynamic-lookup.model'; import { DynamicDsDatePickerModel } from './ds-dynamic-form-ui/models/date-picker/date-picker.model'; -import { DynamicTypeaheadModel } from './ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.model'; +import { DynamicOneboxModel } from './ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; import { DynamicListRadioGroupModel } from './ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model'; import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model'; import { FormFieldModel } from './models/form-field.model'; @@ -195,7 +195,7 @@ describe('FormBuilderService test suite', () => { new DynamicColorPickerModel({id: 'testColorPicker'}), - new DynamicTypeaheadModel({id: 'testTypeahead', repeatable: false, metadataFields: [], submissionId: '1234'}), + new DynamicOneboxModel({id: 'testOnebox', repeatable: false, metadataFields: [], submissionId: '1234'}), new DynamicScrollableDropdownModel({id: 'testScrollableDropdown', vocabularyOptions: vocabularyOptions, repeatable: false, metadataFields: [], submissionId: '1234'}), @@ -433,7 +433,7 @@ describe('FormBuilderService test suite', () => { expect(formModel[2] instanceof DynamicRowGroupModel).toBe(true); expect((formModel[2] as DynamicRowGroupModel).group.length).toBe(1); - expect((formModel[2] as DynamicRowGroupModel).get(0) instanceof DynamicTypeaheadModel).toBe(true); + expect((formModel[2] as DynamicRowGroupModel).get(0) instanceof DynamicOneboxModel).toBe(true); }); it('should return form\'s fields value from form model', () => { @@ -449,7 +449,7 @@ describe('FormBuilderService test suite', () => { }; expect(service.getValueFromModel(formModel)).toEqual(value); - ((formModel[2] as DynamicRowGroupModel).get(0) as DynamicTypeaheadModel).valueUpdates.next('test one'); + ((formModel[2] as DynamicRowGroupModel).get(0) as DynamicOneboxModel).valueUpdates.next('test one'); value = { issue: [new FormFieldMetadataValueObject('test')], conference: [new FormFieldMetadataValueObject('test one')] @@ -462,11 +462,11 @@ describe('FormBuilderService test suite', () => { const value = {} as any; ((formModel[0] as DynamicRowGroupModel).get(1) as DsDynamicInputModel).valueUpdates.next('test'); - ((formModel[2] as DynamicRowGroupModel).get(0) as DynamicTypeaheadModel).valueUpdates.next('test one'); + ((formModel[2] as DynamicRowGroupModel).get(0) as DynamicOneboxModel).valueUpdates.next('test one'); service.clearAllModelsValue(formModel); - expect(((formModel[0] as DynamicRowGroupModel).get(1) as DynamicTypeaheadModel).value).toEqual(undefined) - expect(((formModel[2] as DynamicRowGroupModel).get(0) as DynamicTypeaheadModel).value).toEqual(undefined) + expect(((formModel[0] as DynamicRowGroupModel).get(1) as DynamicOneboxModel).value).toEqual(undefined) + expect(((formModel[2] as DynamicRowGroupModel).get(0) as DynamicOneboxModel).value).toEqual(undefined) }); it('should return true when model has a custom group model as parent', () => { diff --git a/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts b/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts index c2d1b6f565..e6a99d8e74 100644 --- a/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts @@ -1,7 +1,7 @@ import { FormFieldModel } from '../models/form-field.model'; import { OneboxFieldParser } from './onebox-field-parser'; import { DynamicQualdropModel } from '../ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; -import { DynamicTypeaheadModel } from '../ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.model'; +import { DynamicOneboxModel } from '../ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; import { DsDynamicInputModel } from '../ds-dynamic-form-ui/models/ds-dynamic-input.model'; import { ParserOptions } from './parser-options'; @@ -92,12 +92,12 @@ describe('OneboxFieldParser test suite', () => { expect(fieldModel instanceof DsDynamicInputModel).toBe(true); }); - it('should return a DynamicTypeaheadModel object when selectableMetadata has authority', () => { + it('should return a DynamicOneboxModel object when selectableMetadata has authority', () => { const parser = new OneboxFieldParser(submissionId, field1, initFormValues, parserOptions); const fieldModel = parser.parse(); - expect(fieldModel instanceof DynamicTypeaheadModel).toBe(true); + expect(fieldModel instanceof DynamicOneboxModel).toBe(true); }); }); diff --git a/src/app/shared/form/builder/parsers/onebox-field-parser.ts b/src/app/shared/form/builder/parsers/onebox-field-parser.ts index 598918ac2e..3eb5764a37 100644 --- a/src/app/shared/form/builder/parsers/onebox-field-parser.ts +++ b/src/app/shared/form/builder/parsers/onebox-field-parser.ts @@ -12,9 +12,9 @@ import { FormFieldMetadataValueObject } from '../models/form-field-metadata-valu import { isNotEmpty } from '../../../empty.util'; import { DsDynamicInputModel, DsDynamicInputModelConfig } from '../ds-dynamic-form-ui/models/ds-dynamic-input.model'; import { - DsDynamicTypeaheadModelConfig, - DynamicTypeaheadModel -} from '../ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.model'; + DsDynamicOneboxModelConfig, + DynamicOneboxModel +} from '../ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; export class OneboxFieldParser extends FieldParser { @@ -76,11 +76,11 @@ export class OneboxFieldParser extends FieldParser { return new DynamicQualdropModel(inputSelectGroup, clsGroup); } else if (this.configData.selectableMetadata[0].controlledVocabulary) { - const typeaheadModelConfig: DsDynamicTypeaheadModelConfig = this.initModel(null, label); - this.setVocabularyOptions(typeaheadModelConfig, this.parserOptions.collectionUUID); - this.setValues(typeaheadModelConfig, fieldValue, true); + const oneboxModelConfig: DsDynamicOneboxModelConfig = this.initModel(null, label); + this.setVocabularyOptions(oneboxModelConfig, this.parserOptions.collectionUUID); + this.setValues(oneboxModelConfig, fieldValue, true); - return new DynamicTypeaheadModel(typeaheadModelConfig); + return new DynamicOneboxModel(oneboxModelConfig); } else { const inputModelConfig: DsDynamicInputModelConfig = this.initModel(null, label); this.setValues(inputModelConfig, fieldValue); diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 38bb9afd5d..7790698059 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -48,7 +48,7 @@ import { VarDirective } from './utils/var.directive'; import { AuthNavMenuComponent } from './auth-nav-menu/auth-nav-menu.component'; import { LogOutComponent } from './log-out/log-out.component'; import { FormComponent } from './form/form.component'; -import { DsDynamicTypeaheadComponent } from './form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component'; +import { DsDynamicOneboxComponent } from './form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component'; import { DsDynamicScrollableDropdownComponent } from './form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; import { DsDynamicFormControlContainerComponent, @@ -271,7 +271,7 @@ const COMPONENTS = [ DsDynamicLookupRelationModalComponent, DsDynamicScrollableDropdownComponent, DsDynamicTagComponent, - DsDynamicTypeaheadComponent, + DsDynamicOneboxComponent, DsDynamicRelationGroupComponent, DsDatePickerComponent, DsDynamicFormGroupComponent, @@ -415,7 +415,7 @@ const ENTRY_COMPONENTS = [ DsDynamicLookupRelationModalComponent, DsDynamicScrollableDropdownComponent, DsDynamicTagComponent, - DsDynamicTypeaheadComponent, + DsDynamicOneboxComponent, DsDynamicRelationGroupComponent, DsDatePickerComponent, DsDynamicFormGroupComponent, From 3f0ab2cf1310f5f8bd29040a502466e553742f17 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 2 Jul 2020 14:15:16 +0200 Subject: [PATCH 017/126] [CST-3088] Fixed lint errors --- .../core/submission/vocabularies/vocabulary.service.spec.ts | 1 - .../models/onebox/dynamic-onebox.component.spec.ts | 4 ++-- .../shared/vocabulary-treeview/vocabulary-treeview.service.ts | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts index 94ac30ab56..962507ad94 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -607,7 +607,6 @@ describe('VocabularyService', () => { }); }); - describe('searchByTop', () => { it('should proxy the call to vocabularyEntryDetailDataService.searchBy', () => { const options: VocabularyFindOptions = new VocabularyFindOptions( diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts index 3982f659af..f43d779ae3 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts @@ -65,7 +65,7 @@ describe('DsDynamicOneboxComponent test suite', () => { let vocabularyServiceStub: any; let html; let modal; - let vocabulary = { + const vocabulary = { id: 'vocabulary', name: 'vocabulary', scrollable: true, @@ -82,7 +82,7 @@ describe('DsDynamicOneboxComponent test suite', () => { } } - let hierarchicalVocabulary = { + const hierarchicalVocabulary = { id: 'hierarchicalVocabulary', name: 'hierarchicalVocabulary', scrollable: true, diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts index 8beead5419..836ce7672a 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts @@ -230,7 +230,7 @@ export class VocabularyTreeviewService { const entryDetail: VocabularyEntryDetail = Object.assign(new VocabularyEntryDetail(), entry, { id: entryId }); - const hasChildren = entry.hasOtherInformation() && (entry.otherInformation as any)!.hasChildren == 'true'; + const hasChildren = entry.hasOtherInformation() && (entry.otherInformation as any)!.hasChildren === 'true'; const pageInfo: PageInfo = this.pageInfo; const isInInitValueHierarchy = this.initValueHierarchy.includes(entryId); const result = new TreeviewNode( From 599ddf465305fb1df3b1c2813d944afa358b0ef2 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 2 Jul 2020 14:28:12 +0200 Subject: [PATCH 018/126] [CST-3088] Renamed browseEntries variable to entries --- src/app/core/data/entries-response-parsing.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/core/data/entries-response-parsing.service.ts b/src/app/core/data/entries-response-parsing.service.ts index 2f49eb3871..ed13c3f228 100644 --- a/src/app/core/data/entries-response-parsing.service.ts +++ b/src/app/core/data/entries-response-parsing.service.ts @@ -23,12 +23,12 @@ export abstract class EntriesResponseParsingService e parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload)) { - let browseEntries = []; + let entries = []; if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { const serializer = new DSpaceSerializer(this.getSerializerModel()); - browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); + entries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); } - return new GenericSuccessResponse(browseEntries, data.statusCode, data.statusText, this.processPageInfo(data.payload)); + return new GenericSuccessResponse(entries, data.statusCode, data.statusText, this.processPageInfo(data.payload)); } else { return new ErrorResponse( Object.assign( From 2238599a7d695a20a06463c6693e0ae15e14d31a Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 2 Jul 2020 17:57:31 +0200 Subject: [PATCH 019/126] [CST-3088] remove duplicated test --- .../vocabularies/vocabulary.service.spec.ts | 103 +----------------- 1 file changed, 1 insertion(+), 102 deletions(-) diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts index 962507ad94..91eda41df1 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -9,7 +9,7 @@ import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.s import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { RequestService } from '../../data/request.service'; -import { FindListOptions, VocabularyEntriesRequest } from '../../data/request.models'; +import { VocabularyEntriesRequest } from '../../data/request.models'; import { RequestParam } from '../../cache/models/request-param.model'; import { PageInfo } from '../../shared/page-info.model'; import { PaginatedList } from '../../data/paginated-list'; @@ -67,55 +67,6 @@ describe('VocabularyService', () => { } }; - const vocabularyEntry: any = { - display: 'testValue1', - value: 'testValue1', - otherInformation: {}, - type: 'vocabularyEntry' - }; - - const vocabularyEntryWithAuthority: any = { - authority: 'authorityId1', - display: 'testValue1', - value: 'testValue1', - otherInformation: { - id: 'VR131402', - parent: 'Research Subject Categories::SOCIAL SCIENCES::Social sciences::Social work', - hasChildren: 'false', - note: 'Familjeforskning' - }, - type: 'vocabularyEntry', - _links: { - vocabularyEntryDetail: { - href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:VR131402' - } - } - }; - - const vocabularyEntryDetail: any = { - authority: 'authorityId1', - display: 'testValue1', - value: 'testValue1', - otherInformation: { - id: 'VR131402', - parent: 'Research Subject Categories::SOCIAL SCIENCES::Social sciences::Social work', - hasChildren: 'true', - note: 'Familjeforskning' - }, - type: 'vocabularyEntryDetail', - _links: { - self: { - href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:VR131402' - }, - parent: { - href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:parent' - }, - children: { - href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:children' - } - } - }; - const vocabularyEntryParentDetail: any = { authority: 'authorityId2', display: 'testParent', @@ -185,27 +136,6 @@ describe('VocabularyService', () => { } }; - const anotherVocabularyEntryDetail: any = { - authority: 'authorityId1', - display: 'children', - value: 'children', - otherInformation: { - id: 'VR131402', - parent: 'Research Subject Categories::SOCIAL SCIENCES::Social sciences::Social work', - hasChildren: 'false', - note: 'Familjeforskning' - }, - type: 'vocabularyEntryDetail', - _links: { - self: { - href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:children' - }, - parent: { - href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:testValue1' - } - } - }; - const endpointURL = `https://rest.api/rest/api/submission/vocabularies`; const requestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}`; const entryDetailEndpointURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails`; @@ -230,17 +160,13 @@ describe('VocabularyService', () => { } const pageInfo = new PageInfo(); const array = [vocabulary, hierarchicalVocabulary]; - const arrayEntries = [vocabularyEntryDetail, anotherVocabularyEntryDetail]; const childrenEntries = [vocabularyEntryChildDetail, vocabularyEntryChild2Detail]; const paginatedList = new PaginatedList(pageInfo, array); - const entriesPaginatedList = new PaginatedList(pageInfo, arrayEntries); const childrenPaginatedList = new PaginatedList(pageInfo, childrenEntries); const vocabularyRD = createSuccessfulRemoteDataObject(vocabulary); - const vocabularyEntryDetailRD = createSuccessfulRemoteDataObject(vocabularyEntryDetail); const vocabularyEntryDetailParentRD = createSuccessfulRemoteDataObject(vocabularyEntryParentDetail); const vocabularyEntryChildrenRD = createSuccessfulRemoteDataObject(childrenPaginatedList); const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); - const entriesPaginatedListRD = createSuccessfulRemoteDataObject(entriesPaginatedList); const getRequestEntry$ = (successful: boolean) => { return observableOf({ response: { isSuccessful: successful, payload: vocabulary } as any @@ -375,33 +301,6 @@ describe('VocabularyService', () => { }); - describe('getVocabularyEntries', () => { - - beforeEach(() => { - requestService = getMockRequestService(getRequestEntry$(true)); - rdbService = getMockRemoteDataBuildService(); - spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); - service = initTestService(); - }); - - it('should configure a new VocabularyEntriesRequest', () => { - const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entriesRequestURL); - - scheduler.schedule(() => service.getVocabularyEntries(vocabularyOptions, pageInfo).subscribe()); - scheduler.flush(); - - expect(requestService.configure).toHaveBeenCalledWith(expected); - }); - - it('should call RemoteDataBuildService to create the RemoteData Observable', () => { - service.getVocabularyEntries(vocabularyOptions, pageInfo); - - expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); - - }); - - }); - describe('', () => { beforeEach(() => { From 2da96aac25a06b7d178ed7481ecb2a672f8d21ea Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 3 Jul 2020 15:43:08 +0200 Subject: [PATCH 020/126] initialized submission object's sections properly on submission submit --- .../submission/submit/submission-submit.component.html | 1 + .../submission/submit/submission-submit.component.spec.ts | 1 + src/app/submission/submit/submission-submit.component.ts | 8 ++++++++ 3 files changed, 10 insertions(+) diff --git a/src/app/submission/submit/submission-submit.component.html b/src/app/submission/submit/submission-submit.component.html index c9e8c6b51a..25f397bf4e 100644 --- a/src/app/submission/submit/submission-submit.component.html +++ b/src/app/submission/submit/submission-submit.component.html @@ -1,6 +1,7 @@
diff --git a/src/app/submission/submit/submission-submit.component.spec.ts b/src/app/submission/submit/submission-submit.component.spec.ts index d0a2291ba9..19cd6d55dd 100644 --- a/src/app/submission/submit/submission-submit.component.spec.ts +++ b/src/app/submission/submit/submission-submit.component.spec.ts @@ -69,6 +69,7 @@ describe('SubmissionSubmitComponent Component', () => { expect(comp.submissionId.toString()).toEqual(submissionId); expect(comp.collectionId).toBe(submissionObject.collection.id); expect(comp.selfUrl).toBe(submissionObject._links.self.href); + expect(comp.sections).toBe(submissionObject.sections); expect(comp.submissionDefinition).toBe(submissionObject.submissionDefinition); })); diff --git a/src/app/submission/submit/submission-submit.component.ts b/src/app/submission/submit/submission-submit.component.ts index d3d3ca4e66..30612c9560 100644 --- a/src/app/submission/submit/submission-submit.component.ts +++ b/src/app/submission/submit/submission-submit.component.ts @@ -10,6 +10,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { SubmissionService } from '../submission.service'; import { SubmissionObject } from '../../core/submission/models/submission-object.model'; import { Collection } from '../../core/shared/collection.model'; +import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; /** * This component allows to submit a new workspaceitem. @@ -33,6 +34,12 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { */ public collectionParam: string; + /** + * The list of submission's sections + * @type {WorkspaceitemSectionsObject} + */ + public sections: WorkspaceitemSectionsObject; + /** * The submission self url * @type {string} @@ -95,6 +102,7 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { this.router.navigate(['/mydspace']); } else { this.collectionId = (submissionObject.collection as Collection).id; + this.sections = submissionObject.sections; this.selfUrl = submissionObject._links.self.href; this.submissionDefinition = (submissionObject.submissionDefinition as SubmissionDefinitionsModel); this.submissionId = submissionObject.id; From 58f18737f70742a5d1d12701b49cb635831d8a4b Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 3 Jul 2020 15:53:29 +0200 Subject: [PATCH 021/126] [CST-3088] tried to resolve travis fail --- .../vocabularies/vocabulary.service.spec.ts | 175 +++++++++--------- 1 file changed, 92 insertions(+), 83 deletions(-) diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts index 91eda41df1..0998b64579 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -198,107 +198,115 @@ describe('VocabularyService', () => { halService = jasmine.createSpyObj('halService', { getEndpoint: cold('a', { a: endpointURL }) }); - - responseCacheEntry = new RequestEntry(); - responseCacheEntry.response = new RestResponse(true, 200, 'Success'); - - requestService = jasmine.createSpyObj('requestService', { - generateRequestId: requestUUID, - configure: true, - removeByHrefSubstring: {}, - getByHref: observableOf(responseCacheEntry), - getByUUID: observableOf(responseCacheEntry), - }); - rdbService = jasmine.createSpyObj('rdbService', { - buildSingle: hot('a|', { - a: vocabularyRD - }), - buildList: hot('a|', { - a: paginatedListRD - }), - }); - - service = initTestService(); - - 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, 'searchBy').and.callThrough(); - spyOn((service as any).vocabularyDataService, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL)); - spyOn((service as any).vocabularyDataService, 'getFindAllHref').and.returnValue(observableOf(entriesRequestURL)); }); afterEach(() => { service = null; }); - describe('findVocabularyById', () => { - it('should proxy the call to vocabularyDataService.findVocabularyById', () => { - scheduler.schedule(() => service.findVocabularyById(vocabularyId)); - scheduler.flush(); + describe('', () => { + beforeEach(() => { + responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); - expect((service as any).vocabularyDataService.findById).toHaveBeenCalledWith(vocabularyId); - }); - - it('should return a RemoteData for the object with the given id', () => { - const result = service.findVocabularyById(vocabularyId); - const expected = cold('a|', { - a: vocabularyRD + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + configure: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), }); - expect(result).toBeObservable(expected); - }); - }); - - describe('findVocabularyByHref', () => { - it('should proxy the call to vocabularyDataService.findVocabularyByHref', () => { - scheduler.schedule(() => service.findVocabularyByHref(requestURL)); - scheduler.flush(); - - expect((service as any).vocabularyDataService.findByHref).toHaveBeenCalledWith(requestURL); - }); - - it('should return a RemoteData for the object with the given URL', () => { - const result = service.findVocabularyByHref(requestURL); - const expected = cold('a|', { - a: vocabularyRD + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('a|', { + a: vocabularyRD + }), + buildList: hot('a|', { + a: paginatedListRD + }), }); - expect(result).toBeObservable(expected); - }); - }); - describe('findAllVocabularies', () => { - it('should proxy the call to vocabularyDataService.findAllVocabularies', () => { - scheduler.schedule(() => service.findAllVocabularies()); - scheduler.flush(); + service = initTestService(); - expect((service as any).vocabularyDataService.findAll).toHaveBeenCalled(); + 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, 'searchBy').and.callThrough(); + spyOn((service as any).vocabularyDataService, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL)); + spyOn((service as any).vocabularyDataService, 'getFindAllHref').and.returnValue(observableOf(entriesRequestURL)); }); - it('should return a RemoteData>', () => { - const result = service.findAllVocabularies(); - const expected = cold('a|', { - a: paginatedListRD + afterEach(() => { + service = null; + }); + + describe('findVocabularyById', () => { + it('should proxy the call to vocabularyDataService.findVocabularyById', () => { + scheduler.schedule(() => service.findVocabularyById(vocabularyId)); + scheduler.flush(); + + expect((service as any).vocabularyDataService.findById).toHaveBeenCalledWith(vocabularyId); }); - expect(result).toBeObservable(expected); - }); - }); - describe('searchVocabularyByMetadataAndCollection', () => { - it('should proxy the call to vocabularyDataService.findVocabularyByHref', () => { - scheduler.schedule(() => service.searchVocabularyByMetadataAndCollection(vocabularyOptions).subscribe()); - scheduler.flush(); - - expect((service as any).vocabularyDataService.findByHref).toHaveBeenCalledWith(searchRequestURL); - }); - - it('should return a RemoteData for the search', () => { - const result = service.searchVocabularyByMetadataAndCollection(vocabularyOptions); - const expected = cold('a|', { - a: vocabularyRD + it('should return a RemoteData for the object with the given id', () => { + const result = service.findVocabularyById(vocabularyId); + const expected = cold('a|', { + a: vocabularyRD + }); + expect(result).toBeObservable(expected); }); - expect(result).toBeObservable(expected); }); + describe('findVocabularyByHref', () => { + it('should proxy the call to vocabularyDataService.findVocabularyByHref', () => { + scheduler.schedule(() => service.findVocabularyByHref(requestURL)); + scheduler.flush(); + + expect((service as any).vocabularyDataService.findByHref).toHaveBeenCalledWith(requestURL); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.findVocabularyByHref(requestURL); + const expected = cold('a|', { + a: vocabularyRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('findAllVocabularies', () => { + it('should proxy the call to vocabularyDataService.findAllVocabularies', () => { + scheduler.schedule(() => service.findAllVocabularies()); + scheduler.flush(); + + expect((service as any).vocabularyDataService.findAll).toHaveBeenCalled(); + }); + + it('should return a RemoteData>', () => { + const result = service.findAllVocabularies(); + const expected = cold('a|', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('searchVocabularyByMetadataAndCollection', () => { + it('should proxy the call to vocabularyDataService.findVocabularyByHref', () => { + scheduler.schedule(() => service.searchVocabularyByMetadataAndCollection(vocabularyOptions).subscribe()); + scheduler.flush(); + + expect((service as any).vocabularyDataService.findByHref).toHaveBeenCalledWith(searchRequestURL); + }); + + it('should return a RemoteData for the search', () => { + const result = service.searchVocabularyByMetadataAndCollection(vocabularyOptions); + const expected = cold('a|', { + a: vocabularyRD + }); + expect(result).toBeObservable(expected); + }); + + }); }); describe('', () => { @@ -383,6 +391,7 @@ describe('VocabularyService', () => { }); }); + }); describe('vocabularyEntryDetails endpoint', () => { From aa265c02a522e4d8c7f75cc370c004471d0ffed1 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Mon, 6 Jul 2020 10:28:23 +0200 Subject: [PATCH 022/126] [CST-3088] Fixed travis failure --- .../onebox/dynamic-onebox.component.spec.ts | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts index f43d779ae3..5bbae9c98c 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts @@ -3,7 +3,10 @@ import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/c import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { async, ComponentFixture, fakeAsync, inject, TestBed, tick, } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { CdkTreeModule } from '@angular/cdk/tree'; +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { DynamicFormLayoutService, DynamicFormsCoreModule, DynamicFormValidationService } from '@ng-dynamic-forms/core'; @@ -22,14 +25,23 @@ import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { createSuccessfulRemoteDataObject$ } from '../../../../../remote-data.utils'; import { VocabularyTreeviewComponent } from '../../../../../vocabulary-treeview/vocabulary-treeview.component'; -import { CdkTreeModule } from '@angular/cdk/tree'; -import { TestScheduler } from 'rxjs/testing'; -import { getTestScheduler } from 'jasmine-marbles'; export let ONEBOX_TEST_GROUP; export let ONEBOX_TEST_MODEL_CONFIG; +/* tslint:disable:max-classes-per-file */ + +// Mock class for NgbModalRef +export class MockNgbModalRef { + componentInstance = { + vocabularyOptions: undefined, + preloadLevel: undefined, + selectedItem: undefined + }; + result: Promise = new Promise((resolve, reject) => resolve(true)); +} + function init() { ONEBOX_TEST_GROUP = new FormGroup({ onebox: new FormControl(), @@ -63,6 +75,7 @@ describe('DsDynamicOneboxComponent test suite', () => { let testFixture: ComponentFixture; let oneboxCompFixture: ComponentFixture; let vocabularyServiceStub: any; + let modalService: any; let html; let modal; const vocabulary = { @@ -102,14 +115,14 @@ describe('DsDynamicOneboxComponent test suite', () => { // async beforeEach beforeEach(async(() => { vocabularyServiceStub = new VocabularyServiceStub(); - modal = jasmine.createSpyObj('modal', ['open', 'close', 'dismiss']); -/* jasmine.createSpyObj('modal', + // modal = jasmine.createSpyObj('modal', ['open', 'close', 'dismiss']); + modal = jasmine.createSpyObj('modal', { - open: jasmine.createSpy('open'), + open: jasmine.createSpy('open').and.returnValue(new MockNgbModalRef()), close: jasmine.createSpy('close'), dismiss: jasmine.createSpy('dismiss'), } - );*/ + ); init(); TestBed.configureTestingModule({ imports: [ @@ -365,13 +378,13 @@ describe('DsDynamicOneboxComponent test suite', () => { beforeEach(() => { scheduler = getTestScheduler(); spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(hierarchicalVocabulary)); + oneboxCompFixture = TestBed.createComponent(DsDynamicOneboxComponent); + oneboxComponent = oneboxCompFixture.componentInstance; // FormComponent test instance + modalService = TestBed.get(NgbModal); }); describe('when init model value is empty', () => { beforeEach(() => { - - oneboxCompFixture = TestBed.createComponent(DsDynamicOneboxComponent); - oneboxComponent = oneboxCompFixture.componentInstance; // FormComponent test instance oneboxComponent.group = ONEBOX_TEST_GROUP; oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); oneboxCompFixture.detectChanges(); @@ -396,8 +409,6 @@ describe('DsDynamicOneboxComponent test suite', () => { describe('when init model value is not empty', () => { beforeEach(() => { - oneboxCompFixture = TestBed.createComponent(DsDynamicOneboxComponent); - oneboxComponent = oneboxCompFixture.componentInstance; // FormComponent test instance oneboxComponent.group = ONEBOX_TEST_GROUP; oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); const entry = observableOf(Object.assign(new VocabularyEntry(), { @@ -445,3 +456,5 @@ class TestComponent { model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); } + +/* tslint:enable:max-classes-per-file */ From 73370fa00dcee4dd124edc6863fb036b4b931e16 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 9 Jul 2020 17:56:35 +0200 Subject: [PATCH 023/126] 71764: DsoPageAdministratorGuard --- .../collection-page-administrator.guard.ts | 20 +++++++ .../collection-page-routing.module.ts | 6 +- .../community-page-administrator.guard.ts | 20 +++++++ .../community-page-routing.module.ts | 6 +- .../item-page-administrator.guard.ts | 20 +++++++ .../+item-page/item-page-routing.module.ts | 6 +- .../dso-page-administrator.guard.spec.ts | 56 +++++++++++++++++++ .../dso-page-administrator.guard.ts | 39 +++++++++++++ .../feature-authorization.guard.spec.ts | 15 ++--- .../feature-authorization.guard.ts | 19 ++++--- .../site-administrator.guard.ts | 8 ++- 11 files changed, 192 insertions(+), 23 deletions(-) create mode 100644 src/app/+collection-page/collection-page-administrator.guard.ts create mode 100644 src/app/+community-page/community-page-administrator.guard.ts create mode 100644 src/app/+item-page/item-page-administrator.guard.ts create mode 100644 src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard.spec.ts create mode 100644 src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard.ts diff --git a/src/app/+collection-page/collection-page-administrator.guard.ts b/src/app/+collection-page/collection-page-administrator.guard.ts new file mode 100644 index 0000000000..dcb9f545a9 --- /dev/null +++ b/src/app/+collection-page/collection-page-administrator.guard.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { DsoPageAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard'; +import { Collection } from '../core/shared/collection.model'; +import { CollectionPageResolver } from './collection-page.resolver'; +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; + +@Injectable({ + providedIn: 'root' +}) +/** + * Guard for preventing unauthorized access to certain {@link Collection} pages requiring administrator rights + */ +export class CollectionPageAdministratorGuard extends DsoPageAdministratorGuard { + constructor(protected resolver: CollectionPageResolver, + protected authorizationService: AuthorizationDataService, + protected router: Router) { + super(resolver, authorizationService, router); + } +} diff --git a/src/app/+collection-page/collection-page-routing.module.ts b/src/app/+collection-page/collection-page-routing.module.ts index 1ebd9b3630..ebe086375f 100644 --- a/src/app/+collection-page/collection-page-routing.module.ts +++ b/src/app/+collection-page/collection-page-routing.module.ts @@ -16,6 +16,7 @@ import { CollectionBreadcrumbResolver } from '../core/breadcrumbs/collection-bre import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { CollectionPageAdministratorGuard } from './collection-page-administrator.guard'; export const COLLECTION_PARENT_PARAMETER = 'parent'; @@ -54,7 +55,7 @@ const ITEMTEMPLATE_PATH = 'itemtemplate'; { path: COLLECTION_EDIT_PATH, loadChildren: './edit-collection-page/edit-collection-page.module#EditCollectionPageModule', - canActivate: [AuthenticatedGuard] + canActivate: [CollectionPageAdministratorGuard] }, { path: 'delete', @@ -93,7 +94,8 @@ const ITEMTEMPLATE_PATH = 'itemtemplate'; CollectionBreadcrumbResolver, DSOBreadcrumbsService, LinkService, - CreateCollectionPageGuard + CreateCollectionPageGuard, + CollectionPageAdministratorGuard ] }) export class CollectionPageRoutingModule { diff --git a/src/app/+community-page/community-page-administrator.guard.ts b/src/app/+community-page/community-page-administrator.guard.ts new file mode 100644 index 0000000000..886a449951 --- /dev/null +++ b/src/app/+community-page/community-page-administrator.guard.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { DsoPageAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard'; +import { Community } from '../core/shared/community.model'; +import { CommunityPageResolver } from './community-page.resolver'; +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; + +@Injectable({ + providedIn: 'root' +}) +/** + * Guard for preventing unauthorized access to certain {@link Community} pages requiring administrator rights + */ +export class CommunityPageAdministratorGuard extends DsoPageAdministratorGuard { + constructor(protected resolver: CommunityPageResolver, + protected authorizationService: AuthorizationDataService, + protected router: Router) { + super(resolver, authorizationService, router); + } +} diff --git a/src/app/+community-page/community-page-routing.module.ts b/src/app/+community-page/community-page-routing.module.ts index 9922bc2c01..384574d9be 100644 --- a/src/app/+community-page/community-page-routing.module.ts +++ b/src/app/+community-page/community-page-routing.module.ts @@ -12,6 +12,7 @@ import { getCommunityModulePath } from '../app-routing.module'; import { CommunityBreadcrumbResolver } from '../core/breadcrumbs/community-breadcrumb.resolver'; import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; +import { CommunityPageAdministratorGuard } from './community-page-administrator.guard'; export const COMMUNITY_PARENT_PARAMETER = 'parent'; @@ -49,7 +50,7 @@ const COMMUNITY_EDIT_PATH = 'edit'; { path: COMMUNITY_EDIT_PATH, loadChildren: './edit-community-page/edit-community-page.module#EditCommunityPageModule', - canActivate: [AuthenticatedGuard] + canActivate: [CommunityPageAdministratorGuard] }, { path: 'delete', @@ -71,7 +72,8 @@ const COMMUNITY_EDIT_PATH = 'edit'; CommunityBreadcrumbResolver, DSOBreadcrumbsService, LinkService, - CreateCommunityPageGuard + CreateCommunityPageGuard, + CommunityPageAdministratorGuard ] }) export class CommunityPageRoutingModule { diff --git a/src/app/+item-page/item-page-administrator.guard.ts b/src/app/+item-page/item-page-administrator.guard.ts new file mode 100644 index 0000000000..507f8fcdbe --- /dev/null +++ b/src/app/+item-page/item-page-administrator.guard.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { DsoPageAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard'; +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { ItemPageResolver } from './item-page.resolver'; +import { Item } from '../core/shared/item.model'; + +@Injectable({ + providedIn: 'root' +}) +/** + * Guard for preventing unauthorized access to certain {@link Item} pages requiring administrator rights + */ +export class ItemPageAdministratorGuard extends DsoPageAdministratorGuard { + constructor(protected resolver: ItemPageResolver, + protected authorizationService: AuthorizationDataService, + protected router: Router) { + super(resolver, authorizationService, router); + } +} diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index 52faf96236..fc5cfa3522 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -11,6 +11,7 @@ import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.reso import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; +import { ItemPageAdministratorGuard } from './item-page-administrator.guard'; export function getItemPageRoute(itemId: string) { return new URLCombiner(getItemModulePath(), itemId).toString(); @@ -46,7 +47,7 @@ const UPLOAD_BITSTREAM_PATH = 'bitstreams/new'; { path: ITEM_EDIT_PATH, loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule', - canActivate: [AuthenticatedGuard] + canActivate: [ItemPageAdministratorGuard] }, { path: UPLOAD_BITSTREAM_PATH, @@ -61,7 +62,8 @@ const UPLOAD_BITSTREAM_PATH = 'bitstreams/new'; ItemPageResolver, ItemBreadcrumbResolver, DSOBreadcrumbsService, - LinkService + LinkService, + ItemPageAdministratorGuard ] }) diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard.spec.ts new file mode 100644 index 0000000000..7fef2e5d4c --- /dev/null +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard.spec.ts @@ -0,0 +1,56 @@ +import { DsoPageAdministratorGuard } from './dso-page-administrator.guard'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { Resolve, Router } from '@angular/router'; +import { RemoteData } from '../../remote-data'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { DSpaceObject } from '../../../shared/dspace-object.model'; + +/** + * Test implementation of abstract class DsoPageAdministratorGuard + */ +class DsoPageAdministratorGuardImpl extends DsoPageAdministratorGuard { + constructor(protected resolver: Resolve>, + protected authorizationService: AuthorizationDataService, + protected router: Router) { + super(resolver, authorizationService, router); + } +} + +describe('DsoPageAdministratorGuard', () => { + let guard: DsoPageAdministratorGuard; + let authorizationService: AuthorizationDataService; + let router: Router; + let resolver: Resolve>; + let object: DSpaceObject; + + function init() { + object = { + self: 'test-selflink' + } as DSpaceObject; + + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true) + }); + router = jasmine.createSpyObj('router', { + parseUrl: {} + }); + resolver = jasmine.createSpyObj('resolver', { + resolve: createSuccessfulRemoteDataObject$(object) + }); + guard = new DsoPageAdministratorGuardImpl(resolver, authorizationService, router); + } + + beforeEach(() => { + init(); + }); + + describe('getObjectUrl', () => { + it('should return the resolved object\'s selflink', (done) => { + guard.getObjectUrl(undefined, undefined).subscribe((selflink) => { + expect(selflink).toEqual(object.self); + done(); + }); + }); + }); +}); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard.ts new file mode 100644 index 0000000000..868510eb4e --- /dev/null +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard.ts @@ -0,0 +1,39 @@ +import { FeatureAuthorizationGuard } from './feature-authorization.guard'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; +import { DSpaceObject } from '../../../shared/dspace-object.model'; +import { FeatureID } from '../feature-id'; +import { of as observableOf } from 'rxjs'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../remote-data'; +import { getAllSucceededRemoteDataPayload } from '../../../shared/operators'; +import { map } from 'rxjs/operators'; + +/** + * Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require administrator rights + * This guard utilizes a resolver to retrieve the relevant object to check authorizations for + */ +export abstract class DsoPageAdministratorGuard extends FeatureAuthorizationGuard { + constructor(protected resolver: Resolve>, + protected authorizationService: AuthorizationDataService, + protected router: Router) { + super(authorizationService, router); + } + + /** + * Check administrator authorization rights + */ + getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.AdministratorOf); + } + + /** + * Check authorization rights for the object resolved using the provided resolver + */ + getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return (this.resolver.resolve(route, state) as Observable>).pipe( + getAllSucceededRemoteDataPayload(), + map((dso) => dso.self) + ); + } +} diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts index bfd161bad2..829a246dcc 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts @@ -2,7 +2,8 @@ import { FeatureAuthorizationGuard } from './feature-authorization.guard'; import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; import { of as observableOf } from 'rxjs'; -import { Router } from '@angular/router'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs/internal/Observable'; /** * Test implementation of abstract class FeatureAuthorizationGuard @@ -17,16 +18,16 @@ class FeatureAuthorizationGuardImpl extends FeatureAuthorizationGuard { super(authorizationService, router); } - getFeatureID(): FeatureID { - return this.featureId; + getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(this.featureId); } - getObjectUrl(): string { - return this.objectUrl; + getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(this.objectUrl); } - getEPersonUuid(): string { - return this.ePersonUuid; + getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(this.ePersonUuid); } } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts index 7806d87b0c..d53e71e289 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts @@ -9,6 +9,8 @@ import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; import { Observable } from 'rxjs/internal/Observable'; import { returnUnauthorizedUrlTreeOnFalse } from '../../../shared/operators'; +import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; /** * Abstract Guard for preventing unauthorized activating and loading of routes when a user @@ -24,29 +26,32 @@ export abstract class FeatureAuthorizationGuard implements CanActivate { * True when user has authorization rights for the feature and object provided * Redirect the user to the unauthorized page when he/she's not authorized for the given feature */ - canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return this.authorizationService.isAuthorized(this.getFeatureID(), this.getObjectUrl(), this.getEPersonUuid()).pipe(returnUnauthorizedUrlTreeOnFalse(this.router)); + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableCombineLatest(this.getFeatureID(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe( + switchMap(([featureID, objectUrl, ePersonUuid]) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid)), + returnUnauthorizedUrlTreeOnFalse(this.router) + ); } /** * The type of feature to check authorization for * Override this method to define a feature */ - abstract getFeatureID(): FeatureID; + abstract getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable; /** * The URL of the object to check if the user has authorized rights for * Override this method to define an object URL. If not provided, the {@link Site}'s URL will be used */ - getObjectUrl(): string { - return undefined; + getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(undefined); } /** * The UUID of the user to check authorization rights for * Override this method to define an {@link EPerson} UUID. If not provided, the authenticated user's UUID will be used. */ - getEPersonUuid(): string { - return undefined; + getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(undefined); } } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts index a64e40468d..a45049645a 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts @@ -2,7 +2,9 @@ import { Injectable } from '@angular/core'; import { FeatureAuthorizationGuard } from './feature-authorization.guard'; import { FeatureID } from '../feature-id'; import { AuthorizationDataService } from '../authorization-data.service'; -import { Router } from '@angular/router'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { of as observableOf } from 'rxjs'; +import { Observable } from 'rxjs/internal/Observable'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have administrator @@ -19,7 +21,7 @@ export class SiteAdministratorGuard extends FeatureAuthorizationGuard { /** * Check administrator authorization rights */ - getFeatureID(): FeatureID { - return FeatureID.AdministratorOf; + getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.AdministratorOf); } } From 9a666731e67ce460a2af9c997b870a3bbde4d50e Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 10 Jul 2020 11:47:29 +0200 Subject: [PATCH 024/126] 71764: Hide withdraw and reinstate button depending on authorization --- .../item-status/item-status.component.html | 4 +- .../item-status/item-status.component.ts | 40 ++++++++++++++++--- .../data/feature-authorization/feature-id.ts | 4 +- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.html b/src/app/+item-page/edit-item-page/item-status/item-status.component.html index 83662c9d7c..a619d4a576 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.html +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.html @@ -15,7 +15,7 @@ {{getItemPage((itemRD$ | async)?.payload)}}
-
- +
+
diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts index 1be13e3a7a..04e0e28645 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts @@ -1,17 +1,20 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { fadeIn, fadeInOut } from '../../../shared/animations/fade'; import { Item } from '../../../core/shared/item.model'; import { ActivatedRoute } from '@angular/router'; import { ItemOperation } from '../item-operation/itemOperation.model'; -import { first, map } from 'rxjs/operators'; +import { distinctUntilChanged, first, map } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { getItemEditPath, getItemPageRoute } from '../../item-page-routing.module'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { hasValue } from '../../../shared/empty.util'; @Component({ selector: 'ds-item-status', templateUrl: './item-status.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, + changeDetection: ChangeDetectionStrategy.Default, animations: [ fadeIn, fadeInOut @@ -47,7 +50,9 @@ export class ItemStatusComponent implements OnInit { */ actionsKeys; - constructor(private route: ActivatedRoute) { + constructor(private route: ActivatedRoute, + private authorizationService: AuthorizationDataService, + private changeDetection: ChangeDetectorRef) { } ngOnInit(): void { @@ -70,10 +75,29 @@ export class ItemStatusComponent implements OnInit { this.operations = []; this.operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations')); this.operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper')); + this.operations.push(undefined); + // Store the index of the "withdraw" or "reinstate" operation, because it's added asynchronously + const index = this.operations.length - 1; if (item.isWithdrawn) { - this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate')); + this.authorizationService.isAuthorized(FeatureID.ReinstateItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => { + if (authorized) { + console.log('added reinstate'); + this.operations[index] = new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate'); + } else { + this.operations[index] = undefined; + } + this.changeDetection.detectChanges(); + }); } else { - this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw')); + this.authorizationService.isAuthorized(FeatureID.WithdrawItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => { + if (authorized) { + console.log('added withdraw'); + this.operations[index] = new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw'); + } else { + this.operations[index] = undefined; + } + this.changeDetection.detectChanges(); + }); } if (item.isDiscoverable) { this.operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private')); @@ -102,4 +126,8 @@ export class ItemStatusComponent implements OnInit { return getItemEditPath(item.id); } + trackOperation(index: number, operation: ItemOperation) { + return hasValue(operation) ? operation.operationKey : undefined; + } + } diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 4731e92d6c..e3eb9cd668 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -3,5 +3,7 @@ */ export enum FeatureID { LoginOnBehalfOf = 'loginOnBehalfOf', - AdministratorOf = 'administratorOf' + AdministratorOf = 'administratorOf', + WithdrawItem = 'withdrawItem', + ReinstateItem = 'reinstateItem', } From 02fb4a4e4e2c645c6c8e442a3ed69cd7c09512b3 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 10 Jul 2020 12:08:48 +0200 Subject: [PATCH 025/126] 71764: Refactor DsoPageAdministratorGuard to more abstract DsoPageFeatureGuard and add implementations for WithdrawItem / ReinstateItem guards --- .../collection-page-administrator.guard.ts | 16 ++++++++-- .../community-page-administrator.guard.ts | 16 ++++++++-- .../edit-item-page.routing.module.ts | 8 ++++- .../item-page-reinstate.guard.ts | 30 +++++++++++++++++++ .../item-page-withdraw.guard.ts | 30 +++++++++++++++++++ .../item-status/item-status.component.ts | 2 -- .../item-page-administrator.guard.ts | 16 ++++++++-- ...spec.ts => dso-page-feature.guard.spec.ts} | 19 ++++++++---- ...tor.guard.ts => dso-page-feature.guard.ts} | 21 ++++--------- 9 files changed, 125 insertions(+), 33 deletions(-) create mode 100644 src/app/+item-page/edit-item-page/item-page-reinstate.guard.ts create mode 100644 src/app/+item-page/edit-item-page/item-page-withdraw.guard.ts rename src/app/core/data/feature-authorization/feature-authorization-guard/{dso-page-administrator.guard.spec.ts => dso-page-feature.guard.spec.ts} (68%) rename src/app/core/data/feature-authorization/feature-authorization-guard/{dso-page-administrator.guard.ts => dso-page-feature.guard.ts} (73%) diff --git a/src/app/+collection-page/collection-page-administrator.guard.ts b/src/app/+collection-page/collection-page-administrator.guard.ts index dcb9f545a9..4d2f246689 100644 --- a/src/app/+collection-page/collection-page-administrator.guard.ts +++ b/src/app/+collection-page/collection-page-administrator.guard.ts @@ -1,9 +1,12 @@ import { Injectable } from '@angular/core'; -import { Router } from '@angular/router'; -import { DsoPageAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; import { Collection } from '../core/shared/collection.model'; import { CollectionPageResolver } from './collection-page.resolver'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { of as observableOf } from 'rxjs'; +import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard'; +import { Observable } from 'rxjs/internal/Observable'; +import { FeatureID } from '../core/data/feature-authorization/feature-id'; @Injectable({ providedIn: 'root' @@ -11,10 +14,17 @@ import { AuthorizationDataService } from '../core/data/feature-authorization/aut /** * Guard for preventing unauthorized access to certain {@link Collection} pages requiring administrator rights */ -export class CollectionPageAdministratorGuard extends DsoPageAdministratorGuard { +export class CollectionPageAdministratorGuard extends DsoPageFeatureGuard { constructor(protected resolver: CollectionPageResolver, protected authorizationService: AuthorizationDataService, protected router: Router) { super(resolver, authorizationService, router); } + + /** + * Check administrator authorization rights + */ + getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.AdministratorOf); + } } diff --git a/src/app/+community-page/community-page-administrator.guard.ts b/src/app/+community-page/community-page-administrator.guard.ts index 886a449951..c5e58ddb1a 100644 --- a/src/app/+community-page/community-page-administrator.guard.ts +++ b/src/app/+community-page/community-page-administrator.guard.ts @@ -1,9 +1,12 @@ import { Injectable } from '@angular/core'; -import { Router } from '@angular/router'; -import { DsoPageAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; import { Community } from '../core/shared/community.model'; import { CommunityPageResolver } from './community-page.resolver'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { of as observableOf } from 'rxjs'; +import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard'; +import { Observable } from 'rxjs/internal/Observable'; +import { FeatureID } from '../core/data/feature-authorization/feature-id'; @Injectable({ providedIn: 'root' @@ -11,10 +14,17 @@ import { AuthorizationDataService } from '../core/data/feature-authorization/aut /** * Guard for preventing unauthorized access to certain {@link Community} pages requiring administrator rights */ -export class CommunityPageAdministratorGuard extends DsoPageAdministratorGuard { +export class CommunityPageAdministratorGuard extends DsoPageFeatureGuard { constructor(protected resolver: CommunityPageResolver, protected authorizationService: AuthorizationDataService, protected router: Router) { super(resolver, authorizationService, router); } + + /** + * Check administrator authorization rights + */ + getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.AdministratorOf); + } } diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts index 87b4b7a592..ff1b79a247 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -20,6 +20,8 @@ import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; import { I18nBreadcrumbsService } from '../../core/breadcrumbs/i18n-breadcrumbs.service'; +import { ItemPageReinstateGuard } from './item-page-reinstate.guard'; +import { ItemPageWithdrawGuard } from './item-page-withdraw.guard'; export const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; export const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; @@ -97,10 +99,12 @@ export const ITEM_EDIT_AUTHORIZATIONS_PATH = 'authorizations'; { path: ITEM_EDIT_WITHDRAW_PATH, component: ItemWithdrawComponent, + canActivate: [ItemPageWithdrawGuard] }, { path: ITEM_EDIT_REINSTATE_PATH, component: ItemReinstateComponent, + canActivate: [ItemPageReinstateGuard] }, { path: ITEM_EDIT_PRIVATE_PATH, @@ -153,7 +157,9 @@ export const ITEM_EDIT_AUTHORIZATIONS_PATH = 'authorizations'; I18nBreadcrumbResolver, I18nBreadcrumbsService, ResourcePolicyResolver, - ResourcePolicyTargetResolver + ResourcePolicyTargetResolver, + ItemPageReinstateGuard, + ItemPageWithdrawGuard ] }) export class EditItemPageRoutingModule { diff --git a/src/app/+item-page/edit-item-page/item-page-reinstate.guard.ts b/src/app/+item-page/edit-item-page/item-page-reinstate.guard.ts new file mode 100644 index 0000000000..061705619a --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-page-reinstate.guard.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { DsoPageFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard'; +import { Item } from '../../core/shared/item.model'; +import { ItemPageResolver } from '../item-page.resolver'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs/internal/Observable'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { of as observableOf } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +/** + * Guard for preventing unauthorized access to certain {@link Item} pages requiring reinstate rights + */ +export class ItemPageReinstateGuard extends DsoPageFeatureGuard { + constructor(protected resolver: ItemPageResolver, + protected authorizationService: AuthorizationDataService, + protected router: Router) { + super(resolver, authorizationService, router); + } + + /** + * Check reinstate authorization rights + */ + getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.ReinstateItem); + } +} diff --git a/src/app/+item-page/edit-item-page/item-page-withdraw.guard.ts b/src/app/+item-page/edit-item-page/item-page-withdraw.guard.ts new file mode 100644 index 0000000000..60576bcdb8 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-page-withdraw.guard.ts @@ -0,0 +1,30 @@ +import { DsoPageFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard'; +import { Item } from '../../core/shared/item.model'; +import { Injectable } from '@angular/core'; +import { ItemPageResolver } from '../item-page.resolver'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs/internal/Observable'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { of as observableOf } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +/** + * Guard for preventing unauthorized access to certain {@link Item} pages requiring withdraw rights + */ +export class ItemPageWithdrawGuard extends DsoPageFeatureGuard { + constructor(protected resolver: ItemPageResolver, + protected authorizationService: AuthorizationDataService, + protected router: Router) { + super(resolver, authorizationService, router); + } + + /** + * Check withdraw authorization rights + */ + getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.WithdrawItem); + } +} diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts index 04e0e28645..00a03e18c1 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts @@ -81,7 +81,6 @@ export class ItemStatusComponent implements OnInit { if (item.isWithdrawn) { this.authorizationService.isAuthorized(FeatureID.ReinstateItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => { if (authorized) { - console.log('added reinstate'); this.operations[index] = new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate'); } else { this.operations[index] = undefined; @@ -91,7 +90,6 @@ export class ItemStatusComponent implements OnInit { } else { this.authorizationService.isAuthorized(FeatureID.WithdrawItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => { if (authorized) { - console.log('added withdraw'); this.operations[index] = new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw'); } else { this.operations[index] = undefined; diff --git a/src/app/+item-page/item-page-administrator.guard.ts b/src/app/+item-page/item-page-administrator.guard.ts index 507f8fcdbe..eae76348ad 100644 --- a/src/app/+item-page/item-page-administrator.guard.ts +++ b/src/app/+item-page/item-page-administrator.guard.ts @@ -1,9 +1,12 @@ import { Injectable } from '@angular/core'; -import { Router } from '@angular/router'; -import { DsoPageAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { ItemPageResolver } from './item-page.resolver'; import { Item } from '../core/shared/item.model'; +import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard'; +import { Observable } from 'rxjs/internal/Observable'; +import { FeatureID } from '../core/data/feature-authorization/feature-id'; +import { of as observableOf } from 'rxjs'; @Injectable({ providedIn: 'root' @@ -11,10 +14,17 @@ import { Item } from '../core/shared/item.model'; /** * Guard for preventing unauthorized access to certain {@link Item} pages requiring administrator rights */ -export class ItemPageAdministratorGuard extends DsoPageAdministratorGuard { +export class ItemPageAdministratorGuard extends DsoPageFeatureGuard { constructor(protected resolver: ItemPageResolver, protected authorizationService: AuthorizationDataService, protected router: Router) { super(resolver, authorizationService, router); } + + /** + * Check administrator authorization rights + */ + getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.AdministratorOf); + } } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.spec.ts similarity index 68% rename from src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard.spec.ts rename to src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.spec.ts index 7fef2e5d4c..1f5efd1329 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.spec.ts @@ -1,24 +1,31 @@ -import { DsoPageAdministratorGuard } from './dso-page-administrator.guard'; import { AuthorizationDataService } from '../authorization-data.service'; -import { Resolve, Router } from '@angular/router'; +import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; import { RemoteData } from '../../remote-data'; import { of as observableOf } from 'rxjs'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import { DSpaceObject } from '../../../shared/dspace-object.model'; +import { DsoPageFeatureGuard } from './dso-page-feature.guard'; +import { FeatureID } from '../feature-id'; +import { Observable } from 'rxjs/internal/Observable'; /** * Test implementation of abstract class DsoPageAdministratorGuard */ -class DsoPageAdministratorGuardImpl extends DsoPageAdministratorGuard { +class DsoPageFeatureGuardImpl extends DsoPageFeatureGuard { constructor(protected resolver: Resolve>, protected authorizationService: AuthorizationDataService, - protected router: Router) { + protected router: Router, + protected featureID: FeatureID) { super(resolver, authorizationService, router); } + + getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(this.featureID); + } } describe('DsoPageAdministratorGuard', () => { - let guard: DsoPageAdministratorGuard; + let guard: DsoPageFeatureGuard; let authorizationService: AuthorizationDataService; let router: Router; let resolver: Resolve>; @@ -38,7 +45,7 @@ describe('DsoPageAdministratorGuard', () => { resolver = jasmine.createSpyObj('resolver', { resolve: createSuccessfulRemoteDataObject$(object) }); - guard = new DsoPageAdministratorGuardImpl(resolver, authorizationService, router); + guard = new DsoPageFeatureGuardImpl(resolver, authorizationService, router, undefined); } beforeEach(() => { diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.ts similarity index 73% rename from src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard.ts rename to src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.ts index 868510eb4e..ed2590b521 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.ts @@ -1,32 +1,23 @@ -import { FeatureAuthorizationGuard } from './feature-authorization.guard'; -import { AuthorizationDataService } from '../authorization-data.service'; import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; -import { DSpaceObject } from '../../../shared/dspace-object.model'; -import { FeatureID } from '../feature-id'; -import { of as observableOf } from 'rxjs'; -import { Observable } from 'rxjs/internal/Observable'; import { RemoteData } from '../../remote-data'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { Observable } from 'rxjs/internal/Observable'; import { getAllSucceededRemoteDataPayload } from '../../../shared/operators'; import { map } from 'rxjs/operators'; +import { DSpaceObject } from '../../../shared/dspace-object.model'; +import { FeatureAuthorizationGuard } from './feature-authorization.guard'; /** - * Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require administrator rights + * Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for a specific feature * This guard utilizes a resolver to retrieve the relevant object to check authorizations for */ -export abstract class DsoPageAdministratorGuard extends FeatureAuthorizationGuard { +export abstract class DsoPageFeatureGuard extends FeatureAuthorizationGuard { constructor(protected resolver: Resolve>, protected authorizationService: AuthorizationDataService, protected router: Router) { super(authorizationService, router); } - /** - * Check administrator authorization rights - */ - getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(FeatureID.AdministratorOf); - } - /** * Check authorization rights for the object resolved using the provided resolver */ From 7787a2d0e9387819d799e0bdfa98a354088c7835 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 10 Jul 2020 13:07:31 +0200 Subject: [PATCH 026/126] 71764: SiteRegisterGuard + hide register link when unauthorized --- src/app/app-routing.module.ts | 3 ++- src/app/core/core.module.ts | 2 ++ .../site-register.guard.ts | 27 +++++++++++++++++++ .../data/feature-authorization/feature-id.ts | 1 + src/app/shared/log-in/log-in.component.html | 2 +- src/app/shared/log-in/log-in.component.ts | 11 +++++++- 6 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index c8ee6ecd8b..5842e8b06b 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -12,6 +12,7 @@ import { getItemPageRoute } from './+item-page/item-page-routing.module'; import { getCollectionPageRoute } from './+collection-page/collection-page-routing.module'; import { SiteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { UnauthorizedComponent } from './unauthorized/unauthorized.component'; +import { SiteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard'; const ITEM_MODULE_PATH = 'items'; @@ -93,7 +94,7 @@ export function getUnauthorizedPath() { { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' }, { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, - { path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule' }, + { path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule', canActivate: [SiteRegisterGuard] }, { path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule' }, { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' }, { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 4b2a842bca..6c8c1ec1dc 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -159,6 +159,7 @@ import { SubmissionCcLicenseDataService } from './submission/submission-cc-licen import { SubmissionCcLicence } from './submission/models/submission-cc-license.model'; import { SubmissionCcLicenceUrl } from './submission/models/submission-cc-license-url.model'; import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-license-url-data.service'; +import { SiteRegisterGuard } from './data/feature-authorization/feature-authorization-guard/site-register.guard'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -282,6 +283,7 @@ const PROVIDERS = [ FeatureDataService, AuthorizationDataService, SiteAdministratorGuard, + SiteRegisterGuard, MetadataSchemaDataService, MetadataFieldDataService, TokenResponseParsingService, diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts new file mode 100644 index 0000000000..18397cf71e --- /dev/null +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts @@ -0,0 +1,27 @@ +import { FeatureAuthorizationGuard } from './feature-authorization.guard'; +import { Injectable } from '@angular/core'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs/internal/Observable'; +import { FeatureID } from '../feature-id'; +import { of as observableOf } from 'rxjs'; + +/** + * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have registration + * rights to the {@link Site} + */ +@Injectable({ + providedIn: 'root' +}) +export class SiteRegisterGuard extends FeatureAuthorizationGuard { + constructor(protected authorizationService: AuthorizationDataService, protected router: Router) { + super(authorizationService, router); + } + + /** + * Check registration authorization rights + */ + getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.EPersonRegistration); + } +} diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index e3eb9cd668..27d6618e44 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -6,4 +6,5 @@ export enum FeatureID { AdministratorOf = 'administratorOf', WithdrawItem = 'withdrawItem', ReinstateItem = 'reinstateItem', + EPersonRegistration = 'epersonRegistration', } diff --git a/src/app/shared/log-in/log-in.component.html b/src/app/shared/log-in/log-in.component.html index 5285bc65e4..0b63ce0304 100644 --- a/src/app/shared/log-in/log-in.component.html +++ b/src/app/shared/log-in/log-in.component.html @@ -8,6 +8,6 @@ - {{"login.form.new-user" | translate}} + {{"login.form.new-user" | translate}} {{"login.form.forgot-password" | translate}}
diff --git a/src/app/shared/log-in/log-in.component.ts b/src/app/shared/log-in/log-in.component.ts index 32e10fef45..91d0ea97a8 100644 --- a/src/app/shared/log-in/log-in.component.ts +++ b/src/app/shared/log-in/log-in.component.ts @@ -9,6 +9,8 @@ import { getAuthenticationMethods, isAuthenticated, isAuthenticationLoading } fr import { CoreState } from '../../core/core.reducers'; import { AuthService } from '../../core/auth/auth.service'; import { getForgotPasswordPath, getRegisterPath } from '../../app-routing.module'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; /** * /users/sign-in @@ -51,8 +53,14 @@ export class LogInComponent implements OnInit, OnDestroy { */ private alive = true; + /** + * Whether or not the current user (or anonymous) is authorized to register an account + */ + canRegister$: Observable; + constructor(private store: Store, - private authService: AuthService,) { + private authService: AuthService, + private authorizationService: AuthorizationDataService) { } ngOnInit(): void { @@ -77,6 +85,7 @@ export class LogInComponent implements OnInit, OnDestroy { } ); + this.canRegister$ = this.authorizationService.isAuthorized(FeatureID.EPersonRegistration); } ngOnDestroy(): void { From 7f6bd680b2f62d9e5c26913ac4bd5d3f7c3c8148 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 10 Jul 2020 14:18:01 +0200 Subject: [PATCH 027/126] 71764: Test fixes --- .../item-status/item-status.component.spec.ts | 15 +++++++++++++-- .../form/process-form.component.spec.ts | 2 +- src/app/shared/log-in/log-in.component.spec.ts | 9 +++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts index abb2839551..9c28f097a4 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts @@ -12,6 +12,7 @@ import { By } from '@angular/platform-browser'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { of as observableOf } from 'rxjs'; import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; describe('ItemStatusComponent', () => { let comp: ItemStatusComponent; @@ -20,7 +21,10 @@ describe('ItemStatusComponent', () => { const mockItem = Object.assign(new Item(), { id: 'fake-id', handle: 'fake/handle', - lastModified: '2018' + lastModified: '2018', + _links: { + self: { href: 'test-item-selflink' } + } }); const itemPageUrl = `items/${mockItem.id}`; @@ -31,13 +35,20 @@ describe('ItemStatusComponent', () => { } }; + let authorizationService: AuthorizationDataService; + beforeEach(async(() => { + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true) + }); + TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], declarations: [ItemStatusComponent], providers: [ { provide: ActivatedRoute, useValue: routeStub }, - { provide: HostWindowService, useValue: new HostWindowServiceStub(0) } + { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, + { provide: AuthorizationDataService, useValue: authorizationService }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); })); diff --git a/src/app/process-page/form/process-form.component.spec.ts b/src/app/process-page/form/process-form.component.spec.ts index 938aa56a88..95f266ed6f 100644 --- a/src/app/process-page/form/process-form.component.spec.ts +++ b/src/app/process-page/form/process-form.component.spec.ts @@ -60,7 +60,7 @@ describe('ProcessFormComponent', () => { { provide: ScriptDataService, useValue: scriptService }, { provide: NotificationsService, useClass: NotificationsServiceStub }, { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeBySubstring', 'removeByHrefSubstring']) }, - { provide: Router, useValue: {} }, + { provide: Router, useValue: jasmine.createSpyObj('router', ['navigateByUrl']) }, ], schemas: [NO_ERRORS_SCHEMA] }) diff --git a/src/app/shared/log-in/log-in.component.spec.ts b/src/app/shared/log-in/log-in.component.spec.ts index a9a42bf3dd..5ff9754fec 100644 --- a/src/app/shared/log-in/log-in.component.spec.ts +++ b/src/app/shared/log-in/log-in.component.spec.ts @@ -18,6 +18,8 @@ import { NativeWindowService } from '../../core/services/window.service'; import { provideMockStore } from '@ngrx/store/testing'; import { createTestComponent } from '../testing/utils.test'; import { RouterTestingModule } from '@angular/router/testing'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { of } from 'rxjs/internal/observable/of'; describe('LogInComponent', () => { @@ -34,7 +36,13 @@ describe('LogInComponent', () => { } }; + let authorizationService: AuthorizationDataService; + beforeEach(async(() => { + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: of(true) + }); + // refine the test module by declaring the test component TestBed.configureTestingModule({ imports: [ @@ -58,6 +66,7 @@ describe('LogInComponent', () => { { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, // { provide: Router, useValue: new RouterStub() }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + { provide: AuthorizationDataService, useValue: authorizationService }, provideMockStore({ initialState }), LogInComponent ], From 3afe8c0b1dfb4ef7fc95677109db94594aa8464a Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 14 Jul 2020 15:13:21 +0200 Subject: [PATCH 028/126] 71764: ItemStatusComponent BehaviorSubject for updating operations --- .../item-status/item-status.component.html | 2 +- .../item-status/item-status.component.ts | 47 ++++++++++--------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.html b/src/app/+item-page/edit-item-page/item-status/item-status.component.html index a619d4a576..3fcf10a2f5 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.html +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.html @@ -15,7 +15,7 @@ {{getItemPage((itemRD$ | async)?.payload)}}
-
+
diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts index 00a03e18c1..93792acb35 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts @@ -10,6 +10,7 @@ import { getItemEditPath, getItemPageRoute } from '../../item-page-routing.modul import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { hasValue } from '../../../shared/empty.util'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; @Component({ selector: 'ds-item-status', @@ -43,7 +44,7 @@ export class ItemStatusComponent implements OnInit { * The possible actions that can be performed on the item * key: id value: url to action's component */ - operations: ItemOperation[]; + operations$: BehaviorSubject = new BehaviorSubject([]); /** * The keys of the actions (to loop over) @@ -51,8 +52,7 @@ export class ItemStatusComponent implements OnInit { actionsKeys; constructor(private route: ActivatedRoute, - private authorizationService: AuthorizationDataService, - private changeDetection: ChangeDetectorRef) { + private authorizationService: AuthorizationDataService) { } ngOnInit(): void { @@ -72,38 +72,43 @@ export class ItemStatusComponent implements OnInit { i18n example: 'item.edit.tabs.status.buttons..label' The value is supposed to be a href for the button */ - this.operations = []; - this.operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations')); - this.operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper')); - this.operations.push(undefined); + const operations = []; + operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations')); + operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper')); + operations.push(undefined); // Store the index of the "withdraw" or "reinstate" operation, because it's added asynchronously - const index = this.operations.length - 1; + const indexOfWithdrawReinstate = operations.length - 1; + if (item.isDiscoverable) { + operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private')); + } else { + operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public')); + } + operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete')); + operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move')); + + this.operations$.next(operations); + if (item.isWithdrawn) { this.authorizationService.isAuthorized(FeatureID.ReinstateItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => { + const newOperations = [...this.operations$.value]; if (authorized) { - this.operations[index] = new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate'); + newOperations[indexOfWithdrawReinstate] = new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate'); } else { - this.operations[index] = undefined; + newOperations[indexOfWithdrawReinstate] = undefined; } - this.changeDetection.detectChanges(); + this.operations$.next(newOperations); }); } else { this.authorizationService.isAuthorized(FeatureID.WithdrawItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => { + const newOperations = [...this.operations$.value]; if (authorized) { - this.operations[index] = new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw'); + newOperations[indexOfWithdrawReinstate] = new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw'); } else { - this.operations[index] = undefined; + newOperations[indexOfWithdrawReinstate] = undefined; } - this.changeDetection.detectChanges(); + this.operations$.next(newOperations); }); } - if (item.isDiscoverable) { - this.operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private')); - } else { - this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public')); - } - this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete')); - this.operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move')); }); } From a2834764035265cb537891cee289656ed1cc8445 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 16 Jul 2020 13:08:17 +0200 Subject: [PATCH 029/126] [CST-3088] Fixed travis failure --- .../form/process-form.component.spec.ts | 3 ++- .../onebox/dynamic-onebox.component.spec.ts | 24 +++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/app/process-page/form/process-form.component.spec.ts b/src/app/process-page/form/process-form.component.spec.ts index 12326111da..d32405d8bf 100644 --- a/src/app/process-page/form/process-form.component.spec.ts +++ b/src/app/process-page/form/process-form.component.spec.ts @@ -75,8 +75,9 @@ describe('ProcessFormComponent', () => { fixture.detectChanges(); }); - it('should create', () => { + it('should create', (done) => { expect(component).toBeTruthy(); + done(); }); it('should call invoke on the scriptService on submit', () => { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts index 5bbae9c98c..e94d3a208c 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts @@ -1,7 +1,7 @@ // Load the implementations that should be tested import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { async, ComponentFixture, fakeAsync, inject, TestBed, tick, } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, inject, TestBed, tick, } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { CdkTreeModule } from '@angular/cdk/tree'; @@ -113,12 +113,12 @@ describe('DsDynamicOneboxComponent test suite', () => { } // async beforeEach - beforeEach(async(() => { + beforeEach(() => { vocabularyServiceStub = new VocabularyServiceStub(); - // modal = jasmine.createSpyObj('modal', ['open', 'close', 'dismiss']); + modal = jasmine.createSpyObj('modal', { - open: jasmine.createSpy('open').and.returnValue(new MockNgbModalRef()), + open: jasmine.createSpy('open'), close: jasmine.createSpy('close'), dismiss: jasmine.createSpy('dismiss'), } @@ -150,9 +150,9 @@ describe('DsDynamicOneboxComponent test suite', () => { { provide: NgbModal, useValue: modal } ], schemas: [CUSTOM_ELEMENTS_SCHEMA] - }); + }).compileComponents(); - })); + }); describe('', () => { // synchronous beforeEach @@ -381,6 +381,7 @@ describe('DsDynamicOneboxComponent test suite', () => { oneboxCompFixture = TestBed.createComponent(DsDynamicOneboxComponent); oneboxComponent = oneboxCompFixture.componentInstance; // FormComponent test instance modalService = TestBed.get(NgbModal); + modalService.open.and.returnValue(new MockNgbModalRef()); }); describe('when init model value is empty', () => { @@ -395,15 +396,17 @@ describe('DsDynamicOneboxComponent test suite', () => { oneboxComponent = null; }); - it('should init component properly', () => { + it('should init component properly', fakeAsync(() => { + tick(); expect(oneboxComponent.currentValue).not.toBeDefined(); - }); + })); - it('should open tree properly', () => { + it('should open tree properly', (done) => { scheduler.schedule(() => oneboxComponent.openTree(new Event('click'))); scheduler.flush(); expect((oneboxComponent as any).modalService.open).toHaveBeenCalled(); + done(); }); }); @@ -433,11 +436,12 @@ describe('DsDynamicOneboxComponent test suite', () => { expect((oneboxComponent as any).vocabularyService.getVocabularyEntryByValue).toHaveBeenCalled(); })); - it('should open tree properly', () => { + it('should open tree properly', (done) => { scheduler.schedule(() => oneboxComponent.openTree(new Event('click'))); scheduler.flush(); expect((oneboxComponent as any).modalService.open).toHaveBeenCalled(); + done(); }); }); From c33a63aa90480f6235bdbeaa1df91b620cc06ae9 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 16 Jul 2020 13:13:24 +0200 Subject: [PATCH 030/126] [CST-3088] Removed metadata and collection params when making vocabulary requests --- .../models/vocabulary-find-options.model.ts | 11 +--- .../vocabularies/vocabulary.service.spec.ts | 59 +++++++++++++------ .../vocabularies/vocabulary.service.ts | 10 ---- 3 files changed, 43 insertions(+), 37 deletions(-) diff --git a/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts index 9dc12fad57..bd9bd55b95 100644 --- a/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts +++ b/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts @@ -8,9 +8,7 @@ import { isNotEmpty } from '../../../../shared/empty.util'; */ export class VocabularyFindOptions extends FindListOptions { - constructor(public collection, - public metadata, - public query: string = '', + constructor(public query: string = '', public filter?: string, public exact?: boolean, public entryID?: string, @@ -21,12 +19,7 @@ export class VocabularyFindOptions extends FindListOptions { super(); const searchParams = []; - if (isNotEmpty(metadata)) { - searchParams.push(new RequestParam('metadata', metadata)) - } - if (isNotEmpty(collection)) { - searchParams.push(new RequestParam('collection', collection)) - } + if (isNotEmpty(query)) { searchParams.push(new RequestParam('query', query)) } diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts index 0998b64579..682cc215e0 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -13,7 +13,7 @@ import { VocabularyEntriesRequest } from '../../data/request.models'; import { RequestParam } from '../../cache/models/request-param.model'; import { PageInfo } from '../../shared/page-info.model'; import { PaginatedList } from '../../data/paginated-list'; -import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { RequestEntry } from '../../data/request.reducer'; import { RestResponse } from '../../cache/response.models'; import { VocabularyService } from './vocabulary.service'; @@ -67,6 +67,27 @@ describe('VocabularyService', () => { } }; + const vocabularyEntry: any = { + display: 'testValue1', + value: 'testValue1', + otherInformation: {}, + type: 'vocabularyEntry' + }; + + const vocabularyEntry2: any = { + display: 'testValue2', + value: 'testValue2', + otherInformation: {}, + type: 'vocabularyEntry' + }; + + const vocabularyEntry3: any = { + display: 'testValue3', + value: 'testValue3', + otherInformation: {}, + type: 'vocabularyEntry' + }; + const vocabularyEntryParentDetail: any = { authority: 'authorityId2', display: 'testParent', @@ -148,10 +169,10 @@ describe('VocabularyService', () => { const collectionUUID = '8b39g7ya-5a4b-438b-851f-be1d5b4a1c5a'; const entryID = 'dsfsfsdf-5a4b-438b-851f-be1d5b4a1c5a'; const searchRequestURL = `https://rest.api/rest/api/submission/vocabularies/search/byMetadataAndCollection?metadata=${metadata}&collection=${collectionUUID}`; - const entriesRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?metadata=${metadata}&collection=${collectionUUID}`; - const entriesByValueRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?metadata=${metadata}&collection=${collectionUUID}&filter=test&exact=false`; - const entryByValueRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?metadata=${metadata}&collection=${collectionUUID}&filter=test&exact=true`; - const entryByIDRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?metadata=${metadata}&collection=${collectionUUID}&entryID=${entryID}`; + const entriesRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries`; + const entriesByValueRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?filter=test&exact=false`; + const entryByValueRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?filter=test&exact=true`; + const entryByIDRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?entryID=${entryID}`; const vocabularyOptions: VocabularyOptions = { name: vocabularyId, metadata: metadata, @@ -160,16 +181,19 @@ describe('VocabularyService', () => { } const pageInfo = new PageInfo(); const array = [vocabulary, hierarchicalVocabulary]; + const arrayEntries = [vocabularyEntry, vocabularyEntry2, vocabularyEntry3]; const childrenEntries = [vocabularyEntryChildDetail, vocabularyEntryChild2Detail]; const paginatedList = new PaginatedList(pageInfo, array); + const paginatedListEntries = new PaginatedList(pageInfo, arrayEntries); const childrenPaginatedList = new PaginatedList(pageInfo, childrenEntries); const vocabularyRD = createSuccessfulRemoteDataObject(vocabulary); + const vocabularyEntriesRD = createSuccessfulRemoteDataObject$(paginatedListEntries); const vocabularyEntryDetailParentRD = createSuccessfulRemoteDataObject(vocabularyEntryParentDetail); const vocabularyEntryChildrenRD = createSuccessfulRemoteDataObject(childrenPaginatedList); const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); - const getRequestEntry$ = (successful: boolean) => { + const getRequestEntries$ = (successful: boolean) => { return observableOf({ - response: { isSuccessful: successful, payload: vocabulary } as any + response: { isSuccessful: successful, payload: arrayEntries } as any } as RequestEntry) }; objectCache = {} as ObjectCacheService; @@ -312,8 +336,8 @@ describe('VocabularyService', () => { describe('', () => { beforeEach(() => { - requestService = getMockRequestService(getRequestEntry$(true)); - rdbService = getMockRemoteDataBuildService(); + requestService = getMockRequestService(getRequestEntries$(true)); + rdbService = getMockRemoteDataBuildService(undefined, vocabularyEntriesRD); spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); service = initTestService(); }); @@ -329,10 +353,10 @@ describe('VocabularyService', () => { }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { - service.getVocabularyEntries(vocabularyOptions, pageInfo); + scheduler.schedule(() => service.getVocabularyEntries(vocabularyOptions, pageInfo)); + scheduler.flush(); expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); - }); }); @@ -347,7 +371,8 @@ describe('VocabularyService', () => { }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { - service.getVocabularyEntriesByValue('test', false, vocabularyOptions, pageInfo); + scheduler.schedule(() => service.getVocabularyEntriesByValue('test', false, vocabularyOptions, pageInfo)); + scheduler.flush(); expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); @@ -365,7 +390,8 @@ describe('VocabularyService', () => { }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { - service.getVocabularyEntryByValue('test', vocabularyOptions); + scheduler.schedule(() => service.getVocabularyEntryByValue('test', vocabularyOptions)); + scheduler.flush(); expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); @@ -383,7 +409,8 @@ describe('VocabularyService', () => { }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { - service.getVocabularyEntryByID('test', vocabularyOptions); + scheduler.schedule(() => service.getVocabularyEntryByID('test', vocabularyOptions)); + scheduler.flush(); expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); @@ -495,8 +522,6 @@ describe('VocabularyService', () => { null, null, null, - null, - null, pageInfo.elementsPerPage, pageInfo.currentPage ); @@ -522,8 +547,6 @@ describe('VocabularyService', () => { null, null, null, - null, - null, pageInfo.elementsPerPage, pageInfo.currentPage ); diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index 1dac3b56a6..648f4744d3 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -150,8 +150,6 @@ export class VocabularyService { getVocabularyEntries(vocabularyOptions: VocabularyOptions, pageInfo: PageInfo): Observable>> { const options: VocabularyFindOptions = new VocabularyFindOptions( - vocabularyOptions.scope, - vocabularyOptions.metadata, null, null, null, @@ -179,8 +177,6 @@ export class VocabularyService { */ getVocabularyEntriesByValue(value: string, exact: boolean, vocabularyOptions: VocabularyOptions, pageInfo: PageInfo): Observable>> { const options: VocabularyFindOptions = new VocabularyFindOptions( - vocabularyOptions.scope, - vocabularyOptions.metadata, null, value, exact, @@ -229,8 +225,6 @@ export class VocabularyService { getVocabularyEntryByID(ID: string, vocabularyOptions: VocabularyOptions): Observable { const pageInfo = new PageInfo() const options: VocabularyFindOptions = new VocabularyFindOptions( - vocabularyOptions.scope, - vocabularyOptions.metadata, null, null, null, @@ -335,8 +329,6 @@ export class VocabularyService { null, null, null, - null, - null, pageInfo.elementsPerPage, pageInfo.currentPage ); @@ -358,8 +350,6 @@ export class VocabularyService { null, null, null, - null, - null, pageInfo.elementsPerPage, pageInfo.currentPage ); From c7ef818454c9c6cf671c3f5b310619ff75365b44 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 16 Jul 2020 14:35:57 +0200 Subject: [PATCH 031/126] [CST-3088] fix ProcessFormComponent test --- .../form/process-form.component.spec.ts | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/app/process-page/form/process-form.component.spec.ts b/src/app/process-page/form/process-form.component.spec.ts index d32405d8bf..97423c8617 100644 --- a/src/app/process-page/form/process-form.component.spec.ts +++ b/src/app/process-page/form/process-form.component.spec.ts @@ -1,4 +1,4 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; @@ -14,6 +14,8 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { RequestService } from '../../core/data/request.service'; import { Router } from '@angular/router'; +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'jasmine-marbles'; describe('ProcessFormComponent', () => { let component: ProcessFormComponent; @@ -21,6 +23,9 @@ describe('ProcessFormComponent', () => { let scriptService; let parameterValues; let script; + let scheduler: TestScheduler; + let requestService: RequestService; + let router: Router; function init() { const param1 = new ScriptParameter(); @@ -41,10 +46,18 @@ describe('ProcessFormComponent', () => { } }) } - ) + ); + + requestService = jasmine.createSpyObj('requestService', { + removeByHrefSubstring: jasmine.createSpy('removeByHrefSubstring') + }); + + router = jasmine.createSpyObj('requestService', { + navigateByUrl: jasmine.createSpy('navigateByUrl') + }); } - beforeEach(async(() => { + beforeEach(() => { init(); TestBed.configureTestingModule({ imports: [ @@ -59,15 +72,16 @@ describe('ProcessFormComponent', () => { providers: [ { provide: ScriptDataService, useValue: scriptService }, { provide: NotificationsService, useClass: NotificationsServiceStub }, - { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeBySubstring']) }, - { provide: Router, useValue: {} }, + { provide: RequestService, useValue: requestService }, + { provide: Router, useValue: router }, ], schemas: [NO_ERRORS_SCHEMA] }) .compileComponents(); - })); + }); beforeEach(() => { + scheduler = getTestScheduler(); fixture = TestBed.createComponent(ProcessFormComponent); component = fixture.componentInstance; component.parameters = parameterValues; @@ -75,13 +89,15 @@ describe('ProcessFormComponent', () => { fixture.detectChanges(); }); - it('should create', (done) => { + it('should create', () => { expect(component).toBeTruthy(); - done(); }); - it('should call invoke on the scriptService on submit', () => { - component.submitForm({ controls: {} } as any); + it('should call invoke on the scriptService on submit', (done) => { + scheduler.schedule(() => component.submitForm({ controls: {} } as any)); + scheduler.flush(); + expect(scriptService.invoke).toHaveBeenCalled(); + done(); }); }); From 607318f6c5cfdb8ef1e517b968264c33648b74a2 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Mon, 13 Jul 2020 15:42:52 +0200 Subject: [PATCH 032/126] Fix CommunityDataService's topLinkPath --- src/app/core/data/community-data.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 123c3eccd1..474bdef44a 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -24,7 +24,7 @@ import { RequestService } from './request.service'; @dataService(COMMUNITY) export class CommunityDataService extends ComColDataService { protected linkPath = 'communities'; - protected topLinkPath = 'communities/search/top'; + protected topLinkPath = 'search/top'; protected cds = this; constructor( From 6fecdfadb2521a1d6269170579ec3025eeff4090 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Tue, 21 Jul 2020 18:53:09 +0200 Subject: [PATCH 033/126] Fixed issue that redirect always to home page after checking authentication on CSR --- src/app/core/auth/auth.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 85e5eebb9e..7f61bec9f3 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -423,6 +423,7 @@ export class AuthService { } else { // If redirectUrl is empty use history. this.routeService.getHistory().pipe( + filter((history) => history.length > 0), take(1) ).subscribe((history) => { let redirUrl; From 47ab023e7a090523246f76cc83b90d9aeb003caa Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Wed, 22 Jul 2020 17:03:52 +0200 Subject: [PATCH 034/126] fixed VocabularyTreeviewComponent selector --- .../vocabulary-treeview/vocabulary-treeview.component.spec.ts | 2 +- .../shared/vocabulary-treeview/vocabulary-treeview.component.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.spec.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.spec.ts index 9af00b6be7..16ee2a55dd 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.spec.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.spec.ts @@ -79,7 +79,7 @@ describe('VocabularyTreeviewComponent test suite', () => { // synchronous beforeEach beforeEach(() => { const html = ` - `; + `; testFixture = createTestComponent(html, TestComponent) as ComponentFixture; testComp = testFixture.componentInstance; diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts index 8c7543849d..6de1a11e00 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts @@ -22,7 +22,7 @@ import { VocabularyEntry } from '../../core/submission/vocabularies/models/vocab * Component that show a hierarchical vocabulary in a tree view */ @Component({ - selector: 'ds-authority-treeview', + selector: 'ds-vocabulary-treeview', templateUrl: './vocabulary-treeview.component.html', styleUrls: ['./vocabulary-treeview.component.scss'] }) From d88a863a0d8b1041f01bcf0b9d698b3440e21529 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Wed, 22 Jul 2020 17:40:03 +0200 Subject: [PATCH 035/126] fixed issue that didn't allow to select tree node after a search --- .../vocabulary-treeview-node.model.ts | 1 + .../vocabulary-treeview.component.ts | 5 ++- .../vocabulary-treeview.service.spec.ts | 33 ++++++++++++----- .../vocabulary-treeview.service.ts | 36 ++++++++++--------- 4 files changed, 48 insertions(+), 27 deletions(-) diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview-node.model.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview-node.model.ts index 60473c77ca..2ed21cbdf0 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview-node.model.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview-node.model.ts @@ -34,6 +34,7 @@ export class TreeviewFlatNode { constructor(public item: VocabularyEntryDetail, public level = 1, public expandable = false, + public childrenLoaded = false, public pageInfo: PageInfo = new PageInfo(), public loadMoreParentItem: VocabularyEntryDetail | null = null, public isSearchNode = false, diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts index 6de1a11e00..cbd9c25a9b 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts @@ -143,6 +143,7 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit { node.item, level, node.hasChildren, + (node.hasChildren && isNotEmpty(node.children)), node.pageInfo, node.loadMoreParentItem, node.isSearchNode, @@ -150,7 +151,9 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit { ); this.nodeMap.set(node.item.id, newNode); - if ((((level + 1) < this.preloadLevel) && newNode.expandable) || newNode.isSearchNode || newNode.isInInitValueHierarchy) { + if ((((level + 1) < this.preloadLevel) && newNode.childrenLoaded) + || (newNode.isSearchNode && newNode.childrenLoaded) + || newNode.isInInitValueHierarchy) { if (!newNode.isSearchNode) { this.loadChildren(newNode); } diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts index 2f03549898..944ca0bfb0 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts @@ -13,6 +13,7 @@ import { PageInfo } from '../../core/shared/page-info.model'; import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; import { PaginatedList } from '../../core/data/paginated-list'; import { createSuccessfulRemoteDataObject } from '../remote-data.utils'; +import { VocabularyEntry } from '../../core/submission/vocabularies/models/vocabulary-entry.model'; describe('VocabularyTreeviewService test suite', () => { @@ -34,6 +35,7 @@ describe('VocabularyTreeviewService test suite', () => { let childNode: TreeviewNode; let child2: VocabularyEntryDetail; let childNode2: TreeviewNode; + let childEntry3: VocabularyEntry; let child3: VocabularyEntryDetail; let childNode3: TreeviewNode; let searchItemNode: TreeviewNode; @@ -71,37 +73,46 @@ describe('VocabularyTreeviewService test suite', () => { }); loadMoreNode = new TreeviewNode(LOAD_MORE_NODE, false, new PageInfo(), item); loadMoreRootNode = new TreeviewNode(LOAD_MORE_ROOT_NODE, false, new PageInfo(), null); - loadMoreRootFlatNode = new TreeviewFlatNode(LOAD_MORE_ROOT_NODE, 1, false, new PageInfo(), null); + loadMoreRootFlatNode = new TreeviewFlatNode(LOAD_MORE_ROOT_NODE, 1, false, false, new PageInfo(), null); item = new VocabularyEntryDetail(); - item.id = item.value = item.display = 'root1'; + item.id = 'vocabularyTest:root1'; + item.value = item.display = 'root1'; item.otherInformation = { hasChildren: 'true', id: 'root1' }; itemNode = new TreeviewNode(item, true, pageInfo); searchItemNode = new TreeviewNode(item, true, new PageInfo(), null, true); item2 = new VocabularyEntryDetail(); - item2.id = item2.value = item2.display = 'root2'; + item2.id = 'vocabularyTest:root2'; + item2.value = item2.display = 'root2'; item2.otherInformation = { id: 'root2' }; itemNode2 = new TreeviewNode(item2, false, pageInfo); item3 = new VocabularyEntryDetail(); - item3.id = item3.value = item3.display = 'root3'; + item3.id = 'vocabularyTest:root3'; + item3.value = item3.display = 'root3'; item3.otherInformation = { id: 'root3' }; itemNode3 = new TreeviewNode(item3, false, pageInfo); child = new VocabularyEntryDetail(); - child.id = child.value = child.display = 'root1-child1'; + child.id = 'vocabularyTest:root1-child1'; + child.value = child.display = 'root1-child1'; child.otherInformation = { parent: 'root1', hasChildren: 'true', id: 'root1-child1' }; childNode = new TreeviewNode(child); searchChildNode = new TreeviewNode(child, true, new PageInfo(), item, true); + childEntry3 = new VocabularyEntry(); + childEntry3.value = childEntry3.display = 'root1-child1-child1'; + childEntry3.otherInformation = { parent: 'root1-child1', id: 'root1-child1-child1' }; child3 = new VocabularyEntryDetail(); - child3.id = child3.value = child3.display = 'root1-child1-child1'; + child3.id = 'vocabularyTest:root1-child1-child1'; + child3.value = child3.display = 'root1-child1-child1'; child3.otherInformation = { parent: 'root1-child1', id: 'root1-child1-child1' }; childNode3 = new TreeviewNode(child3); searchChildNode3 = new TreeviewNode(child3, false, new PageInfo(), child, true); child2 = new VocabularyEntryDetail(); - child2.id = child2.value = child2.display = 'root1-child2'; + child2.id = 'vocabularyTest:root1-child2'; + child2.value = child2.display = 'root1-child2'; child2.otherInformation = { parent: 'root1', id: 'root1-child2' }; childNode2 = new TreeviewNode(child2, true); initValueChildNode2 = new TreeviewNode(child2, false, new PageInfo(), item, false, true); @@ -184,7 +195,6 @@ describe('VocabularyTreeviewService test suite', () => { expect(serviceAsAny.vocabularyName).toEqual(vocabularyOptions.name); expect(serviceAsAny.pageInfo).toEqual(pageInfo); - console.log(serviceAsAny.dataChange.value[0].pageInfo, itemNode.pageInfo); expect(serviceAsAny.dataChange.value).toEqual([itemNode, itemNode2, itemNode3]); }); @@ -290,7 +300,11 @@ describe('VocabularyTreeviewService test suite', () => { currentPage: 1 }); serviceAsAny.vocabularyService.getVocabularyEntriesByValue.and.returnValue(hot('-a', { - a: createSuccessfulRemoteDataObject(new PaginatedList(pageInfo, [child3])) + a: createSuccessfulRemoteDataObject(new PaginatedList(pageInfo, [childEntry3])) + })); + + serviceAsAny.vocabularyService.findEntryDetailByValue.and.returnValue(hot('-a', { + a: createSuccessfulRemoteDataObject(child3) })); serviceAsAny.vocabularyService.getEntryDetailParent.and.returnValues( @@ -305,6 +319,7 @@ describe('VocabularyTreeviewService test suite', () => { scheduler.schedule(() => service.searchByQuery(vocabularyOptions)); scheduler.flush(); + searchChildNode.childrenChange.next([searchChildNode3]); searchItemNode.childrenChange.next([searchChildNode]); expect(serviceAsAny.dataChange.value.length).toEqual(1); diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts index 836ce7672a..698f5bb0d2 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts @@ -134,13 +134,13 @@ export class VocabularyTreeviewService { * @param onlyFirstTime */ loadMore(item: VocabularyEntryDetail, onlyFirstTime = false) { - if (!this.nodeMap.has(item.id)) { + if (!this.nodeMap.has(item.otherInformation.id)) { return; } - const parent: TreeviewNode = this.nodeMap.get(item.id)!; - const children = this.nodeMap.get(item.id)!.children || []; + const parent: TreeviewNode = this.nodeMap.get(item.otherInformation.id)!; + const children = this.nodeMap.get(item.otherInformation.id)!.children || []; children.pop(); - this.getChildrenNodesByParent(item.id, parent.pageInfo).subscribe((list: PaginatedList) => { + this.getChildrenNodesByParent(item.otherInformation.id, parent.pageInfo).subscribe((list: PaginatedList) => { if (onlyFirstTime && parent.children!.length > 0) { return; @@ -187,9 +187,14 @@ export class VocabularyTreeviewService { this.vocabularyService.getVocabularyEntriesByValue(query, false, this.vocabularyOptions, new PageInfo()).pipe( getFirstSucceededRemoteListPayload(), flatMap((result: VocabularyEntry[]) => (result.length > 0) ? result : observableOf(null)), - flatMap((entry: VocabularyEntry) => this.getNodeHierarchy(entry)), + flatMap((entry: VocabularyEntry) => + this.vocabularyService.findEntryDetailByValue(entry.otherInformation.id, this.vocabularyName).pipe( + getFirstSucceededRemoteDataPayload() + ) + ), + flatMap((entry: VocabularyEntryDetail) => this.getNodeHierarchy(entry)), scan((acc: TreeviewNode[], value: TreeviewNode) => { - if (isEmpty(value) || findIndex(acc, (node) => node.item.id === value.item.id) !== -1) { + if (isEmpty(value) || findIndex(acc, (node) => node.item.otherInformation.id === value.item.otherInformation.id) !== -1) { return acc; } else { return [...acc, value] @@ -217,24 +222,21 @@ export class VocabularyTreeviewService { /** * Generate a {@link TreeviewNode} object from vocabulary entry * - * @param entry The vocabulary entry + * @param entry The vocabulary entry detail * @param isSearchNode A Boolean representing if given entry is the result of a search * @param toStore A Boolean representing if the node created is to store or not * @return TreeviewNode */ - private _generateNode(entry: VocabularyEntry, isSearchNode = false, toStore = true): TreeviewNode { + private _generateNode(entry: VocabularyEntryDetail, isSearchNode = false, toStore = true): TreeviewNode { const entryId = entry.otherInformation.id; if (this.nodeMap.has(entryId)) { return this.nodeMap.get(entryId)!; } - const entryDetail: VocabularyEntryDetail = Object.assign(new VocabularyEntryDetail(), entry, { - id: entryId - }); const hasChildren = entry.hasOtherInformation() && (entry.otherInformation as any)!.hasChildren === 'true'; const pageInfo: PageInfo = this.pageInfo; const isInInitValueHierarchy = this.initValueHierarchy.includes(entryId); const result = new TreeviewNode( - entryDetail, + entry, hasChildren, pageInfo, null, @@ -328,7 +330,7 @@ export class VocabularyTreeviewService { * @param toStore A Boolean representing if the node created is to store or not * @return Observable */ - private getNodeHierarchy(item: VocabularyEntry, children?: TreeviewNode[], toStore = true): Observable { + private getNodeHierarchy(item: VocabularyEntryDetail, children?: TreeviewNode[], toStore = true): Observable { if (isEmpty(item)) { return observableOf(null); } @@ -337,7 +339,7 @@ export class VocabularyTreeviewService { if (isNotEmpty(children)) { const newChildren = children .filter((entry: TreeviewNode) => { - return findIndex(node.children, (nodeEntry) => nodeEntry.item.id === entry.item.id) === -1; + return findIndex(node.children, (nodeEntry) => nodeEntry.item.otherInformation.id === entry.item.otherInformation.id) === -1; }); newChildren.forEach((entry: TreeviewNode) => { entry.loadMoreParentItem = node.item @@ -346,7 +348,7 @@ export class VocabularyTreeviewService { } if (node.item.hasOtherInformation() && isNotEmpty(node.item.otherInformation.parent)) { - return this.getParentNode(node.item.id).pipe( + return this.getParentNode(node.item.otherInformation.id).pipe( flatMap((parentItem: VocabularyEntryDetail) => this.getNodeHierarchy(parentItem, [node], toStore)) ) } else { @@ -362,8 +364,8 @@ export class VocabularyTreeviewService { * @return string[] */ private getNodeHierarchyIds(node: TreeviewNode, hierarchyIds: string[] = []): string[] { - if (!hierarchyIds.includes(node.item.id)) { - hierarchyIds.push(node.item.id); + if (!hierarchyIds.includes(node.item.otherInformation.id)) { + hierarchyIds.push(node.item.otherInformation.id); } if (isNotEmpty(node.children)) { return this.getNodeHierarchyIds(node.children[0], hierarchyIds); From fb1b6c7a7abf0f79337d0fce64ad8752a6e92344 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Wed, 22 Jul 2020 17:49:36 +0200 Subject: [PATCH 036/126] Changed findEntryDetailByValue method name to a properly findEntryDetailById --- .../submission/vocabularies/vocabulary.service.spec.ts | 6 +++--- .../core/submission/vocabularies/vocabulary.service.ts | 8 ++++---- .../relation-group/dynamic-relation-group.components.ts | 2 +- .../vocabulary-treeview.service.spec.ts | 6 +++--- .../vocabulary-treeview/vocabulary-treeview.service.ts | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts index 682cc215e0..16e0eaf844 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -481,16 +481,16 @@ describe('VocabularyService', () => { }); }); - describe('findEntryDetailByValue', () => { + describe('findEntryDetailById', () => { it('should proxy the call to vocabularyDataService.findVocabularyById', () => { - scheduler.schedule(() => service.findEntryDetailByValue('testValue', hierarchicalVocabulary.id)); + scheduler.schedule(() => service.findEntryDetailById('testValue', hierarchicalVocabulary.id)); scheduler.flush(); const expectedId = `${hierarchicalVocabulary.id}:testValue` expect((service as any).vocabularyEntryDetailDataService.findById).toHaveBeenCalledWith(expectedId); }); it('should return a RemoteData for the object with the given id', () => { - const result = service.findEntryDetailByValue('testValue', hierarchicalVocabulary.id); + const result = service.findEntryDetailById('testValue', hierarchicalVocabulary.id); const expected = cold('a|', { a: vocabularyEntryDetailParentRD }); diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index 648f4744d3..5200d703b9 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -283,15 +283,15 @@ export class VocabularyService { /** * Returns an observable of {@link RemoteData} of a {@link VocabularyEntryDetail}, based on its ID, with a list of {@link FollowLinkConfig}, * to automatically resolve {@link HALLink}s of the object - * @param value The entry value for which to provide detailed information. + * @param id The entry id for which to provide detailed information. * @param name The name of {@link Vocabulary} to which the entry belongs * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved * @return {Observable>} * Return an observable that emits VocabularyEntryDetail object */ - findEntryDetailByValue(value: string, name: string, ...linksToFollow: Array>): Observable> { - const id = `${name}:${value}`; - return this.vocabularyEntryDetailDataService.findById(id, ...linksToFollow); + findEntryDetailById(id: string, name: string, ...linksToFollow: Array>): Observable> { + const findId = `${name}:${id}`; + return this.vocabularyEntryDetailDataService.findById(findId, ...linksToFollow); } /** diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts index 51cf69d560..5c2616dd34 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts @@ -233,7 +233,7 @@ export class DsDynamicRelationGroupComponent extends DynamicFormControlComponent if (isObject(valueObj[fieldName]) && valueObj[fieldName].hasAuthority() && isNotEmpty(valueObj[fieldName].authority)) { const fieldId = fieldName.replace(/\./g, '_'); const model = this.formBuilderService.findById(fieldId, this.formModel); - return$ = this.vocabularyService.findEntryDetailByValue( + return$ = this.vocabularyService.findEntryDetailById( valueObj[fieldName].authority, (model as any).vocabularyOptions.name ).pipe( diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts index 944ca0bfb0..f45478f82b 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts @@ -57,7 +57,7 @@ describe('VocabularyTreeviewService test suite', () => { const vocabularyServiceStub = jasmine.createSpyObj('VocabularyService', { getVocabularyEntriesByValue: jasmine.createSpy('getVocabularyEntriesByValue'), getEntryDetailParent: jasmine.createSpy('getEntryDetailParent'), - findEntryDetailByValue: jasmine.createSpy('findEntryDetailByValue'), + findEntryDetailById: jasmine.createSpy('findEntryDetailById'), searchTopEntries: jasmine.createSpy('searchTopEntries'), getEntryDetailChildren: jasmine.createSpy('getEntryDetailChildren'), clearSearchTopRequests: jasmine.createSpy('clearSearchTopRequests') @@ -202,7 +202,7 @@ describe('VocabularyTreeviewService test suite', () => { serviceAsAny.vocabularyService.searchTopEntries.and.returnValue(hot('-c', { a: createSuccessfulRemoteDataObject(new PaginatedList(pageInfo, [item, item2, item3])) })); - serviceAsAny.vocabularyService.findEntryDetailByValue.and.returnValue( + serviceAsAny.vocabularyService.findEntryDetailById.and.returnValue( hot('-a', { a: createSuccessfulRemoteDataObject(child2) }) @@ -303,7 +303,7 @@ describe('VocabularyTreeviewService test suite', () => { a: createSuccessfulRemoteDataObject(new PaginatedList(pageInfo, [childEntry3])) })); - serviceAsAny.vocabularyService.findEntryDetailByValue.and.returnValue(hot('-a', { + serviceAsAny.vocabularyService.findEntryDetailById.and.returnValue(hot('-a', { a: createSuccessfulRemoteDataObject(child3) })); diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts index 698f5bb0d2..dd58a6aede 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts @@ -188,7 +188,7 @@ export class VocabularyTreeviewService { getFirstSucceededRemoteListPayload(), flatMap((result: VocabularyEntry[]) => (result.length > 0) ? result : observableOf(null)), flatMap((entry: VocabularyEntry) => - this.vocabularyService.findEntryDetailByValue(entry.otherInformation.id, this.vocabularyName).pipe( + this.vocabularyService.findEntryDetailById(entry.otherInformation.id, this.vocabularyName).pipe( getFirstSucceededRemoteDataPayload() ) ), @@ -289,7 +289,7 @@ export class VocabularyTreeviewService { * @return Observable */ private getById(entryId: string): Observable { - return this.vocabularyService.findEntryDetailByValue(entryId, this.vocabularyName).pipe( + return this.vocabularyService.findEntryDetailById(entryId, this.vocabularyName).pipe( getFirstSucceededRemoteDataPayload() ); } From adb51bc06c6ea6ee044a6fd7619f0ee996ebcc11 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 30 Jul 2020 11:09:16 +0200 Subject: [PATCH 037/126] 71764: authorization-data-service remove eperson param unless provided --- .../data/feature-authorization/authorization-data.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/core/data/feature-authorization/authorization-data.service.ts b/src/app/core/data/feature-authorization/authorization-data.service.ts index 2d32b26efa..e8ee7a9d65 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.ts @@ -90,7 +90,6 @@ export class AuthorizationDataService extends DataService { searchByObject(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { return observableOf(new AuthorizationSearchParams(objectUrl, ePersonUuid, featureId)).pipe( addSiteObjectUrlIfEmpty(this.siteService), - addAuthenticatedUserUuidIfEmpty(this.authService), switchMap((params: AuthorizationSearchParams) => { return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), ...linksToFollow); }) From 23354b45c189e61bd688e6ecae43c29af6635a51 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 30 Jul 2020 11:24:58 +0200 Subject: [PATCH 038/126] 71764: Test changes --- .../authorization-data.service.spec.ts | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts index 29db1a086b..7db7c27c29 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts @@ -63,33 +63,33 @@ describe('AuthorizationDataService', () => { return Object.assign(new FindListOptions(), { searchParams }); } - describe('when no arguments are provided and a user is authenticated', () => { + describe('when no arguments are provided', () => { beforeEach(() => { service.searchByObject().subscribe(); }); - it('should call searchBy with the site\'s url and authenticated user\'s uuid', () => { - expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid)); + it('should call searchBy with the site\'s url', () => { + expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self)); }); }); - describe('when no arguments except for a feature are provided and a user is authenticated', () => { + describe('when no arguments except for a feature are provided', () => { beforeEach(() => { service.searchByObject(FeatureID.LoginOnBehalfOf).subscribe(); }); - it('should call searchBy with the site\'s url, authenticated user\'s uuid and the feature', () => { - expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid, FeatureID.LoginOnBehalfOf)); + it('should call searchBy with the site\'s url and the feature', () => { + expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, null, FeatureID.LoginOnBehalfOf)); }); }); - describe('when a feature and object url are provided, but no user uuid and a user is authenticated', () => { + describe('when a feature and object url are provided', () => { beforeEach(() => { service.searchByObject(FeatureID.LoginOnBehalfOf, objectUrl).subscribe(); }); - it('should call searchBy with the object\'s url, authenticated user\'s uuid and the feature', () => { - expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePerson.uuid, FeatureID.LoginOnBehalfOf)); + it('should call searchBy with the object\'s url and the feature', () => { + expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, null, FeatureID.LoginOnBehalfOf)); }); }); @@ -102,17 +102,6 @@ describe('AuthorizationDataService', () => { expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePersonUuid, FeatureID.LoginOnBehalfOf)); }); }); - - describe('when no arguments are provided and no user is authenticated', () => { - beforeEach(() => { - spyOn(authService, 'isAuthenticated').and.returnValue(observableOf(false)); - service.searchByObject().subscribe(); - }); - - it('should call searchBy with the site\'s url', () => { - expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self)); - }); - }); }); describe('isAuthorized', () => { From 7e7ce7b06e0e8b7347ee146f31d6a33e302936ab Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 30 Jul 2020 18:37:45 +0200 Subject: [PATCH 039/126] Added check for VocabularyEntry when initializing a controlled vocabulary component's init model --- .../models/dynamic-vocabulary.component.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts index 5e6acf8581..d3263f1584 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts @@ -70,6 +70,13 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom return this.model.value as any; } })); + } else if (isNotEmpty(this.model.value) && (this.model.value instanceof VocabularyEntry)) { + initValue$ = observableOf(Object.assign(new FormFieldMetadataValueObject(), this.model.value, { + value: this.model.value.value, + authority: this.model.value.authority, + display: this.model.value.display, + otherInformation: this.model.value.otherInformation || null + })); } else { initValue$ = observableOf(new FormFieldMetadataValueObject(this.model.value)); } From 7dae82cbbd54034510db2dcd79b6d15210ad49ad Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 30 Jul 2020 18:41:04 +0200 Subject: [PATCH 040/126] added virtualValue property to FormFieldMetadataValueObject --- .../models/form-field-metadata-value.model.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/app/shared/form/builder/models/form-field-metadata-value.model.ts b/src/app/shared/form/builder/models/form-field-metadata-value.model.ts index 2b451a7217..752db8bd5c 100644 --- a/src/app/shared/form/builder/models/form-field-metadata-value.model.ts +++ b/src/app/shared/form/builder/models/form-field-metadata-value.model.ts @@ -51,26 +51,53 @@ export class FormFieldMetadataValueObject implements MetadataValueInterface { this.otherInformation = otherInformation; } + /** + * Returns true if this this object has an authority value + */ hasAuthority(): boolean { return isNotEmpty(this.authority); } + /** + * Returns true if this this object has a value + */ hasValue(): boolean { return isNotEmpty(this.value); } + /** + * Returns true if this this object has otherInformation property with value + */ hasOtherInformation(): boolean { return isNotEmpty(this.otherInformation); } + /** + * Returns true if this object value contains a placeholder + */ hasPlaceholder() { return this.hasValue() && this.value === PLACEHOLDER_PARENT_METADATA; } + /** + * Returns true if this Metadatum's authority key starts with 'virtual::' + */ get isVirtual(): boolean { return hasValue(this.authority) && this.authority.startsWith(VIRTUAL_METADATA_PREFIX); } + /** + * If this is a virtual Metadatum, it returns everything in the authority key after 'virtual::'. + * Returns undefined otherwise. + */ + get virtualValue(): string { + if (this.isVirtual) { + return this.authority.substring(this.authority.indexOf(VIRTUAL_METADATA_PREFIX) + VIRTUAL_METADATA_PREFIX.length); + } else { + return undefined; + } + } + toString() { return this.display || this.value; } From 1a9d20161c0cf0d8f618e72e2c7c9d6f12d27356 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 30 Jul 2020 18:43:19 +0200 Subject: [PATCH 041/126] Fixed issue while preparing json patch value in case of array in the JsonPatchOperationsBuilder --- .../builder/json-patch-operations-builder.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/core/json-patch/builder/json-patch-operations-builder.ts b/src/app/core/json-patch/builder/json-patch-operations-builder.ts index 7ce0358ab2..3755821bf9 100644 --- a/src/app/core/json-patch/builder/json-patch-operations-builder.ts +++ b/src/app/core/json-patch/builder/json-patch-operations-builder.ts @@ -1,9 +1,14 @@ import { Store } from '@ngrx/store'; import { CoreState } from '../../core.reducers'; -import { NewPatchAddOperationAction, NewPatchMoveOperationAction, NewPatchRemoveOperationAction, NewPatchReplaceOperationAction } from '../json-patch-operations.actions'; +import { + NewPatchAddOperationAction, + NewPatchMoveOperationAction, + NewPatchRemoveOperationAction, + NewPatchReplaceOperationAction +} from '../json-patch-operations.actions'; import { JsonPatchOperationPathObject } from './json-patch-operation-path-combiner'; import { Injectable } from '@angular/core'; -import { hasNoValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { dateToISOFormat } from '../../../shared/date.util'; import { VocabularyEntry } from '../../submission/vocabularies/models/vocabulary-entry.model'; import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; @@ -96,7 +101,7 @@ export class JsonPatchOperationsBuilder { protected prepareValue(value: any, plain: boolean, first: boolean) { let operationValue: any = null; - if (isNotEmpty(value)) { + if (hasValue(value)) { if (plain) { operationValue = value; } else { From 85506238b36a9eaaba3d5804e40aa2e2cbc9f23d Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 30 Jul 2020 18:46:17 +0200 Subject: [PATCH 042/126] Fixed issue with form dropdown field which didn't close menu after entry selection --- .../dynamic-scrollable-dropdown.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html index 7a35287a99..f40d58bb0e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html @@ -26,7 +26,7 @@ [scrollWindow]="false"> -

{{'form.loading' | translate}}

From 090fb94c4da57df2dbe415f520d5cd0e4ae32f85 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 30 Jul 2020 18:50:47 +0200 Subject: [PATCH 043/126] Normalized submission section data when retrieving them from item object --- ...ynamic-form-control-container.component.ts | 5 +- .../models/ds-dynamic-input.model.ts | 12 ++- .../objects/submission-objects.actions.ts | 9 ++- .../submission-objects.effects.spec.ts | 2 +- .../objects/submission-objects.effects.ts | 74 ++++++++++++++----- .../objects/submission-objects.reducer.ts | 2 +- 6 files changed, 76 insertions(+), 28 deletions(-) diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index edc6efc5f9..7873f3a86e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -116,6 +116,7 @@ import { followLink } from '../../../utils/follow-link-config.model'; import { paginatedRelationsToItems } from '../../../../+item-page/simple/item-types/shared/item-relationships-utils'; import { RelationshipOptions } from '../models/relationship-options.model'; import { FormBuilderService } from '../form-builder.service'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type | null { switch (model.type) { @@ -303,9 +304,9 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo } if (hasValue(this.model.metadataValue)) { - this.value = Object.assign(new MetadataValue(), this.model.metadataValue); + this.value = Object.assign(new FormFieldMetadataValueObject(), this.model.metadataValue); } else { - this.value = Object.assign(new MetadataValue(), this.model.value); + this.value = Object.assign(new FormFieldMetadataValueObject(), this.model.value); } if (hasValue(this.value) && this.value.isVirtual) { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts index c91a0aea54..290e29dc65 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts @@ -1,4 +1,9 @@ -import { DynamicFormControlLayout, DynamicInputModel, DynamicInputModelConfig, serializable } from '@ng-dynamic-forms/core'; +import { + DynamicFormControlLayout, + DynamicInputModel, + DynamicInputModelConfig, + serializable +} from '@ng-dynamic-forms/core'; import { Subject } from 'rxjs'; import { LanguageCode } from '../../models/form-field-language-value.model'; @@ -6,7 +11,6 @@ import { VocabularyOptions } from '../../../../../core/submission/vocabularies/m import { hasValue } from '../../../../empty.util'; import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; import { RelationshipOptions } from '../../models/relationship-options.model'; -import { MetadataValue } from '../../../../../core/shared/metadata.models'; export interface DsDynamicInputModelConfig extends DynamicInputModelConfig { vocabularyOptions?: VocabularyOptions; @@ -19,7 +23,7 @@ export interface DsDynamicInputModelConfig extends DynamicInputModelConfig { metadataFields: string[]; submissionId: string; hasSelectableMetadata: boolean; - metadataValue?: MetadataValue; + metadataValue?: FormFieldMetadataValueObject; } @@ -34,7 +38,7 @@ export class DsDynamicInputModel extends DynamicInputModel { @serializable() metadataFields: string[]; @serializable() submissionId: string; @serializable() hasSelectableMetadata: boolean; - @serializable() metadataValue: MetadataValue; + @serializable() metadataValue: FormFieldMetadataValueObject; constructor(config: DsDynamicInputModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); diff --git a/src/app/submission/objects/submission-objects.actions.ts b/src/app/submission/objects/submission-objects.actions.ts index 1e3e44aba9..73c070846c 100644 --- a/src/app/submission/objects/submission-objects.actions.ts +++ b/src/app/submission/objects/submission-objects.actions.ts @@ -42,7 +42,8 @@ export const SubmissionObjectActionTypes = { DISABLE_SECTION: type('dspace/submission/DISABLE_SECTION'), SECTION_STATUS_CHANGE: type('dspace/submission/SECTION_STATUS_CHANGE'), SECTION_LOADING_STATUS_CHANGE: type('dspace/submission/SECTION_LOADING_STATUS_CHANGE'), - UPLOAD_SECTION_DATA: type('dspace/submission/UPLOAD_SECTION_DATA'), + UPDATE_SECTION_DATA: type('dspace/submission/UPDATE_SECTION_DATA'), + UPDATE_SECTION_DATA_SUCCESS: type('dspace/submission/UPDATE_SECTION_DATA_SUCCESS'), SAVE_AND_DEPOSIT_SUBMISSION: type('dspace/submission/SAVE_AND_DEPOSIT_SUBMISSION'), DEPOSIT_SUBMISSION: type('dspace/submission/DEPOSIT_SUBMISSION'), DEPOSIT_SUBMISSION_SUCCESS: type('dspace/submission/DEPOSIT_SUBMISSION_SUCCESS'), @@ -199,7 +200,7 @@ export class DisableSectionAction implements Action { } export class UpdateSectionDataAction implements Action { - type = SubmissionObjectActionTypes.UPLOAD_SECTION_DATA; + type = SubmissionObjectActionTypes.UPDATE_SECTION_DATA; payload: { submissionId: string; sectionId: string; @@ -227,6 +228,10 @@ export class UpdateSectionDataAction implements Action { } } +export class UpdateSectionDataSuccessAction implements Action { + type = SubmissionObjectActionTypes.UPDATE_SECTION_DATA_SUCCESS; +} + export class RemoveSectionErrorsAction implements Action { type = SubmissionObjectActionTypes.REMOVE_SECTION_ERRORS; payload: { diff --git a/src/app/submission/objects/submission-objects.effects.spec.ts b/src/app/submission/objects/submission-objects.effects.spec.ts index 6c2e9eefc6..c35968c0a0 100644 --- a/src/app/submission/objects/submission-objects.effects.spec.ts +++ b/src/app/submission/objects/submission-objects.effects.spec.ts @@ -276,7 +276,7 @@ describe('SubmissionObjectEffects test suite', () => { describe('saveSubmissionSuccess$', () => { - it('should return a UPLOAD_SECTION_DATA action for each updated section', () => { + it('should return a UPDATE_SECTION_DATA action for each updated section', () => { store.nextState({ submission: { objects: submissionState diff --git a/src/app/submission/objects/submission-objects.effects.ts b/src/app/submission/objects/submission-objects.effects.ts index 2dfed9ee47..ecda75759e 100644 --- a/src/app/submission/objects/submission-objects.effects.ts +++ b/src/app/submission/objects/submission-objects.effects.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { isEqual, union } from 'lodash'; +import { union } from 'lodash'; import { from as observableFrom, Observable, of as observableOf } from 'rxjs'; import { catchError, filter, map, mergeMap, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; @@ -40,7 +40,8 @@ import { SaveSubmissionSectionFormSuccessAction, SubmissionObjectAction, SubmissionObjectActionTypes, - UpdateSectionDataAction + UpdateSectionDataAction, + UpdateSectionDataSuccessAction } from './submission-objects.actions'; import { SubmissionObjectEntry, SubmissionSectionObject } from './submission-objects.reducer'; import { Item } from '../../core/shared/item.model'; @@ -48,6 +49,8 @@ import { RemoteData } from '../../core/data/remote-data'; import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; import { SubmissionObjectDataService } from '../../core/submission/submission-object-data.service'; import { followLink } from '../../shared/utils/follow-link-config.model'; +import { normalizeSectionData } from '../../core/submission/submission-response-parsing.service'; +import { difference } from '../../shared/object.util'; @Injectable() export class SubmissionObjectEffects { @@ -69,7 +72,7 @@ export class SubmissionObjectEffects { if (sectionDefinition.sectionType !== SectionsType.SubmissionForm) { sectionData = (isNotUndefined(action.payload.sections) && isNotUndefined(action.payload.sections[sectionId])) ? action.payload.sections[sectionId] : Object.create(null); } else { - sectionData = action.payload.item.metadata; + sectionData = normalizeSectionData(action.payload.item.metadata); } const sectionErrors = null; mappedActions.push( @@ -246,28 +249,37 @@ export class SubmissionObjectEffects { * Adds all metadata an item to the SubmissionForm sections of the submission */ @Effect() addAllMetadataToSectionData = this.actions$.pipe( - ofType(SubmissionObjectActionTypes.UPLOAD_SECTION_DATA), + ofType(SubmissionObjectActionTypes.UPDATE_SECTION_DATA), switchMap((action: UpdateSectionDataAction) => { return this.sectionService.getSectionState(action.payload.submissionId, action.payload.sectionId) .pipe(map((section: SubmissionSectionObject) => [action, section]), take(1)); }), filter(([action, section]: [UpdateSectionDataAction, SubmissionSectionObject]) => section.sectionType === SectionsType.SubmissionForm), switchMap(([action, section]: [UpdateSectionDataAction, SubmissionSectionObject]) => { - const submissionObject$ = this.submissionObjectService - .findById(action.payload.submissionId, followLink('item')).pipe( - getFirstSucceededRemoteDataPayload() + if (section.sectionType === SectionsType.SubmissionForm) { + const submissionObject$ = this.submissionObjectService + .findById(action.payload.submissionId, followLink('item')).pipe( + getFirstSucceededRemoteDataPayload() + ); + + const item$ = submissionObject$.pipe( + switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable>).pipe( + getFirstSucceededRemoteDataPayload(), + ))); + + return item$.pipe( + map((item: Item) => item.metadata), + map((metadata: any) => { + if (!this.isEqual(action.payload.data, normalizeSectionData(metadata))) { + return new UpdateSectionDataAction(action.payload.submissionId, action.payload.sectionId, normalizeSectionData(metadata), action.payload.errors) + } else { + return new UpdateSectionDataSuccessAction(); + } + }) ); - - const item$ = submissionObject$.pipe( - switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable>).pipe( - getFirstSucceededRemoteDataPayload(), - ))); - - return item$.pipe( - map((item: Item) => item.metadata), - filter((metadata) => !isEqual(action.payload.data, metadata)), - map((metadata: any) => new UpdateSectionDataAction(action.payload.submissionId, action.payload.sectionId, metadata, action.payload.errors)) - ); + } else { + return observableOf(new UpdateSectionDataSuccessAction()); + } }), ); @@ -382,4 +394,30 @@ export class SubmissionObjectEffects { return mappedActions; } + /** + * Check if the section data has been enriched by the server + * + * @param sectionData + * the section metadata retrieved from the server + * @param itemData + * the item data retrieved from the server + */ + isEqual(sectionData: any, itemData: any): boolean { + const diffResult = []; + + // compare current form data state with section data retrieved from store + const diffObj = difference(sectionData, itemData); + + // iterate over differences to check whether they are actually different + Object.keys(diffObj) + .forEach((key) => { + diffObj[key].forEach((value) => { + if (value.hasOwnProperty('value')) { + diffResult.push(value); + } + }); + }); + return isEmpty(diffResult); + } + } diff --git a/src/app/submission/objects/submission-objects.reducer.ts b/src/app/submission/objects/submission-objects.reducer.ts index e0aeefd7b6..098160c737 100644 --- a/src/app/submission/objects/submission-objects.reducer.ts +++ b/src/app/submission/objects/submission-objects.reducer.ts @@ -262,7 +262,7 @@ export function submissionObjectReducer(state = initialState, action: Submission return changeSectionState(state, action as EnableSectionAction, true); } - case SubmissionObjectActionTypes.UPLOAD_SECTION_DATA: { + case SubmissionObjectActionTypes.UPDATE_SECTION_DATA: { return updateSectionData(state, action as UpdateSectionDataAction); } From 9ced3530abc3fcbb541687e45f4bca678101b4d8 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 30 Jul 2020 19:13:59 +0200 Subject: [PATCH 044/126] Added inline comments --- src/app/submission/objects/submission-objects.effects.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/submission/objects/submission-objects.effects.ts b/src/app/submission/objects/submission-objects.effects.ts index ecda75759e..0f4158a760 100644 --- a/src/app/submission/objects/submission-objects.effects.ts +++ b/src/app/submission/objects/submission-objects.effects.ts @@ -72,6 +72,8 @@ export class SubmissionObjectEffects { if (sectionDefinition.sectionType !== SectionsType.SubmissionForm) { sectionData = (isNotUndefined(action.payload.sections) && isNotUndefined(action.payload.sections[sectionId])) ? action.payload.sections[sectionId] : Object.create(null); } else { + // Normalize item metadata before to init section + // TODO to review after https://github.com/DSpace/dspace-angular/issues/818 is resolved sectionData = normalizeSectionData(action.payload.item.metadata); } const sectionErrors = null; @@ -271,6 +273,8 @@ export class SubmissionObjectEffects { map((item: Item) => item.metadata), map((metadata: any) => { if (!this.isEqual(action.payload.data, normalizeSectionData(metadata))) { + // Normalize item metadata before to update section + // TODO to review after https://github.com/DSpace/dspace-angular/issues/818 is resolved return new UpdateSectionDataAction(action.payload.submissionId, action.payload.sectionId, normalizeSectionData(metadata), action.payload.errors) } else { return new UpdateSectionDataSuccessAction(); From c9519f44fbc93be7305c13a02532e4391cf54567 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 31 Jul 2020 13:30:23 +0200 Subject: [PATCH 045/126] Retrieve entries url from vocabulary's link --- src/app/core/data/data.service.ts | 2 +- .../vocabularies/vocabulary.service.spec.ts | 5 +++++ .../vocabularies/vocabulary.service.ts | 17 ++++++++++++----- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index bb1a65cc3a..3b4e5f7d42 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -112,7 +112,7 @@ export abstract class DataService implements UpdateDa * Return an observable that emits created HREF * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - protected buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: Array>): string { + public buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: Array>): string { let args = [...extraArgs]; if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts index ca7711637e..a172ae2a45 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -187,6 +187,7 @@ describe('VocabularyService', () => { const paginatedListEntries = new PaginatedList(pageInfo, arrayEntries); const childrenPaginatedList = new PaginatedList(pageInfo, childrenEntries); const vocabularyRD = createSuccessfulRemoteDataObject(vocabulary); + const vocabularyRD$ = createSuccessfulRemoteDataObject$(vocabulary); const vocabularyEntriesRD = createSuccessfulRemoteDataObject$(paginatedListEntries); const vocabularyEntryDetailParentRD = createSuccessfulRemoteDataObject(vocabularyEntryParentDetail); const vocabularyEntryChildrenRD = createSuccessfulRemoteDataObject(childrenPaginatedList); @@ -342,9 +343,11 @@ describe('VocabularyService', () => { rdbService = getMockRemoteDataBuildService(undefined, vocabularyEntriesRD); spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); service = initTestService(); + spyOn(service, 'findVocabularyById').and.returnValue(vocabularyRD$); }); describe('getVocabularyEntries', () => { + it('should configure a new VocabularyEntriesRequest', () => { const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entriesRequestURL); @@ -363,6 +366,7 @@ describe('VocabularyService', () => { }); describe('getVocabularyEntriesByValue', () => { + it('should configure a new VocabularyEntriesRequest', () => { const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entriesByValueRequestURL); @@ -382,6 +386,7 @@ describe('VocabularyService', () => { }); describe('getVocabularyEntryByValue', () => { + it('should configure a new VocabularyEntriesRequest', () => { const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entryByValueRequestURL); diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index 5200d703b9..90438e77e9 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -26,6 +26,7 @@ import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty. import { configureRequest, filterSuccessfulResponses, + getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteListPayload, getRequestFromRequestHref } from '../../shared/operators'; @@ -158,11 +159,13 @@ export class VocabularyService { pageInfo.currentPage ); - return this.vocabularyDataService.getFindAllHref(options, `${vocabularyOptions.name}/entries`).pipe( + return this.findVocabularyById(vocabularyOptions.name).pipe( + getFirstSucceededRemoteDataPayload(), + map((vocabulary: Vocabulary) => this.vocabularyDataService.buildHrefFromFindOptions(vocabulary._links.entries.href, options)), isNotEmptyOperator(), distinctUntilChanged(), getVocabularyEntriesFor(this.requestService, this.rdbService) - ); + ) } /** @@ -185,11 +188,13 @@ export class VocabularyService { pageInfo.currentPage ); - return this.vocabularyDataService.getFindAllHref(options, `${vocabularyOptions.name}/entries`).pipe( + return this.findVocabularyById(vocabularyOptions.name).pipe( + getFirstSucceededRemoteDataPayload(), + map((vocabulary: Vocabulary) => this.vocabularyDataService.buildHrefFromFindOptions(vocabulary._links.entries.href, options)), isNotEmptyOperator(), distinctUntilChanged(), getVocabularyEntriesFor(this.requestService, this.rdbService) - ); + ) } /** @@ -233,7 +238,9 @@ export class VocabularyService { pageInfo.currentPage ); - return this.vocabularyDataService.getFindAllHref(options, `${vocabularyOptions.name}/entries`).pipe( + return this.findVocabularyById(vocabularyOptions.name).pipe( + getFirstSucceededRemoteDataPayload(), + map((vocabulary: Vocabulary) => this.vocabularyDataService.buildHrefFromFindOptions(vocabulary._links.entries.href, options)), isNotEmptyOperator(), distinctUntilChanged(), getVocabularyEntriesFor(this.requestService, this.rdbService), From 2ed144bd1c04e29785abc3ed177af42a3d3f5dfa Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 31 Jul 2020 13:37:26 +0200 Subject: [PATCH 046/126] Fixed wrong links in mock object --- .../vocabulary-entries-response-parsing.service.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts index 9f28ccea17..7d4ec8be7c 100644 --- a/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts @@ -60,16 +60,16 @@ describe('VocabularyEntriesResponseParsingService', () => { }, _links: { first: { - href: 'https://rest.api/discover/browses/author/entries?page=0&size=5' + href: 'https://rest.api/discover/browses/author/entries/first?page=0&size=5' }, self: { href: 'https://rest.api/discover/browses/author/entries' }, next: { - href: 'https://rest.api/discover/browses/author/entries?page=1&size=5' + href: 'https://rest.api/discover/browses/author/entries/next?page=1&size=5' }, last: { - href: 'https://rest.api/discover/browses/author/entries?page=9&size=5' + href: 'https://rest.api/discover/browses/author/entries/last?page=9&size=5' } }, page: { From 79db49043f7573831fd391429fbfc170ca0c6f53 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 31 Jul 2020 14:16:20 +0200 Subject: [PATCH 047/126] Make metadata and scope properties as optional in the VocabularyOptions --- .../vocabularies/models/vocabulary-options.model.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts index c28d8504bd..bdbd19f85f 100644 --- a/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts +++ b/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts @@ -11,12 +11,12 @@ export class VocabularyOptions { /** * The metadata field name (e.g. "dc.type") for which the vocabulary is used: */ - metadata: string; + metadata?: string; /** * The uuid of the collection where the item is being submitted */ - scope: string; + scope?: string; /** * A boolean representing if value is closely related to a vocabulary entry or not @@ -24,8 +24,8 @@ export class VocabularyOptions { closed: boolean; constructor(name: string, - metadata: string, - scope: string, + metadata?: string, + scope?: string, closed: boolean = false) { this.name = name; this.metadata = metadata; From 0582bc56222863f55453b36d77a527e0f688e930 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 31 Jul 2020 15:17:51 +0200 Subject: [PATCH 048/126] Fixed issue when adding repeatable onebox field with vocabulary --- .../models/onebox/dynamic-onebox.component.ts | 4 +++- src/app/shared/form/form.component.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts index fa9fe2103a..0cc9f66ef7 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts @@ -46,7 +46,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple @ViewChild('instance', { static: false }) instance: NgbTypeahead; - pageInfo: PageInfo; + pageInfo: PageInfo = new PageInfo(); searching = false; searchFailed = false; hideSearchingWhenUnsubscribed$ = new Observable(() => () => this.changeSearchingStatus(false)); @@ -247,6 +247,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple this.getInitValueFromModel() .subscribe((formValue: FormFieldMetadataValueObject) => { this.currentValue = formValue; + this.cdr.detectChanges(); }); } else { if (isEmpty(value)) { @@ -258,6 +259,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple } this.currentValue = result; + this.cdr.detectChanges(); } } diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts index dee06c29b2..286f2673de 100644 --- a/src/app/shared/form/form.component.ts +++ b/src/app/shared/form/form.component.ts @@ -11,6 +11,7 @@ import { FormService } from './form.service'; import { FormEntry, FormError } from './form.reducer'; import { DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN } from './builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; import { QUALDROP_GROUP_SUFFIX } from './builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; +import { DYNAMIC_FORM_CONTROL_TYPE_ONEBOX } from './builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; const QUALDROP_GROUP_REGEX = new RegExp(`${QUALDROP_GROUP_SUFFIX}_\\d+$`); @@ -316,7 +317,7 @@ export class FormComponent implements OnDestroy, OnInit { // set that field to the new value const model = arrayContext.groups[arrayContext.groups.length - 1].group[0] as any; - if (model.type === DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN) { + if (model.type === DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN || model.type === DYNAMIC_FORM_CONTROL_TYPE_ONEBOX) { model.value = Object.values(value)[0]; } else if (this.formBuilderService.isQualdropGroup(model)) { const ctrl = formArrayControl.controls[formArrayControl.length - 1]; From 34da1dc0550d1fe80933c9124e515b7919282ff9 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 31 Jul 2020 15:18:41 +0200 Subject: [PATCH 049/126] Removed angular material dependency --- package.json | 1 - .../vocabulary-tree-flat-data-source.ts | 51 ++++++++ .../vocabulary-tree-flattener.ts | 111 ++++++++++++++++++ .../vocabulary-treeview.component.ts | 11 +- yarn.lock | 7 -- 5 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 src/app/shared/vocabulary-treeview/vocabulary-tree-flat-data-source.ts create mode 100644 src/app/shared/vocabulary-treeview/vocabulary-tree-flattener.ts diff --git a/package.json b/package.json index 69ca11b82d..c1e5b05010 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "@angular/compiler": "~8.2.14", "@angular/core": "~8.2.14", "@angular/forms": "~8.2.14", - "@angular/material": "8.2.3", "@angular/platform-browser": "~8.2.14", "@angular/platform-browser-dynamic": "~8.2.14", "@angular/platform-server": "~8.2.14", diff --git a/src/app/shared/vocabulary-treeview/vocabulary-tree-flat-data-source.ts b/src/app/shared/vocabulary-treeview/vocabulary-tree-flat-data-source.ts new file mode 100644 index 0000000000..2340d477b6 --- /dev/null +++ b/src/app/shared/vocabulary-treeview/vocabulary-tree-flat-data-source.ts @@ -0,0 +1,51 @@ +import { CollectionViewer, DataSource } from '@angular/cdk/collections'; +import { FlatTreeControl } from '@angular/cdk/tree'; + +import { BehaviorSubject, merge, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { VocabularyTreeFlattener } from './vocabulary-tree-flattener'; + +/** + * Data source for flat tree. + * The data source need to handle expansion/collapsion of the tree node and change the data feed + * to `MatTree`. + * The nested tree nodes of type `T` are flattened through `MatTreeFlattener`, and converted + * to type `F` for `MatTree` to consume. + */ +export class VocabularyTreeFlatDataSource extends DataSource { + _flattenedData = new BehaviorSubject([]); + + _expandedData = new BehaviorSubject([]); + + _data: BehaviorSubject; + get data() { return this._data.value; } + set data(value: T[]) { + this._data.next(value); + this._flattenedData.next(this._treeFlattener.flattenNodes(this.data)); + this._treeControl.dataNodes = this._flattenedData.value; + } + + constructor(private _treeControl: FlatTreeControl, + private _treeFlattener: VocabularyTreeFlattener, + initialData: T[] = []) { + super(); + this._data = new BehaviorSubject(initialData); + } + + connect(collectionViewer: CollectionViewer): Observable { + const changes = [ + collectionViewer.viewChange, + this._treeControl.expansionModel.onChange, + this._flattenedData + ]; + return merge(...changes).pipe(map(() => { + this._expandedData.next( + this._treeFlattener.expandFlattenedNodes(this._flattenedData.value, this._treeControl)); + return this._expandedData.value; + })); + } + + disconnect() { + // no op + } +} diff --git a/src/app/shared/vocabulary-treeview/vocabulary-tree-flattener.ts b/src/app/shared/vocabulary-treeview/vocabulary-tree-flattener.ts new file mode 100644 index 0000000000..419f48d222 --- /dev/null +++ b/src/app/shared/vocabulary-treeview/vocabulary-tree-flattener.ts @@ -0,0 +1,111 @@ +import { TreeControl } from '@angular/cdk/tree'; + +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; + +/** + * Tree flattener to convert a normal type of node to node with children & level information. + * Transform nested nodes of type `T` to flattened nodes of type `F`. + * + * For example, the input data of type `T` is nested, and contains its children data: + * SomeNode: { + * key: 'Fruits', + * children: [ + * NodeOne: { + * key: 'Apple', + * }, + * NodeTwo: { + * key: 'Pear', + * } + * ] + * } + * After flattener flatten the tree, the structure will become + * SomeNode: { + * key: 'Fruits', + * expandable: true, + * level: 1 + * }, + * NodeOne: { + * key: 'Apple', + * expandable: false, + * level: 2 + * }, + * NodeTwo: { + * key: 'Pear', + * expandable: false, + * level: 2 + * } + * and the output flattened type is `F` with additional information. + */ +export class VocabularyTreeFlattener { + + constructor(public transformFunction: (node: T, level: number) => F, + public getLevel: (node: F) => number, + public isExpandable: (node: F) => boolean, + public getChildren: (node: T) => + Observable | T[] | undefined | null) {} + + _flattenNode(node: T, level: number, + resultNodes: F[], parentMap: boolean[]): F[] { + const flatNode = this.transformFunction(node, level); + resultNodes.push(flatNode); + + if (this.isExpandable(flatNode)) { + const childrenNodes = this.getChildren(node); + if (childrenNodes) { + if (Array.isArray(childrenNodes)) { + this._flattenChildren(childrenNodes, level, resultNodes, parentMap); + } else { + childrenNodes.pipe(take(1)).subscribe((children) => { + this._flattenChildren(children, level, resultNodes, parentMap); + }); + } + } + } + return resultNodes; + } + + _flattenChildren(children: T[], level: number, + resultNodes: F[], parentMap: boolean[]): void { + children.forEach((child, index) => { + const childParentMap: boolean[] = parentMap.slice(); + childParentMap.push(index !== children.length - 1); + this._flattenNode(child, level + 1, resultNodes, childParentMap); + }); + } + + /** + * Flatten a list of node type T to flattened version of node F. + * Please note that type T may be nested, and the length of `structuredData` may be different + * from that of returned list `F[]`. + */ + flattenNodes(structuredData: T[]): F[] { + const resultNodes: F[] = []; + structuredData.forEach((node) => this._flattenNode(node, 0, resultNodes, [])); + return resultNodes; + } + + /** + * Expand flattened node with current expansion status. + * The returned list may have different length. + */ + expandFlattenedNodes(nodes: F[], treeControl: TreeControl): F[] { + const results: F[] = []; + const currentExpand: boolean[] = []; + currentExpand[0] = true; + + nodes.forEach((node) => { + let expand = true; + for (let i = 0; i <= this.getLevel(node); i++) { + expand = expand && currentExpand[i]; + } + if (expand) { + results.push(node); + } + if (this.isExpandable(node)) { + currentExpand[this.getLevel(node) + 1] = treeControl.isExpanded(node); + } + }); + return results; + } +} diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts index cbd9c25a9b..9eb4bb860c 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts @@ -1,6 +1,5 @@ import { FlatTreeControl } from '@angular/cdk/tree'; import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; -import { MatTreeFlatDataSource, MatTreeFlattener } from '@angular/material/tree'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { filter, find, startWith } from 'rxjs/operators'; @@ -17,6 +16,8 @@ import { LOAD_MORE, LOAD_MORE_ROOT, TreeviewFlatNode, TreeviewNode } from './voc import { VocabularyOptions } from '../../core/submission/vocabularies/models/vocabulary-options.model'; import { PageInfo } from '../../core/shared/page-info.model'; import { VocabularyEntry } from '../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { VocabularyTreeFlattener } from './vocabulary-tree-flattener'; +import { VocabularyTreeFlatDataSource } from './vocabulary-tree-flat-data-source'; /** * Component that show a hierarchical vocabulary in a tree view @@ -66,12 +67,12 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit { /** * Tree flattener object. Able to convert a normal type of node to node with children and level information. */ - treeFlattener: MatTreeFlattener; + treeFlattener: VocabularyTreeFlattener; /** * Flat tree data source */ - dataSource: MatTreeFlatDataSource; + dataSource: VocabularyTreeFlatDataSource; /** * The content of the search box used to search for a vocabulary entry @@ -113,12 +114,12 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit { private store: Store, private translate: TranslateService ) { - this.treeFlattener = new MatTreeFlattener(this.transformer, this.getLevel, + this.treeFlattener = new VocabularyTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren); this.treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); - this.dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); + this.dataSource = new VocabularyTreeFlatDataSource(this.treeControl, this.treeFlattener); } /** diff --git a/yarn.lock b/yarn.lock index 1dd1bb6673..3a36e692d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -211,13 +211,6 @@ resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-8.2.14.tgz#e18b27a6841577ce489ad31540150da5a444ca37" integrity sha512-7EhN9JJbAJcH2xCa+rIOmekjiEuB0qwPdHuD5qn/wwMfRzMZo+Db4hHbR9KHrLH6H82PTwYKye/LLpDaZqoHOA== -"@angular/material@8.2.3": - version "8.2.3" - resolved "https://registry.yarnpkg.com/@angular/material/-/material-8.2.3.tgz#16543e4e06a3fde2651a25cfe126e88e714ae105" - integrity sha512-SOczkIaqes+r+9XF/UUiokidfFKBpHkOPIaFK857sFD0FBNPvPEpOr5oHKCG3feERRwAFqHS7Wo2ohVEWypb5A== - dependencies: - tslib "^1.7.1" - "@angular/platform-browser-dynamic@~8.2.14": version "8.2.14" resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-8.2.14.tgz#4439a79fe10ec45170e6940a28835e9ff0918950" From 9b69aaaa073e2e01567819bfa40775e3697be738 Mon Sep 17 00:00:00 2001 From: Danilo Di Nuzzo Date: Mon, 3 Aug 2020 16:52:28 +0200 Subject: [PATCH 050/126] added missing unsubscription in onebox component --- .../models/onebox/dynamic-onebox.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts index 0cc9f66ef7..b587f9429e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts @@ -123,11 +123,11 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple distinctUntilChanged() ); - this.group.get(this.model.id).valueChanges.pipe( + this.subs.push(this.group.get(this.model.id).valueChanges.pipe( filter((value) => this.currentValue !== value)) .subscribe((value) => { this.setCurrentValue(this.model.value); - }); + })); } /** From 4a30e5d7d5621300da3db83daee064ccf76c1a87 Mon Sep 17 00:00:00 2001 From: Danilo Di Nuzzo Date: Tue, 4 Aug 2020 09:43:36 +0200 Subject: [PATCH 051/126] fix response for test submission/vocabularies --- .../vocabulary-entries-response-parsing.service.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts index 7d4ec8be7c..8e3b63df74 100644 --- a/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts @@ -60,16 +60,16 @@ describe('VocabularyEntriesResponseParsingService', () => { }, _links: { first: { - href: 'https://rest.api/discover/browses/author/entries/first?page=0&size=5' + href: 'https://rest.api/rest/api/submission/vocabularies/types/entries/first?page=0&size=5' }, self: { - href: 'https://rest.api/discover/browses/author/entries' + href: 'https://rest.api/rest/api/submission/vocabularies/types/entries' }, next: { - href: 'https://rest.api/discover/browses/author/entries/next?page=1&size=5' + href: 'https://rest.api/rest/api/submission/vocabularies/types/entries/next?page=1&size=5' }, last: { - href: 'https://rest.api/discover/browses/author/entries/last?page=9&size=5' + href: 'https://rest.api/rest/api/submission/vocabularies/types/entries/last?page=9&size=5' } }, page: { From 66cabcb342991c3cee542003d73eda370531c68f Mon Sep 17 00:00:00 2001 From: Danilo Di Nuzzo Date: Wed, 12 Aug 2020 14:01:29 +0200 Subject: [PATCH 052/126] refactory for metadata and collection params --- .../models/vocabulary-options.model.ts | 14 ------------- .../vocabularies/vocabulary.service.spec.ts | 20 ------------------- .../vocabularies/vocabulary.service.ts | 20 ------------------- ...c-form-control-container.component.spec.ts | 4 +--- .../list/dynamic-list.component.spec.ts | 8 ++------ .../lookup/dynamic-lookup.component.spec.ts | 16 ++++----------- .../onebox/dynamic-onebox.component.spec.ts | 4 +--- ...amic-scrollable-dropdown.component.spec.ts | 4 +--- .../models/tag/dynamic-tag.component.spec.ts | 4 +--- .../form/builder/form-builder.service.spec.ts | 4 +--- .../builder/parsers/dropdown-field-parser.ts | 2 +- .../form/builder/parsers/field-parser.ts | 4 +--- .../form/builder/parsers/list-field-parser.ts | 2 +- .../builder/parsers/lookup-field-parser.ts | 2 +- .../parsers/lookup-name-field-parser.ts | 2 +- .../builder/parsers/onebox-field-parser.ts | 2 +- .../form/builder/parsers/tag-field-parser.ts | 2 +- src/app/shared/mocks/form-models.mock.ts | 4 ++-- .../vocabulary-treeview.component.spec.ts | 4 ++-- .../vocabulary-treeview.service.spec.ts | 2 +- 20 files changed, 23 insertions(+), 101 deletions(-) diff --git a/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts index bdbd19f85f..fd103718e1 100644 --- a/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts +++ b/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts @@ -8,28 +8,14 @@ export class VocabularyOptions { */ name: string; - /** - * The metadata field name (e.g. "dc.type") for which the vocabulary is used: - */ - metadata?: string; - - /** - * The uuid of the collection where the item is being submitted - */ - scope?: string; - /** * A boolean representing if value is closely related to a vocabulary entry or not */ closed: boolean; constructor(name: string, - metadata?: string, - scope?: string, closed: boolean = false) { this.name = name; - this.metadata = metadata; - this.scope = scope; this.closed = closed; } } diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts index a172ae2a45..1119d4f6e6 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -175,8 +175,6 @@ describe('VocabularyService', () => { const entryByIDRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?entryID=${entryID}`; const vocabularyOptions: VocabularyOptions = { name: vocabularyId, - metadata: metadata, - scope: collectionUUID, closed: false } const pageInfo = new PageInfo(); @@ -316,24 +314,6 @@ describe('VocabularyService', () => { expect(result).toBeObservable(expected); }); }); - - describe('searchVocabularyByMetadataAndCollection', () => { - it('should proxy the call to vocabularyDataService.findVocabularyByHref', () => { - scheduler.schedule(() => service.searchVocabularyByMetadataAndCollection(vocabularyOptions).subscribe()); - scheduler.flush(); - - expect((service as any).vocabularyDataService.findByHref).toHaveBeenCalledWith(searchRequestURL); - }); - - it('should return a RemoteData for the search', () => { - const result = service.searchVocabularyByMetadataAndCollection(vocabularyOptions); - const expected = cold('a|', { - a: vocabularyRD - }); - expect(result).toBeObservable(expected); - }); - - }); }); describe('', () => { diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index 90438e77e9..595edfc861 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -255,26 +255,6 @@ export class VocabularyService { ); } - /** - * Return the controlled {@link Vocabulary} configured for the specified metadata and collection if any. - * - * @param vocabularyOptions The {@link VocabularyOptions} for the request - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - * @return {Observable>>} - * Return an observable that emits object list - */ - searchVocabularyByMetadataAndCollection(vocabularyOptions: VocabularyOptions, ...linksToFollow: Array>): Observable> { - const options: VocabularyFindOptions = new VocabularyFindOptions( - vocabularyOptions.scope, - vocabularyOptions.metadata - ); - - return this.vocabularyDataService.getSearchByHref(this.searchByMetadataAndCollectionMethod, options).pipe( - first((href: string) => hasValue(href)), - flatMap((href: string) => this.vocabularyDataService.findByHref(href)) - ) - } - /** * Returns an observable of {@link RemoteData} of a {@link VocabularyEntryDetail}, based on an href, with a list of {@link FollowLinkConfig}, * to automatically resolve {@link HALLink}s of the {@link VocabularyEntryDetail} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts index 81247dd266..0a4de25ebf 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts @@ -78,10 +78,8 @@ import { FormBuilderService } from '../form-builder.service'; describe('DsDynamicFormControlContainerComponent test suite', () => { const vocabularyOptions: VocabularyOptions = { - closed: false, - metadata: 'list', name: 'type_programme', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + closed: false }; const formModel = [ new DynamicCheckboxModel({ id: 'checkbox' }), diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts index b35ee10e8a..b041736e34 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts @@ -36,10 +36,8 @@ export const LIST_TEST_GROUP = new FormGroup({ export const LIST_CHECKBOX_TEST_MODEL_CONFIG = { vocabularyOptions: { - closed: false, - metadata: 'listCheckbox', name: 'type_programme', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + closed: false } as VocabularyOptions, disabled: false, id: 'listCheckbox', @@ -53,10 +51,8 @@ export const LIST_CHECKBOX_TEST_MODEL_CONFIG = { export const LIST_RADIO_TEST_MODEL_CONFIG = { vocabularyOptions: { - closed: false, - metadata: 'listRadio', name: 'type_programme', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + closed: false } as VocabularyOptions, disabled: false, id: 'listRadio', diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts index 30c40aebcc..f577e643cd 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts @@ -25,10 +25,8 @@ import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; let LOOKUP_TEST_MODEL_CONFIG: DynamicLookupModelConfig = { vocabularyOptions: { - closed: false, - metadata: 'lookup', name: 'RPAuthority', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + closed: false } as VocabularyOptions, disabled: false, errorMessages: { required: 'Required field.' }, @@ -49,10 +47,8 @@ let LOOKUP_TEST_MODEL_CONFIG: DynamicLookupModelConfig = { let LOOKUP_NAME_TEST_MODEL_CONFIG = { vocabularyOptions: { - closed: false, - metadata: 'lookup-name', name: 'RPAuthority', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + closed: false } as VocabularyOptions, disabled: false, errorMessages: { required: 'Required field.' }, @@ -80,10 +76,8 @@ describe('Dynamic Lookup component', () => { function init() { LOOKUP_TEST_MODEL_CONFIG = { vocabularyOptions: { - closed: false, - metadata: 'lookup', name: 'RPAuthority', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + closed: false } as VocabularyOptions, disabled: false, errorMessages: { required: 'Required field.' }, @@ -104,10 +98,8 @@ describe('Dynamic Lookup component', () => { LOOKUP_NAME_TEST_MODEL_CONFIG = { vocabularyOptions: { - closed: false, - metadata: 'lookup-name', name: 'RPAuthority', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + closed: false } as VocabularyOptions, disabled: false, errorMessages: { required: 'Required field.' }, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts index e94d3a208c..7a18bcc6e4 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts @@ -50,9 +50,7 @@ function init() { ONEBOX_TEST_MODEL_CONFIG = { vocabularyOptions: { closed: false, - metadata: 'onebox', - name: 'vocabulary', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + name: 'vocabulary' } as VocabularyOptions, disabled: false, id: 'onebox', diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts index 2318f3b0ae..9dbeb41ae8 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts @@ -24,9 +24,7 @@ export const SD_TEST_GROUP = new FormGroup({ export const SD_TEST_MODEL_CONFIG = { vocabularyOptions: { closed: false, - metadata: 'dropdown', - name: 'common_iso_languages', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + name: 'common_iso_languages' } as VocabularyOptions, disabled: false, errorMessages: { required: 'Required field.' }, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts index d0f7f4ec3b..d40ea72764 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts @@ -46,9 +46,7 @@ function init() { TAG_TEST_MODEL_CONFIG = { vocabularyOptions: { closed: false, - metadata: 'tag', - name: 'common_iso_languages', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + name: 'common_iso_languages' } as VocabularyOptions, disabled: false, id: 'tag', diff --git a/src/app/shared/form/builder/form-builder.service.spec.ts b/src/app/shared/form/builder/form-builder.service.spec.ts index 447ceb194d..46d058aae4 100644 --- a/src/app/shared/form/builder/form-builder.service.spec.ts +++ b/src/app/shared/form/builder/form-builder.service.spec.ts @@ -79,10 +79,8 @@ describe('FormBuilderService test suite', () => { }); const vocabularyOptions: VocabularyOptions = { - closed: false, - metadata: 'list', name: 'type_programme', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + closed: false }; testModel = [ diff --git a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts index 7fb5fb206c..a73e270ed4 100644 --- a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts +++ b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts @@ -32,7 +32,7 @@ export class DropdownFieldParser extends FieldParser { let layout: DynamicFormControlLayout; if (isNotEmpty(this.configData.selectableMetadata[0].controlledVocabulary)) { - this.setVocabularyOptions(dropdownModelConfig, this.parserOptions.collectionUUID); + this.setVocabularyOptions(dropdownModelConfig); if (isNotEmpty(fieldValue)) { dropdownModelConfig.value = fieldValue; } diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index 6c6b8fddaf..c902e455d5 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -109,12 +109,10 @@ export abstract class FieldParser { } } - public setVocabularyOptions(controlModel, scope) { + public setVocabularyOptions(controlModel) { if (isNotEmpty(this.configData.selectableMetadata) && isNotEmpty(this.configData.selectableMetadata[0].controlledVocabulary)) { controlModel.vocabularyOptions = new VocabularyOptions( this.configData.selectableMetadata[0].controlledVocabulary, - this.configData.selectableMetadata[0].metadata, - scope, this.configData.selectableMetadata[0].closed ) } diff --git a/src/app/shared/form/builder/parsers/list-field-parser.ts b/src/app/shared/form/builder/parsers/list-field-parser.ts index e9c9f4e5be..48211b0aff 100644 --- a/src/app/shared/form/builder/parsers/list-field-parser.ts +++ b/src/app/shared/form/builder/parsers/list-field-parser.ts @@ -24,7 +24,7 @@ export class ListFieldParser extends FieldParser { } }); } - this.setVocabularyOptions(listModelConfig, this.parserOptions.collectionUUID); + this.setVocabularyOptions(listModelConfig); } let listModel; diff --git a/src/app/shared/form/builder/parsers/lookup-field-parser.ts b/src/app/shared/form/builder/parsers/lookup-field-parser.ts index 6e28194dad..2c35d2ceb8 100644 --- a/src/app/shared/form/builder/parsers/lookup-field-parser.ts +++ b/src/app/shared/form/builder/parsers/lookup-field-parser.ts @@ -8,7 +8,7 @@ export class LookupFieldParser extends FieldParser { if (this.configData.selectableMetadata[0].controlledVocabulary) { const lookupModelConfig: DynamicLookupModelConfig = this.initModel(null, label); - this.setVocabularyOptions(lookupModelConfig, this.parserOptions.collectionUUID); + this.setVocabularyOptions(lookupModelConfig); this.setValues(lookupModelConfig, fieldValue, true); diff --git a/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts b/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts index 26806742c8..c791e07e74 100644 --- a/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts +++ b/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts @@ -11,7 +11,7 @@ export class LookupNameFieldParser extends FieldParser { if (this.configData.selectableMetadata[0].controlledVocabulary) { const lookupModelConfig: DynamicLookupNameModelConfig = this.initModel(null, label); - this.setVocabularyOptions(lookupModelConfig, this.parserOptions.collectionUUID); + this.setVocabularyOptions(lookupModelConfig); this.setValues(lookupModelConfig, fieldValue, true); diff --git a/src/app/shared/form/builder/parsers/onebox-field-parser.ts b/src/app/shared/form/builder/parsers/onebox-field-parser.ts index 3eb5764a37..b9ac531c68 100644 --- a/src/app/shared/form/builder/parsers/onebox-field-parser.ts +++ b/src/app/shared/form/builder/parsers/onebox-field-parser.ts @@ -77,7 +77,7 @@ export class OneboxFieldParser extends FieldParser { return new DynamicQualdropModel(inputSelectGroup, clsGroup); } else if (this.configData.selectableMetadata[0].controlledVocabulary) { const oneboxModelConfig: DsDynamicOneboxModelConfig = this.initModel(null, label); - this.setVocabularyOptions(oneboxModelConfig, this.parserOptions.collectionUUID); + this.setVocabularyOptions(oneboxModelConfig); this.setValues(oneboxModelConfig, fieldValue, true); return new DynamicOneboxModel(oneboxModelConfig); diff --git a/src/app/shared/form/builder/parsers/tag-field-parser.ts b/src/app/shared/form/builder/parsers/tag-field-parser.ts index 08685e0e35..dcc2c5575f 100644 --- a/src/app/shared/form/builder/parsers/tag-field-parser.ts +++ b/src/app/shared/form/builder/parsers/tag-field-parser.ts @@ -8,7 +8,7 @@ export class TagFieldParser extends FieldParser { const tagModelConfig: DynamicTagModelConfig = this.initModel(null, label); if (this.configData.selectableMetadata[0].controlledVocabulary && this.configData.selectableMetadata[0].controlledVocabulary.length > 0) { - this.setVocabularyOptions(tagModelConfig, this.parserOptions.collectionUUID); + this.setVocabularyOptions(tagModelConfig); } this.setValues(tagModelConfig, fieldValue, null, true); diff --git a/src/app/shared/mocks/form-models.mock.ts b/src/app/shared/mocks/form-models.mock.ts index c5f78b9571..739de944f0 100644 --- a/src/app/shared/mocks/form-models.mock.ts +++ b/src/app/shared/mocks/form-models.mock.ts @@ -152,7 +152,7 @@ const relationGroupConfig = { export const MockRelationModel: DynamicRelationGroupModel = new DynamicRelationGroupModel(relationGroupConfig); export const inputWithLanguageAndAuthorityConfig = { - vocabularyOptions: new VocabularyOptions('testAuthority', 'testWithAuthority', 'scope'), + vocabularyOptions: new VocabularyOptions('testAuthority', false), languageCodes: [ { display: 'English', @@ -207,7 +207,7 @@ export const inputWithLanguageConfig = { export const mockInputWithLanguageModel = new DsDynamicInputModel(inputWithLanguageConfig); export const inputWithLanguageAndAuthorityArrayConfig = { - vocabularyOptions: new VocabularyOptions('testAuthority', 'testWithAuthority', 'scope'), + vocabularyOptions: new VocabularyOptions('testAuthority', false), languageCodes: [ { display: 'English', diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.spec.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.spec.ts index 16ee2a55dd..6166dbc082 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.spec.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.spec.ts @@ -31,7 +31,7 @@ describe('VocabularyTreeviewComponent test suite', () => { const emptyNodeMap = new Map(); const storedNodeMap = new Map().set('test', new TreeviewFlatNode(item2)); const nodeMap = new Map().set('test', new TreeviewFlatNode(item)); - const vocabularyOptions = new VocabularyOptions('vocabularyTest', 'metadata.test', '123456'); + const vocabularyOptions = new VocabularyOptions('vocabularyTest', false); const modalStub = jasmine.createSpyObj('modalStub', ['close']); const vocabularyTreeviewServiceStub = jasmine.createSpyObj('VocabularyTreeviewService', { initialize: jasmine.createSpy('initialize'), @@ -224,7 +224,7 @@ describe('VocabularyTreeviewComponent test suite', () => { }) class TestComponent { - vocabularyOptions: VocabularyOptions = new VocabularyOptions('vocabularyTest', 'metadata.test', '123456'); + vocabularyOptions: VocabularyOptions = new VocabularyOptions('vocabularyTest', false); preloadLevel = 2; } diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts index f45478f82b..060c7f4d9b 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts @@ -156,7 +156,7 @@ describe('VocabularyTreeviewService test suite', () => { searchNodeMap = new Map([ [item.id, searchItemNode], ]); - vocabularyOptions = new VocabularyOptions('vocabularyTest', 'metadata.test', '123456'); + vocabularyOptions = new VocabularyOptions('vocabularyTest', false); } beforeEach(async(() => { From 8768645e4aa730860239b670b38d201efca331af Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 19 Aug 2020 17:47:33 +0200 Subject: [PATCH 053/126] 72541: InfoModule, EndUserAgreementComponent, UserAgreementGuard --- .../bitstream-page-routing.module.ts | 3 +- .../collection-page-routing.module.ts | 11 +++--- .../community-page-routing.module.ts | 7 ++-- .../+item-page/item-page-routing.module.ts | 5 ++- .../submit-page-routing.module.ts | 3 +- .../workflowitems-edit-page-routing.module.ts | 7 ++-- ...workspaceitems-edit-page-routing.module.ts | 3 +- src/app/app-routing.module.ts | 16 ++++++-- src/app/core/core.module.ts | 2 + src/app/core/shared/operators.ts | 12 ++++++ .../user-agreement/user-agreement.guard.ts | 37 +++++++++++++++++++ .../end-user-agreement-content.component.html | 37 +++++++++++++++++++ .../end-user-agreement-content.component.scss | 0 ...d-user-agreement-content.component.spec.ts | 24 ++++++++++++ .../end-user-agreement-content.component.ts | 9 +++++ .../end-user-agreement.component.html | 3 ++ .../end-user-agreement.component.scss | 0 .../end-user-agreement.component.spec.ts | 24 ++++++++++++ .../end-user-agreement.component.ts | 9 +++++ src/app/info/info-routing.module.ts | 33 +++++++++++++++++ src/app/info/info.module.ts | 20 ++++++++++ .../process-page-routing.module.ts | 3 +- .../register-page-routing.module.ts | 4 +- src/assets/i18n/en.json5 | 8 ++++ 24 files changed, 258 insertions(+), 22 deletions(-) create mode 100644 src/app/core/user-agreement/user-agreement.guard.ts create mode 100644 src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.html create mode 100644 src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.scss create mode 100644 src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.spec.ts create mode 100644 src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.ts create mode 100644 src/app/info/end-user-agreement/end-user-agreement.component.html create mode 100644 src/app/info/end-user-agreement/end-user-agreement.component.scss create mode 100644 src/app/info/end-user-agreement/end-user-agreement.component.spec.ts create mode 100644 src/app/info/end-user-agreement/end-user-agreement.component.ts create mode 100644 src/app/info/info-routing.module.ts create mode 100644 src/app/info/info.module.ts diff --git a/src/app/+bitstream-page/bitstream-page-routing.module.ts b/src/app/+bitstream-page/bitstream-page-routing.module.ts index 14d688064c..11b5349f90 100644 --- a/src/app/+bitstream-page/bitstream-page-routing.module.ts +++ b/src/app/+bitstream-page/bitstream-page-routing.module.ts @@ -3,6 +3,7 @@ import { RouterModule } from '@angular/router'; import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { BitstreamPageResolver } from './bitstream-page.resolver'; +import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; const EDIT_BITSTREAM_PATH = ':id/edit'; @@ -18,7 +19,7 @@ const EDIT_BITSTREAM_PATH = ':id/edit'; resolve: { bitstream: BitstreamPageResolver }, - canActivate: [AuthenticatedGuard] + canActivate: [AuthenticatedGuard, UserAgreementGuard] } ]) ], diff --git a/src/app/+collection-page/collection-page-routing.module.ts b/src/app/+collection-page/collection-page-routing.module.ts index 1ebd9b3630..3500cf9bc8 100644 --- a/src/app/+collection-page/collection-page-routing.module.ts +++ b/src/app/+collection-page/collection-page-routing.module.ts @@ -16,6 +16,7 @@ import { CollectionBreadcrumbResolver } from '../core/breadcrumbs/collection-bre import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; export const COLLECTION_PARENT_PARAMETER = 'parent'; @@ -41,7 +42,7 @@ const ITEMTEMPLATE_PATH = 'itemtemplate'; { path: COLLECTION_CREATE_PATH, component: CreateCollectionPageComponent, - canActivate: [AuthenticatedGuard, CreateCollectionPageGuard] + canActivate: [AuthenticatedGuard, CreateCollectionPageGuard, UserAgreementGuard] }, { path: ':id', @@ -54,18 +55,18 @@ const ITEMTEMPLATE_PATH = 'itemtemplate'; { path: COLLECTION_EDIT_PATH, loadChildren: './edit-collection-page/edit-collection-page.module#EditCollectionPageModule', - canActivate: [AuthenticatedGuard] + canActivate: [AuthenticatedGuard, UserAgreementGuard] }, { path: 'delete', pathMatch: 'full', component: DeleteCollectionPageComponent, - canActivate: [AuthenticatedGuard], + canActivate: [AuthenticatedGuard, UserAgreementGuard], }, { path: ITEMTEMPLATE_PATH, component: EditItemTemplatePageComponent, - canActivate: [AuthenticatedGuard], + canActivate: [AuthenticatedGuard, UserAgreementGuard], resolve: { item: ItemTemplatePageResolver, breadcrumb: I18nBreadcrumbResolver @@ -81,7 +82,7 @@ const ITEMTEMPLATE_PATH = 'itemtemplate'; path: '/edit/mapper', component: CollectionItemMapperComponent, pathMatch: 'full', - canActivate: [AuthenticatedGuard] + canActivate: [AuthenticatedGuard, UserAgreementGuard] } ] }, diff --git a/src/app/+community-page/community-page-routing.module.ts b/src/app/+community-page/community-page-routing.module.ts index 9922bc2c01..b5c3d9d2d6 100644 --- a/src/app/+community-page/community-page-routing.module.ts +++ b/src/app/+community-page/community-page-routing.module.ts @@ -12,6 +12,7 @@ import { getCommunityModulePath } from '../app-routing.module'; import { CommunityBreadcrumbResolver } from '../core/breadcrumbs/community-breadcrumb.resolver'; import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; +import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; export const COMMUNITY_PARENT_PARAMETER = 'parent'; @@ -36,7 +37,7 @@ const COMMUNITY_EDIT_PATH = 'edit'; { path: COMMUNITY_CREATE_PATH, component: CreateCommunityPageComponent, - canActivate: [AuthenticatedGuard, CreateCommunityPageGuard] + canActivate: [AuthenticatedGuard, CreateCommunityPageGuard, UserAgreementGuard] }, { path: ':id', @@ -49,13 +50,13 @@ const COMMUNITY_EDIT_PATH = 'edit'; { path: COMMUNITY_EDIT_PATH, loadChildren: './edit-community-page/edit-community-page.module#EditCommunityPageModule', - canActivate: [AuthenticatedGuard] + canActivate: [AuthenticatedGuard, UserAgreementGuard] }, { path: 'delete', pathMatch: 'full', component: DeleteCommunityPageComponent, - canActivate: [AuthenticatedGuard], + canActivate: [AuthenticatedGuard, UserAgreementGuard], }, { path: '', diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index 52faf96236..cc75a7ebc4 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -11,6 +11,7 @@ import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.reso import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; +import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; export function getItemPageRoute(itemId: string) { return new URLCombiner(getItemModulePath(), itemId).toString(); @@ -46,12 +47,12 @@ const UPLOAD_BITSTREAM_PATH = 'bitstreams/new'; { path: ITEM_EDIT_PATH, loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule', - canActivate: [AuthenticatedGuard] + canActivate: [AuthenticatedGuard, UserAgreementGuard] }, { path: UPLOAD_BITSTREAM_PATH, component: UploadBitstreamComponent, - canActivate: [AuthenticatedGuard] + canActivate: [AuthenticatedGuard, UserAgreementGuard] } ], } diff --git a/src/app/+submit-page/submit-page-routing.module.ts b/src/app/+submit-page/submit-page-routing.module.ts index 7a123bfc31..f15d0a488c 100644 --- a/src/app/+submit-page/submit-page-routing.module.ts +++ b/src/app/+submit-page/submit-page-routing.module.ts @@ -3,12 +3,13 @@ import { RouterModule } from '@angular/router'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { SubmissionSubmitComponent } from '../submission/submit/submission-submit.component'; +import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; @NgModule({ imports: [ RouterModule.forChild([ { - canActivate: [AuthenticatedGuard], + canActivate: [AuthenticatedGuard, UserAgreementGuard], path: '', pathMatch: 'full', component: SubmissionSubmitComponent, diff --git a/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts b/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts index e9989bf947..a8aa4f93a0 100644 --- a/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts +++ b/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts @@ -8,6 +8,7 @@ import { getWorkflowItemModulePath } from '../app-routing.module'; import { WorkflowItemDeleteComponent } from './workflow-item-delete/workflow-item-delete.component'; import { WorkflowItemPageResolver } from './workflow-item-page.resolver'; import { WorkflowItemSendBackComponent } from './workflow-item-send-back/workflow-item-send-back.component'; +import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; export function getWorkflowItemPageRoute(wfiId: string) { return new URLCombiner(getWorkflowItemModulePath(), wfiId).toString(); @@ -37,19 +38,19 @@ const WORKFLOW_ITEM_SEND_BACK_PATH = 'sendback'; resolve: { wfi: WorkflowItemPageResolver }, children: [ { - canActivate: [AuthenticatedGuard], + canActivate: [AuthenticatedGuard, UserAgreementGuard], path: WORKFLOW_ITEM_EDIT_PATH, component: SubmissionEditComponent, data: { title: 'submission.edit.title' } }, { - canActivate: [AuthenticatedGuard], + canActivate: [AuthenticatedGuard, UserAgreementGuard], path: WORKFLOW_ITEM_DELETE_PATH, component: WorkflowItemDeleteComponent, data: { title: 'workflow-item.delete.title' } }, { - canActivate: [AuthenticatedGuard], + canActivate: [AuthenticatedGuard, UserAgreementGuard], path: WORKFLOW_ITEM_SEND_BACK_PATH, component: WorkflowItemSendBackComponent, data: { title: 'workflow-item.send-back.title' } diff --git a/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts index d10c53e138..9dce207baf 100644 --- a/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts +++ b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts @@ -3,13 +3,14 @@ import { RouterModule } from '@angular/router'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { SubmissionEditComponent } from '../submission/edit/submission-edit.component'; +import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; @NgModule({ imports: [ RouterModule.forChild([ { path: '', redirectTo: '/home', pathMatch: 'full' }, { - canActivate: [AuthenticatedGuard], + canActivate: [AuthenticatedGuard, UserAgreementGuard], path: ':id/edit', component: SubmissionEditComponent, data: { title: 'submission.edit.title' } diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index c8ee6ecd8b..de077a5b3a 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -12,6 +12,7 @@ import { getItemPageRoute } from './+item-page/item-page-routing.module'; import { getCollectionPageRoute } from './+collection-page/collection-page-routing.module'; import { SiteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { UnauthorizedComponent } from './unauthorized/unauthorized.component'; +import { UserAgreementGuard } from './core/user-agreement/user-agreement.guard'; const ITEM_MODULE_PATH = 'items'; @@ -84,6 +85,12 @@ export function getUnauthorizedPath() { return `/${UNAUTHORIZED_PATH}`; } +const INFO_MODULE_PATH = 'info'; + +export function getInfoModulePath() { + return `/${INFO_MODULE_PATH}`; +} + @NgModule({ imports: [ RouterModule.forRoot([ @@ -102,11 +109,11 @@ export function getUnauthorizedPath() { { path: 'mydspace', loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', - canActivate: [AuthenticatedGuard] + canActivate: [AuthenticatedGuard, UserAgreementGuard] }, { path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule' }, { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'}, - { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard] }, + { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard, UserAgreementGuard] }, { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, @@ -120,9 +127,10 @@ export function getUnauthorizedPath() { }, { path: PROFILE_MODULE_PATH, - loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] + loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard, UserAgreementGuard] }, - { path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard] }, + { path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard, UserAgreementGuard] }, + { path: INFO_MODULE_PATH, loadChildren: './info/info.module#InfoModule' }, { path: UNAUTHORIZED_PATH, component: UnauthorizedComponent }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, ], diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 5aa462d5e0..08101260dc 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -162,6 +162,7 @@ import { SubmissionCcLicenceUrl } from './submission/models/submission-cc-licens import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-license-url-data.service'; import { ConfigurationDataService } from './data/configuration-data.service'; import { ConfigurationProperty } from './shared/configuration-property.model'; +import { UserAgreementGuard } from './user-agreement/user-agreement.guard'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -289,6 +290,7 @@ const PROVIDERS = [ MetadataSchemaDataService, MetadataFieldDataService, TokenResponseParsingService, + UserAgreementGuard, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 7516cc6532..95eb822017 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -12,6 +12,7 @@ import { RequestService } from '../data/request.service'; import { BrowseDefinition } from './browse-definition.model'; import { DSpaceObject } from './dspace-object.model'; import { getUnauthorizedPath } from '../../app-routing.module'; +import { getEndUserAgreementPath } from '../../info/info-routing.module'; /** * This file contains custom RxJS operators that can be used in multiple places @@ -192,6 +193,17 @@ export const returnUnauthorizedUrlTreeOnFalse = (router: Router) => return authorized ? authorized : router.parseUrl(getUnauthorizedPath()) })); +/** + * Operator that returns a UrlTree to the unauthorized page when the boolean received is false + * @param router + */ +export const returnEndUserAgreementUrlTreeOnFalse = (router: Router) => + (source: Observable): Observable => + source.pipe( + map((hasAgreed: boolean) => { + return hasAgreed ? hasAgreed : router.parseUrl(getEndUserAgreementPath()) + })); + export const getFinishedRemoteData = () => (source: Observable>): Observable> => source.pipe(find((rd: RemoteData) => !rd.isLoading)); diff --git a/src/app/core/user-agreement/user-agreement.guard.ts b/src/app/core/user-agreement/user-agreement.guard.ts new file mode 100644 index 0000000000..d955c300a1 --- /dev/null +++ b/src/app/core/user-agreement/user-agreement.guard.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; +import { Observable } from 'rxjs/internal/Observable'; +import { CookieService } from '../services/cookie.service'; +import { AuthService } from '../auth/auth.service'; +import { map } from 'rxjs/operators'; +import { hasValue } from '../../shared/empty.util'; +import { returnEndUserAgreementUrlTreeOnFalse } from '../shared/operators'; + +export const USER_AGREEMENT_COOKIE = 'hasAgreedEndUser'; +export const USER_AGREEMENT_METADATA_FIELD = 'dspace.agreements.end-user'; + +/** + * A guard redirecting users to the end agreement page when they haven't accepted the latest user agreement + */ +@Injectable() +export class UserAgreementGuard implements CanActivate { + + constructor(protected cookie: CookieService, + protected authService: AuthService, + protected router: Router) { + } + + /** + * True when the user has accepted the agreements + */ + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable | boolean { + if (this.cookie.get(USER_AGREEMENT_COOKIE) === true) { + return true; + } else { + return this.authService.getAuthenticatedUserFromStore().pipe( + map((user) => hasValue(user) && user.hasMetadata(USER_AGREEMENT_METADATA_FIELD) && user.firstMetadata(USER_AGREEMENT_METADATA_FIELD).value === 'true'), + returnEndUserAgreementUrlTreeOnFalse(this.router) + ); + } + } +} diff --git a/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.html b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.html new file mode 100644 index 0000000000..1ee8712444 --- /dev/null +++ b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.html @@ -0,0 +1,37 @@ +

{{ 'info.end-user-agreement.head' | translate }}

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Nunc sed velit dignissim sodales ut eu. In ante metus dictum at tempor. Diam phasellus vestibulum lorem sed risus. Sed cras ornare arcu dui vivamus. Sit amet consectetur adipiscing elit pellentesque. Id velit ut tortor pretium viverra suspendisse potenti. Sed euismod nisi porta lorem mollis aliquam ut. Justo laoreet sit amet cursus sit amet dictum sit. Ullamcorper morbi tincidunt ornare massa eget egestas. +

+

+ In iaculis nunc sed augue lacus. Curabitur vitae nunc sed velit dignissim sodales ut eu sem. Tellus id interdum velit laoreet id donec ultrices tincidunt arcu. Quis vel eros donec ac odio tempor. Viverra accumsan in nisl nisi scelerisque eu ultrices vitae. Varius quam quisque id diam vel quam. Nisl tincidunt eget nullam non nisi est sit. Nunc aliquet bibendum enim facilisis. Aenean sed adipiscing diam donec adipiscing. Convallis tellus id interdum velit laoreet. Massa placerat duis ultricies lacus sed turpis tincidunt. Sed cras ornare arcu dui vivamus arcu. Egestas integer eget aliquet nibh praesent tristique. Sit amet purus gravida quis blandit turpis cursus in hac. Porta non pulvinar neque laoreet suspendisse. Quis risus sed vulputate odio ut. Dignissim enim sit amet venenatis urna cursus. +

+

+ Interdum velit laoreet id donec ultrices tincidunt arcu non sodales. Massa sapien faucibus et molestie. Dictumst vestibulum rhoncus est pellentesque elit ullamcorper. Metus dictum at tempor commodo ullamcorper. Tincidunt lobortis feugiat vivamus at augue eget. Non diam phasellus vestibulum lorem sed risus ultricies. Neque aliquam vestibulum morbi blandit cursus risus at ultrices mi. Euismod lacinia at quis risus sed. Lorem mollis aliquam ut porttitor leo a diam. Ipsum dolor sit amet consectetur. Ante in nibh mauris cursus mattis molestie a iaculis at. Commodo ullamcorper a lacus vestibulum. Pellentesque elit eget gravida cum sociis. Sit amet commodo nulla facilisi nullam vehicula. Vehicula ipsum a arcu cursus vitae congue mauris rhoncus aenean. +

+

+ Ac turpis egestas maecenas pharetra convallis. Lacus sed viverra tellus in. Nullam eget felis eget nunc lobortis mattis aliquam faucibus purus. Id aliquet risus feugiat in ante metus dictum at. Quis enim lobortis scelerisque fermentum dui faucibus. Eu volutpat odio facilisis mauris sit amet massa vitae tortor. Tellus elementum sagittis vitae et leo. Cras sed felis eget velit aliquet sagittis. Proin fermentum leo vel orci porta non pulvinar neque laoreet. Dui sapien eget mi proin sed libero enim. Ultrices mi tempus imperdiet nulla malesuada. Mattis molestie a iaculis at. Turpis massa sed elementum tempus egestas. +

+

+ Dui faucibus in ornare quam viverra orci sagittis eu volutpat. Cras adipiscing enim eu turpis. Ac felis donec et odio pellentesque. Iaculis nunc sed augue lacus viverra vitae congue eu consequat. Posuere lorem ipsum dolor sit amet consectetur adipiscing elit duis. Elit eget gravida cum sociis natoque penatibus. Id faucibus nisl tincidunt eget nullam non. Sagittis aliquam malesuada bibendum arcu vitae. Fermentum leo vel orci porta. Aliquam ultrices sagittis orci a scelerisque purus semper. Diam maecenas sed enim ut sem viverra aliquet eget sit. Et ultrices neque ornare aenean euismod. Eu mi bibendum neque egestas congue quisque egestas diam. Eget lorem dolor sed viverra. Ut lectus arcu bibendum at. Rutrum tellus pellentesque eu tincidunt tortor. Vitae congue eu consequat ac. Elit ullamcorper dignissim cras tincidunt. Sit amet volutpat consequat mauris nunc congue nisi. +

+

+ Cursus in hac habitasse platea dictumst quisque sagittis purus. Placerat duis ultricies lacus sed turpis tincidunt. In egestas erat imperdiet sed euismod nisi porta lorem mollis. Non nisi est sit amet facilisis magna. In massa tempor nec feugiat nisl pretium fusce. Pulvinar neque laoreet suspendisse interdum consectetur. Ullamcorper morbi tincidunt ornare massa eget egestas purus viverra accumsan. Fringilla urna porttitor rhoncus dolor purus non enim. Mauris nunc congue nisi vitae suscipit. Commodo elit at imperdiet dui accumsan sit amet nulla. Tempor id eu nisl nunc mi ipsum faucibus. Porta non pulvinar neque laoreet suspendisse. Nec nam aliquam sem et tortor consequat. +

+

+ Eget nunc lobortis mattis aliquam faucibus purus. Odio tempor orci dapibus ultrices. Sed nisi lacus sed viverra tellus. Elit ullamcorper dignissim cras tincidunt. Porttitor rhoncus dolor purus non enim praesent elementum facilisis. Viverra orci sagittis eu volutpat odio. Pharetra massa massa ultricies mi quis. Lectus vestibulum mattis ullamcorper velit sed ullamcorper. Pulvinar neque laoreet suspendisse interdum consectetur. Vitae auctor eu augue ut. Arcu dictum varius duis at consectetur lorem donec. Massa sed elementum tempus egestas sed sed. Risus viverra adipiscing at in tellus integer. Vulputate enim nulla aliquet porttitor lacus luctus accumsan. Pharetra massa massa ultricies mi. Elementum eu facilisis sed odio morbi quis commodo odio. Tincidunt lobortis feugiat vivamus at. Felis donec et odio pellentesque diam volutpat commodo sed. Risus feugiat in ante metus dictum at tempor commodo ullamcorper. Fringilla phasellus faucibus scelerisque eleifend donec pretium vulputate. +

+

+ Lectus proin nibh nisl condimentum id venenatis a condimentum. Id consectetur purus ut faucibus pulvinar elementum integer enim. Non pulvinar neque laoreet suspendisse interdum consectetur. Est pellentesque elit ullamcorper dignissim cras tincidunt lobortis feugiat vivamus. Suscipit tellus mauris a diam maecenas sed enim ut sem. Dolor purus non enim praesent elementum facilisis. Non enim praesent elementum facilisis leo vel. Ultricies leo integer malesuada nunc vel risus commodo viverra maecenas. Nulla porttitor massa id neque aliquam vestibulum. Erat velit scelerisque in dictum non consectetur. Amet cursus sit amet dictum. Nec tincidunt praesent semper feugiat nibh. Rutrum quisque non tellus orci ac auctor. Sagittis aliquam malesuada bibendum arcu vitae elementum. Massa tincidunt dui ut ornare lectus sit amet est. Aliquet porttitor lacus luctus accumsan tortor posuere ac. Quis hendrerit dolor magna eget est lorem ipsum dolor sit. Lectus mauris ultrices eros in. +

+

+ Massa massa ultricies mi quis hendrerit dolor magna. Est ullamcorper eget nulla facilisi etiam dignissim diam. Vulputate sapien nec sagittis aliquam malesuada. Nisi porta lorem mollis aliquam ut porttitor leo a diam. Tempus quam pellentesque nec nam. Faucibus vitae aliquet nec ullamcorper sit amet risus nullam eget. Gravida arcu ac tortor dignissim convallis aenean et tortor. A scelerisque purus semper eget duis at tellus at. Viverra ipsum nunc aliquet bibendum enim. Semper feugiat nibh sed pulvinar proin gravida hendrerit. Et ultrices neque ornare aenean euismod. Consequat semper viverra nam libero justo laoreet. Nunc mattis enim ut tellus elementum sagittis. Consectetur lorem donec massa sapien faucibus et. Vel risus commodo viverra maecenas accumsan lacus vel facilisis. Diam sollicitudin tempor id eu nisl nunc. Dolor magna eget est lorem ipsum dolor. Adipiscing elit pellentesque habitant morbi tristique. +

+

+ Nec sagittis aliquam malesuada bibendum arcu vitae elementum curabitur. Egestas fringilla phasellus faucibus scelerisque eleifend donec pretium vulputate sapien. Porttitor leo a diam sollicitudin tempor. Pellentesque dignissim enim sit amet venenatis urna cursus eget nunc. Posuere sollicitudin aliquam ultrices sagittis orci a scelerisque. Vehicula ipsum a arcu cursus vitae congue mauris rhoncus. Leo urna molestie at elementum. Duis tristique sollicitudin nibh sit amet commodo nulla facilisi. Libero id faucibus nisl tincidunt eget nullam. Tellus elementum sagittis vitae et leo duis ut diam. Sodales ut etiam sit amet nisl purus in mollis. Ipsum faucibus vitae aliquet nec ullamcorper sit amet risus. Lacus laoreet non curabitur gravida arcu ac tortor dignissim convallis. Aliquam malesuada bibendum arcu vitae elementum. Leo vel orci porta non pulvinar neque laoreet. Ipsum suspendisse ultrices gravida dictum fusce. +

+

+ Egestas erat imperdiet sed euismod nisi porta lorem. Venenatis a condimentum vitae sapien pellentesque habitant. Sit amet luctus venenatis lectus magna fringilla urna porttitor. Orci sagittis eu volutpat odio facilisis mauris sit amet massa. Ut enim blandit volutpat maecenas volutpat blandit aliquam. Libero volutpat sed cras ornare. Molestie ac feugiat sed lectus vestibulum mattis ullamcorper velit sed. Diam quis enim lobortis scelerisque fermentum dui. Pellentesque habitant morbi tristique senectus et netus. Auctor urna nunc id cursus metus aliquam eleifend. Elit scelerisque mauris pellentesque pulvinar pellentesque habitant morbi tristique. Sed risus ultricies tristique nulla aliquet enim tortor. Tincidunt arcu non sodales neque sodales ut. Sed lectus vestibulum mattis ullamcorper velit sed ullamcorper morbi tincidunt. +

+

+ Pulvinar etiam non quam lacus suspendisse faucibus. Eu mi bibendum neque egestas congue. Egestas purus viverra accumsan in nisl nisi scelerisque eu. Vulputate enim nulla aliquet porttitor lacus luctus accumsan. Eu non diam phasellus vestibulum. Semper feugiat nibh sed pulvinar. Ante in nibh mauris cursus mattis molestie a. Maecenas accumsan lacus vel facilisis volutpat. Non quam lacus suspendisse faucibus. Quis commodo odio aenean sed adipiscing. Vel elit scelerisque mauris pellentesque pulvinar pellentesque habitant morbi. Sed cras ornare arcu dui vivamus arcu felis. Tortor vitae purus faucibus ornare suspendisse sed. Morbi tincidunt ornare massa eget egestas purus viverra. Nibh cras pulvinar mattis nunc. Luctus venenatis lectus magna fringilla urna porttitor. Enim blandit volutpat maecenas volutpat blandit aliquam etiam erat. Malesuada pellentesque elit eget gravida cum sociis natoque penatibus et. Felis eget nunc lobortis mattis aliquam faucibus purus in. Vivamus arcu felis bibendum ut. +

diff --git a/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.scss b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.spec.ts b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.spec.ts new file mode 100644 index 0000000000..c95e60846e --- /dev/null +++ b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.spec.ts @@ -0,0 +1,24 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { EndUserAgreementContentComponent } from './end-user-agreement-content.component'; + +describe('EndUserAgreementContentComponent', () => { + let component: EndUserAgreementContentComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ EndUserAgreementContentComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EndUserAgreementContentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.ts b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.ts new file mode 100644 index 0000000000..cbfc706229 --- /dev/null +++ b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-end-user-agreement-content', + templateUrl: './end-user-agreement-content.component.html', + styleUrls: ['./end-user-agreement-content.component.scss'] +}) +export class EndUserAgreementContentComponent { +} diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.html b/src/app/info/end-user-agreement/end-user-agreement.component.html new file mode 100644 index 0000000000..cc155ee9c3 --- /dev/null +++ b/src/app/info/end-user-agreement/end-user-agreement.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.scss b/src/app/info/end-user-agreement/end-user-agreement.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts b/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts new file mode 100644 index 0000000000..6b87a0fccb --- /dev/null +++ b/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts @@ -0,0 +1,24 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { EndUserAgreementComponent } from './end-user-agreement.component'; + +describe('EndUserAgreementComponent', () => { + let component: EndUserAgreementComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ EndUserAgreementComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EndUserAgreementComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.ts b/src/app/info/end-user-agreement/end-user-agreement.component.ts new file mode 100644 index 0000000000..3e43d68784 --- /dev/null +++ b/src/app/info/end-user-agreement/end-user-agreement.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-end-user-agreement', + templateUrl: './end-user-agreement.component.html', + styleUrls: ['./end-user-agreement.component.scss'] +}) +export class EndUserAgreementComponent { +} diff --git a/src/app/info/info-routing.module.ts b/src/app/info/info-routing.module.ts new file mode 100644 index 0000000000..bfb8b77d96 --- /dev/null +++ b/src/app/info/info-routing.module.ts @@ -0,0 +1,33 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { EndUserAgreementComponent } from './end-user-agreement/end-user-agreement.component'; +import { getInfoModulePath } from '../app-routing.module'; +import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; + +const END_USER_AGREEMENT_PATH = 'end-user-agreement'; + +export function getEndUserAgreementPath() { + return getSubPath(END_USER_AGREEMENT_PATH); +} + +function getSubPath(path: string) { + return `${getInfoModulePath()}/${path}`; +} + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: END_USER_AGREEMENT_PATH, + component: EndUserAgreementComponent, + resolve: { breadcrumb: I18nBreadcrumbResolver }, + data: { title: 'info.end-user-agreement.title', breadcrumbKey: 'info.end-user-agreement' } + } + ]) + ] +}) +/** + * Module for navigating to components within the info module + */ +export class InfoRoutingModule { +} diff --git a/src/app/info/info.module.ts b/src/app/info/info.module.ts new file mode 100644 index 0000000000..93923cffe3 --- /dev/null +++ b/src/app/info/info.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { EndUserAgreementComponent } from './end-user-agreement/end-user-agreement.component'; +import { InfoRoutingModule } from './info-routing.module'; +import { EndUserAgreementContentComponent } from './end-user-agreement/end-user-agreement-content/end-user-agreement-content.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + InfoRoutingModule + ], + declarations: [ + EndUserAgreementComponent, + EndUserAgreementContentComponent + ] +}) +export class InfoModule { +} diff --git a/src/app/process-page/process-page-routing.module.ts b/src/app/process-page/process-page-routing.module.ts index 881ca0e853..92d55467bc 100644 --- a/src/app/process-page/process-page-routing.module.ts +++ b/src/app/process-page/process-page-routing.module.ts @@ -7,6 +7,7 @@ import { ProcessDetailComponent } from './detail/process-detail.component'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { ProcessBreadcrumbResolver } from './process-breadcrumb.resolver'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; @NgModule({ imports: [ @@ -15,7 +16,7 @@ import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; path: '', resolve: { breadcrumb: I18nBreadcrumbResolver }, data: { breadcrumbKey: 'process.overview' }, - canActivate: [AuthenticatedGuard], + canActivate: [AuthenticatedGuard, UserAgreementGuard], children: [ { path: '', diff --git a/src/app/register-page/register-page-routing.module.ts b/src/app/register-page/register-page-routing.module.ts index c7cceeaaf4..c199b51c6c 100644 --- a/src/app/register-page/register-page-routing.module.ts +++ b/src/app/register-page/register-page-routing.module.ts @@ -4,6 +4,7 @@ import { RegisterEmailComponent } from './register-email/register-email.componen import { CreateProfileComponent } from './create-profile/create-profile.component'; import { ItemPageResolver } from '../+item-page/item-page.resolver'; import { RegistrationResolver } from '../register-email-form/registration.resolver'; +import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; @NgModule({ imports: [ @@ -16,7 +17,8 @@ import { RegistrationResolver } from '../register-email-form/registration.resolv { path: ':token', component: CreateProfileComponent, - resolve: {registration: RegistrationResolver} + resolve: {registration: RegistrationResolver}, + canActivate: [UserAgreementGuard] } ]) ], diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index b9c03eafc8..510163c6c3 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1138,6 +1138,14 @@ + "info.end-user-agreement.breadcrumbs": "End User Agreement", + + "info.end-user-agreement.head": "End User Agreement", + + "info.end-user-agreement.title": "End User Agreement", + + + "item.edit.authorizations.heading": "With this editor you can view and alter the policies of an item, plus alter policies of individual item components: bundles and bitstreams. Briefly, an item is a container of bundles, and bundles are containers of bitstreams. Containers usually have ADD/REMOVE/READ/WRITE policies, while bitstreams only have READ/WRITE policies.", "item.edit.authorizations.title": "Edit item's Policies", From d46355e2742641e168706214259c8a0e69c9f8a0 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 20 Aug 2020 17:28:25 +0200 Subject: [PATCH 054/126] 72541: Accepting the End User Agreement + UserAgreementService --- src/app/core/core.module.ts | 2 + .../user-agreement/user-agreement.guard.ts | 33 ++++---- .../user-agreement/user-agreement.service.ts | 76 ++++++++++++++++++ .../end-user-agreement-content.component.ts | 3 + .../end-user-agreement.component.html | 10 +++ .../end-user-agreement.component.scss | 8 ++ .../end-user-agreement.component.ts | 80 ++++++++++++++++++- src/assets/i18n/en.json5 | 10 +++ 8 files changed, 202 insertions(+), 20 deletions(-) create mode 100644 src/app/core/user-agreement/user-agreement.service.ts diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 08101260dc..f1c4ebd121 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -163,6 +163,7 @@ import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-li import { ConfigurationDataService } from './data/configuration-data.service'; import { ConfigurationProperty } from './shared/configuration-property.model'; import { UserAgreementGuard } from './user-agreement/user-agreement.guard'; +import { UserAgreementService } from './user-agreement/user-agreement.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -291,6 +292,7 @@ const PROVIDERS = [ MetadataFieldDataService, TokenResponseParsingService, UserAgreementGuard, + UserAgreementService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, diff --git a/src/app/core/user-agreement/user-agreement.guard.ts b/src/app/core/user-agreement/user-agreement.guard.ts index d955c300a1..7464b87e00 100644 --- a/src/app/core/user-agreement/user-agreement.guard.ts +++ b/src/app/core/user-agreement/user-agreement.guard.ts @@ -1,14 +1,9 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; import { Observable } from 'rxjs/internal/Observable'; -import { CookieService } from '../services/cookie.service'; -import { AuthService } from '../auth/auth.service'; -import { map } from 'rxjs/operators'; -import { hasValue } from '../../shared/empty.util'; import { returnEndUserAgreementUrlTreeOnFalse } from '../shared/operators'; - -export const USER_AGREEMENT_COOKIE = 'hasAgreedEndUser'; -export const USER_AGREEMENT_METADATA_FIELD = 'dspace.agreements.end-user'; +import { UserAgreementService } from './user-agreement.service'; +import { tap } from 'rxjs/operators'; /** * A guard redirecting users to the end agreement page when they haven't accepted the latest user agreement @@ -16,22 +11,24 @@ export const USER_AGREEMENT_METADATA_FIELD = 'dspace.agreements.end-user'; @Injectable() export class UserAgreementGuard implements CanActivate { - constructor(protected cookie: CookieService, - protected authService: AuthService, + constructor(protected userAgreementService: UserAgreementService, protected router: Router) { } /** * True when the user has accepted the agreements + * The user will be redirected to the End User Agreement page if they haven't accepted it before + * A redirect URL will be provided with the navigation so the component can redirect the user back to the blocked route + * when they're finished accepting the agreement */ - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable | boolean { - if (this.cookie.get(USER_AGREEMENT_COOKIE) === true) { - return true; - } else { - return this.authService.getAuthenticatedUserFromStore().pipe( - map((user) => hasValue(user) && user.hasMetadata(USER_AGREEMENT_METADATA_FIELD) && user.firstMetadata(USER_AGREEMENT_METADATA_FIELD).value === 'true'), - returnEndUserAgreementUrlTreeOnFalse(this.router) - ); - } + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.userAgreementService.hasCurrentUserAcceptedAgreement().pipe( + returnEndUserAgreementUrlTreeOnFalse(this.router), + tap((result) => { + if (result instanceof UrlTree) { + this.router.navigateByUrl(result, { state: { redirect: state.url } }) + } + }) + ); } } diff --git a/src/app/core/user-agreement/user-agreement.service.ts b/src/app/core/user-agreement/user-agreement.service.ts new file mode 100644 index 0000000000..8e5694d81c --- /dev/null +++ b/src/app/core/user-agreement/user-agreement.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@angular/core'; +import { AuthService } from '../auth/auth.service'; +import { CookieService } from '../services/cookie.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { of as observableOf } from 'rxjs'; +import { map, switchMap, take } from 'rxjs/operators'; +import { hasValue } from '../../shared/empty.util'; +import { cloneDeep } from 'lodash'; +import { Metadata } from '../shared/metadata.utils'; +import { EPersonDataService } from '../eperson/eperson-data.service'; +import { getSucceededRemoteData } from '../shared/operators'; + +export const USER_AGREEMENT_COOKIE = 'hasAgreedEndUser'; +export const USER_AGREEMENT_METADATA_FIELD = 'dspace.agreements.end-user'; + +/** + * Service for checking and managing the status of the current end user agreement + */ +@Injectable() +export class UserAgreementService { + + constructor(protected cookie: CookieService, + protected authService: AuthService, + protected ePersonService: EPersonDataService) { + } + + /** + * Whether or not the current user has accepted the End User Agreement + */ + hasCurrentUserAcceptedAgreement(): Observable { + if (this.cookie.get(USER_AGREEMENT_COOKIE) === true) { + return observableOf(true); + } else { + return this.authService.isAuthenticated().pipe( + switchMap((authenticated) => { + if (authenticated) { + return this.authService.getAuthenticatedUserFromStore().pipe( + map((user) => hasValue(user) && user.hasMetadata(USER_AGREEMENT_METADATA_FIELD) && user.firstMetadata(USER_AGREEMENT_METADATA_FIELD).value === 'true') + ); + } else { + return observableOf(false); + } + }) + ); + } + } + + /** + * Set the current user's accepted agreement status + * When a user is authenticated, set his/her metadata to the provided value + * When no user is authenticated, set the cookie to the provided value + * @param accepted + */ + setUserAcceptedAgreement(accepted: boolean): Observable { + return this.authService.isAuthenticated().pipe( + switchMap((authenticated) => { + if (authenticated) { + return this.authService.getAuthenticatedUserFromStore().pipe( + switchMap((user) => { + const updatedUser = cloneDeep(user); + Metadata.setFirstValue(updatedUser.metadata, USER_AGREEMENT_METADATA_FIELD, String(accepted)); + return this.ePersonService.update(updatedUser); + }), + getSucceededRemoteData(), + map((rd) => hasValue(rd.payload)) + ); + } else { + this.cookie.set(USER_AGREEMENT_COOKIE, accepted); + return observableOf(true); + } + }), + take(1) + ); + } + +} diff --git a/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.ts b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.ts index cbfc706229..faa7d5a78f 100644 --- a/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.ts +++ b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.ts @@ -5,5 +5,8 @@ import { Component } from '@angular/core'; templateUrl: './end-user-agreement-content.component.html', styleUrls: ['./end-user-agreement-content.component.scss'] }) +/** + * Component displaying the contents of the End User Agreement + */ export class EndUserAgreementContentComponent { } diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.html b/src/app/info/end-user-agreement/end-user-agreement.component.html index cc155ee9c3..624264e2cc 100644 --- a/src/app/info/end-user-agreement/end-user-agreement.component.html +++ b/src/app/info/end-user-agreement/end-user-agreement.component.html @@ -1,3 +1,13 @@
+ +
+ + + +
+ + +
+
diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.scss b/src/app/info/end-user-agreement/end-user-agreement.component.scss index e69de29bb2..2960a0fac1 100644 --- a/src/app/info/end-user-agreement/end-user-agreement.component.scss +++ b/src/app/info/end-user-agreement/end-user-agreement.component.scss @@ -0,0 +1,8 @@ +input#user-agreement-accept { + /* Large-sized Checkboxes */ + -ms-transform: scale(1.6); /* IE */ + -moz-transform: scale(1.6); /* FF */ + -webkit-transform: scale(1.6); /* Safari and Chrome */ + -o-transform: scale(1.6); /* Opera */ + padding: 10px; +} diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.ts b/src/app/info/end-user-agreement/end-user-agreement.component.ts index 3e43d68784..5da59bf5c4 100644 --- a/src/app/info/end-user-agreement/end-user-agreement.component.ts +++ b/src/app/info/end-user-agreement/end-user-agreement.component.ts @@ -1,9 +1,85 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; +import { AuthService } from '../../core/auth/auth.service'; +import { take } from 'rxjs/operators'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../app.reducer'; +import { LogOutAction } from '../../core/auth/auth.actions'; +import { UserAgreementService } from '../../core/user-agreement/user-agreement.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { hasValue } from '../../shared/empty.util'; @Component({ selector: 'ds-end-user-agreement', templateUrl: './end-user-agreement.component.html', styleUrls: ['./end-user-agreement.component.scss'] }) -export class EndUserAgreementComponent { +/** + * Component displaying the End User Agreement and an option to accept it + */ +export class EndUserAgreementComponent implements OnInit { + + /** + * Whether or not the user agreement has been accepted + */ + accepted = false; + + constructor(protected userAgreementService: UserAgreementService, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected authService: AuthService, + protected store: Store, + protected router: Router) { + } + + /** + * Initialize the component + */ + ngOnInit(): void { + this.initAccepted(); + } + + /** + * Initialize the "accepted" property of this component by checking if the current user has accepted it before + */ + initAccepted() { + this.userAgreementService.hasCurrentUserAcceptedAgreement().subscribe((accepted) => { + this.accepted = accepted; + }); + } + + /** + * Submit the form + * Set the End User Agreement, display a notification and (optionally) redirect the user back to their original destination + */ + submit() { + this.userAgreementService.setUserAcceptedAgreement(this.accepted).subscribe((success) => { + if (success) { + this.notificationsService.success(this.translate.instant('info.end-user-agreement.accept.success')); + const redirect = window.history.state.redirect; + if (hasValue(redirect)) { + this.router.navigateByUrl(redirect); + } + } else { + this.notificationsService.error(this.translate.instant('info.end-user-agreement.accept.error')); + } + }); + } + + /** + * Cancel the agreement + * If the user is logged in, this will log him/her out + * If the user is not logged in, they will be redirected to the homepage + */ + cancel() { + this.authService.isAuthenticated().pipe(take(1)).subscribe((authenticated) => { + if (authenticated) { + this.store.dispatch(new LogOutAction()); + } else { + this.router.navigate(['home']); + } + }); + } + } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 510163c6c3..ca82aca80b 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1138,8 +1138,18 @@ + "info.end-user-agreement.accept": "I have read and I agree to the End User Agreement", + + "info.end-user-agreement.accept.error": "An error occurred accepting the End User Agreement", + + "info.end-user-agreement.accept.success": "Successfully updated the End User Agreement", + "info.end-user-agreement.breadcrumbs": "End User Agreement", + "info.end-user-agreement.buttons.cancel": "Cancel", + + "info.end-user-agreement.buttons.save": "Save", + "info.end-user-agreement.head": "End User Agreement", "info.end-user-agreement.title": "End User Agreement", From ecf75efe99d8a9b2d16e8c90bfb5bb7f4f7752d4 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 21 Aug 2020 11:22:02 +0200 Subject: [PATCH 055/126] 72541: Renamed end-user-agreement-guard and -service; Add metadata on registry; privacy statement component --- .../bitstream-page-routing.module.ts | 4 +- .../collection-page-routing.module.ts | 12 +++--- .../community-page-routing.module.ts | 8 ++-- .../+item-page/item-page-routing.module.ts | 6 +-- .../submit-page-routing.module.ts | 4 +- .../workflowitems-edit-page-routing.module.ts | 8 ++-- ...workspaceitems-edit-page-routing.module.ts | 4 +- src/app/app-routing.module.ts | 10 ++--- src/app/core/core.module.ts | 8 ++-- .../end-user-agreement.guard.ts} | 8 ++-- .../end-user-agreement.service.ts} | 36 ++++++++++++++---- .../end-user-agreement.component.html | 2 +- .../end-user-agreement.component.ts | 8 ++-- src/app/info/info-routing.module.ts | 14 +++++++ src/app/info/info.module.ts | 6 ++- .../privacy-content.component.html | 37 +++++++++++++++++++ .../privacy-content.component.scss | 0 .../privacy-content.component.spec.ts | 24 ++++++++++++ .../privacy-content.component.ts | 12 ++++++ src/app/info/privacy/privacy.component.html | 3 ++ src/app/info/privacy/privacy.component.scss | 0 .../info/privacy/privacy.component.spec.ts | 24 ++++++++++++ src/app/info/privacy/privacy.component.ts | 12 ++++++ .../process-page-routing.module.ts | 4 +- .../create-profile.component.ts | 17 ++++++++- .../register-page-routing.module.ts | 4 +- src/assets/i18n/en.json5 | 6 +++ 27 files changed, 227 insertions(+), 54 deletions(-) rename src/app/core/{user-agreement/user-agreement.guard.ts => end-user-agreement/end-user-agreement.guard.ts} (80%) rename src/app/core/{user-agreement/user-agreement.service.ts => end-user-agreement/end-user-agreement.service.ts} (68%) create mode 100644 src/app/info/privacy/privacy-content/privacy-content.component.html create mode 100644 src/app/info/privacy/privacy-content/privacy-content.component.scss create mode 100644 src/app/info/privacy/privacy-content/privacy-content.component.spec.ts create mode 100644 src/app/info/privacy/privacy-content/privacy-content.component.ts create mode 100644 src/app/info/privacy/privacy.component.html create mode 100644 src/app/info/privacy/privacy.component.scss create mode 100644 src/app/info/privacy/privacy.component.spec.ts create mode 100644 src/app/info/privacy/privacy.component.ts diff --git a/src/app/+bitstream-page/bitstream-page-routing.module.ts b/src/app/+bitstream-page/bitstream-page-routing.module.ts index 11b5349f90..0856dd00cb 100644 --- a/src/app/+bitstream-page/bitstream-page-routing.module.ts +++ b/src/app/+bitstream-page/bitstream-page-routing.module.ts @@ -3,7 +3,7 @@ import { RouterModule } from '@angular/router'; import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { BitstreamPageResolver } from './bitstream-page.resolver'; -import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; +import { EndUserAgreementGuard } from '../core/end-user-agreement/end-user-agreement.guard'; const EDIT_BITSTREAM_PATH = ':id/edit'; @@ -19,7 +19,7 @@ const EDIT_BITSTREAM_PATH = ':id/edit'; resolve: { bitstream: BitstreamPageResolver }, - canActivate: [AuthenticatedGuard, UserAgreementGuard] + canActivate: [AuthenticatedGuard, EndUserAgreementGuard] } ]) ], diff --git a/src/app/+collection-page/collection-page-routing.module.ts b/src/app/+collection-page/collection-page-routing.module.ts index 3500cf9bc8..c7ee89a9d2 100644 --- a/src/app/+collection-page/collection-page-routing.module.ts +++ b/src/app/+collection-page/collection-page-routing.module.ts @@ -16,7 +16,7 @@ import { CollectionBreadcrumbResolver } from '../core/breadcrumbs/collection-bre import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; -import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; +import { EndUserAgreementGuard } from '../core/end-user-agreement/end-user-agreement.guard'; export const COLLECTION_PARENT_PARAMETER = 'parent'; @@ -42,7 +42,7 @@ const ITEMTEMPLATE_PATH = 'itemtemplate'; { path: COLLECTION_CREATE_PATH, component: CreateCollectionPageComponent, - canActivate: [AuthenticatedGuard, CreateCollectionPageGuard, UserAgreementGuard] + canActivate: [AuthenticatedGuard, CreateCollectionPageGuard, EndUserAgreementGuard] }, { path: ':id', @@ -55,18 +55,18 @@ const ITEMTEMPLATE_PATH = 'itemtemplate'; { path: COLLECTION_EDIT_PATH, loadChildren: './edit-collection-page/edit-collection-page.module#EditCollectionPageModule', - canActivate: [AuthenticatedGuard, UserAgreementGuard] + canActivate: [AuthenticatedGuard, EndUserAgreementGuard] }, { path: 'delete', pathMatch: 'full', component: DeleteCollectionPageComponent, - canActivate: [AuthenticatedGuard, UserAgreementGuard], + canActivate: [AuthenticatedGuard, EndUserAgreementGuard], }, { path: ITEMTEMPLATE_PATH, component: EditItemTemplatePageComponent, - canActivate: [AuthenticatedGuard, UserAgreementGuard], + canActivate: [AuthenticatedGuard, EndUserAgreementGuard], resolve: { item: ItemTemplatePageResolver, breadcrumb: I18nBreadcrumbResolver @@ -82,7 +82,7 @@ const ITEMTEMPLATE_PATH = 'itemtemplate'; path: '/edit/mapper', component: CollectionItemMapperComponent, pathMatch: 'full', - canActivate: [AuthenticatedGuard, UserAgreementGuard] + canActivate: [AuthenticatedGuard, EndUserAgreementGuard] } ] }, diff --git a/src/app/+community-page/community-page-routing.module.ts b/src/app/+community-page/community-page-routing.module.ts index b5c3d9d2d6..be09a2c48f 100644 --- a/src/app/+community-page/community-page-routing.module.ts +++ b/src/app/+community-page/community-page-routing.module.ts @@ -12,7 +12,7 @@ import { getCommunityModulePath } from '../app-routing.module'; import { CommunityBreadcrumbResolver } from '../core/breadcrumbs/community-breadcrumb.resolver'; import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; -import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; +import { EndUserAgreementGuard } from '../core/end-user-agreement/end-user-agreement.guard'; export const COMMUNITY_PARENT_PARAMETER = 'parent'; @@ -37,7 +37,7 @@ const COMMUNITY_EDIT_PATH = 'edit'; { path: COMMUNITY_CREATE_PATH, component: CreateCommunityPageComponent, - canActivate: [AuthenticatedGuard, CreateCommunityPageGuard, UserAgreementGuard] + canActivate: [AuthenticatedGuard, CreateCommunityPageGuard, EndUserAgreementGuard] }, { path: ':id', @@ -50,13 +50,13 @@ const COMMUNITY_EDIT_PATH = 'edit'; { path: COMMUNITY_EDIT_PATH, loadChildren: './edit-community-page/edit-community-page.module#EditCommunityPageModule', - canActivate: [AuthenticatedGuard, UserAgreementGuard] + canActivate: [AuthenticatedGuard, EndUserAgreementGuard] }, { path: 'delete', pathMatch: 'full', component: DeleteCommunityPageComponent, - canActivate: [AuthenticatedGuard, UserAgreementGuard], + canActivate: [AuthenticatedGuard, EndUserAgreementGuard], }, { path: '', diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index cc75a7ebc4..436f6990ba 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -11,7 +11,7 @@ import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.reso import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; -import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; +import { EndUserAgreementGuard } from '../core/end-user-agreement/end-user-agreement.guard'; export function getItemPageRoute(itemId: string) { return new URLCombiner(getItemModulePath(), itemId).toString(); @@ -47,12 +47,12 @@ const UPLOAD_BITSTREAM_PATH = 'bitstreams/new'; { path: ITEM_EDIT_PATH, loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule', - canActivate: [AuthenticatedGuard, UserAgreementGuard] + canActivate: [AuthenticatedGuard, EndUserAgreementGuard] }, { path: UPLOAD_BITSTREAM_PATH, component: UploadBitstreamComponent, - canActivate: [AuthenticatedGuard, UserAgreementGuard] + canActivate: [AuthenticatedGuard, EndUserAgreementGuard] } ], } diff --git a/src/app/+submit-page/submit-page-routing.module.ts b/src/app/+submit-page/submit-page-routing.module.ts index f15d0a488c..a66e2636a1 100644 --- a/src/app/+submit-page/submit-page-routing.module.ts +++ b/src/app/+submit-page/submit-page-routing.module.ts @@ -3,13 +3,13 @@ import { RouterModule } from '@angular/router'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { SubmissionSubmitComponent } from '../submission/submit/submission-submit.component'; -import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; +import { EndUserAgreementGuard } from '../core/end-user-agreement/end-user-agreement.guard'; @NgModule({ imports: [ RouterModule.forChild([ { - canActivate: [AuthenticatedGuard, UserAgreementGuard], + canActivate: [AuthenticatedGuard, EndUserAgreementGuard], path: '', pathMatch: 'full', component: SubmissionSubmitComponent, diff --git a/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts b/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts index a8aa4f93a0..585a6377ae 100644 --- a/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts +++ b/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts @@ -8,7 +8,7 @@ import { getWorkflowItemModulePath } from '../app-routing.module'; import { WorkflowItemDeleteComponent } from './workflow-item-delete/workflow-item-delete.component'; import { WorkflowItemPageResolver } from './workflow-item-page.resolver'; import { WorkflowItemSendBackComponent } from './workflow-item-send-back/workflow-item-send-back.component'; -import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; +import { EndUserAgreementGuard } from '../core/end-user-agreement/end-user-agreement.guard'; export function getWorkflowItemPageRoute(wfiId: string) { return new URLCombiner(getWorkflowItemModulePath(), wfiId).toString(); @@ -38,19 +38,19 @@ const WORKFLOW_ITEM_SEND_BACK_PATH = 'sendback'; resolve: { wfi: WorkflowItemPageResolver }, children: [ { - canActivate: [AuthenticatedGuard, UserAgreementGuard], + canActivate: [AuthenticatedGuard, EndUserAgreementGuard], path: WORKFLOW_ITEM_EDIT_PATH, component: SubmissionEditComponent, data: { title: 'submission.edit.title' } }, { - canActivate: [AuthenticatedGuard, UserAgreementGuard], + canActivate: [AuthenticatedGuard, EndUserAgreementGuard], path: WORKFLOW_ITEM_DELETE_PATH, component: WorkflowItemDeleteComponent, data: { title: 'workflow-item.delete.title' } }, { - canActivate: [AuthenticatedGuard, UserAgreementGuard], + canActivate: [AuthenticatedGuard, EndUserAgreementGuard], path: WORKFLOW_ITEM_SEND_BACK_PATH, component: WorkflowItemSendBackComponent, data: { title: 'workflow-item.send-back.title' } diff --git a/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts index 9dce207baf..0548b488c4 100644 --- a/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts +++ b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts @@ -3,14 +3,14 @@ import { RouterModule } from '@angular/router'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { SubmissionEditComponent } from '../submission/edit/submission-edit.component'; -import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; +import { EndUserAgreementGuard } from '../core/end-user-agreement/end-user-agreement.guard'; @NgModule({ imports: [ RouterModule.forChild([ { path: '', redirectTo: '/home', pathMatch: 'full' }, { - canActivate: [AuthenticatedGuard, UserAgreementGuard], + canActivate: [AuthenticatedGuard, EndUserAgreementGuard], path: ':id/edit', component: SubmissionEditComponent, data: { title: 'submission.edit.title' } diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index de077a5b3a..ed27cc1b93 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -12,7 +12,7 @@ import { getItemPageRoute } from './+item-page/item-page-routing.module'; import { getCollectionPageRoute } from './+collection-page/collection-page-routing.module'; import { SiteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { UnauthorizedComponent } from './unauthorized/unauthorized.component'; -import { UserAgreementGuard } from './core/user-agreement/user-agreement.guard'; +import { EndUserAgreementGuard } from './core/end-user-agreement/end-user-agreement.guard'; const ITEM_MODULE_PATH = 'items'; @@ -109,11 +109,11 @@ export function getInfoModulePath() { { path: 'mydspace', loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', - canActivate: [AuthenticatedGuard, UserAgreementGuard] + canActivate: [AuthenticatedGuard, EndUserAgreementGuard] }, { path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule' }, { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'}, - { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard, UserAgreementGuard] }, + { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard, EndUserAgreementGuard] }, { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, @@ -127,9 +127,9 @@ export function getInfoModulePath() { }, { path: PROFILE_MODULE_PATH, - loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard, UserAgreementGuard] + loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard, EndUserAgreementGuard] }, - { path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard, UserAgreementGuard] }, + { path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard, EndUserAgreementGuard] }, { path: INFO_MODULE_PATH, loadChildren: './info/info.module#InfoModule' }, { path: UNAUTHORIZED_PATH, component: UnauthorizedComponent }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index f1c4ebd121..c68d0311f2 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -162,8 +162,8 @@ import { SubmissionCcLicenceUrl } from './submission/models/submission-cc-licens import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-license-url-data.service'; import { ConfigurationDataService } from './data/configuration-data.service'; import { ConfigurationProperty } from './shared/configuration-property.model'; -import { UserAgreementGuard } from './user-agreement/user-agreement.guard'; -import { UserAgreementService } from './user-agreement/user-agreement.service'; +import { EndUserAgreementGuard } from './end-user-agreement/end-user-agreement.guard'; +import { EndUserAgreementService } from './end-user-agreement/end-user-agreement.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -291,8 +291,8 @@ const PROVIDERS = [ MetadataSchemaDataService, MetadataFieldDataService, TokenResponseParsingService, - UserAgreementGuard, - UserAgreementService, + EndUserAgreementGuard, + EndUserAgreementService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, diff --git a/src/app/core/user-agreement/user-agreement.guard.ts b/src/app/core/end-user-agreement/end-user-agreement.guard.ts similarity index 80% rename from src/app/core/user-agreement/user-agreement.guard.ts rename to src/app/core/end-user-agreement/end-user-agreement.guard.ts index 7464b87e00..450385984a 100644 --- a/src/app/core/user-agreement/user-agreement.guard.ts +++ b/src/app/core/end-user-agreement/end-user-agreement.guard.ts @@ -2,16 +2,16 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; import { Observable } from 'rxjs/internal/Observable'; import { returnEndUserAgreementUrlTreeOnFalse } from '../shared/operators'; -import { UserAgreementService } from './user-agreement.service'; +import { EndUserAgreementService } from './end-user-agreement.service'; import { tap } from 'rxjs/operators'; /** * A guard redirecting users to the end agreement page when they haven't accepted the latest user agreement */ @Injectable() -export class UserAgreementGuard implements CanActivate { +export class EndUserAgreementGuard implements CanActivate { - constructor(protected userAgreementService: UserAgreementService, + constructor(protected endUserAgreementService: EndUserAgreementService, protected router: Router) { } @@ -22,7 +22,7 @@ export class UserAgreementGuard implements CanActivate { * when they're finished accepting the agreement */ canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return this.userAgreementService.hasCurrentUserAcceptedAgreement().pipe( + return this.endUserAgreementService.hasCurrentUserAcceptedAgreement().pipe( returnEndUserAgreementUrlTreeOnFalse(this.router), tap((result) => { if (result instanceof UrlTree) { diff --git a/src/app/core/user-agreement/user-agreement.service.ts b/src/app/core/end-user-agreement/end-user-agreement.service.ts similarity index 68% rename from src/app/core/user-agreement/user-agreement.service.ts rename to src/app/core/end-user-agreement/end-user-agreement.service.ts index 8e5694d81c..025fc6fc32 100644 --- a/src/app/core/user-agreement/user-agreement.service.ts +++ b/src/app/core/end-user-agreement/end-user-agreement.service.ts @@ -10,14 +10,14 @@ import { Metadata } from '../shared/metadata.utils'; import { EPersonDataService } from '../eperson/eperson-data.service'; import { getSucceededRemoteData } from '../shared/operators'; -export const USER_AGREEMENT_COOKIE = 'hasAgreedEndUser'; -export const USER_AGREEMENT_METADATA_FIELD = 'dspace.agreements.end-user'; +export const END_USER_AGREEMENT_COOKIE = 'hasAgreedEndUser'; +export const END_USER_AGREEMENT_METADATA_FIELD = 'dspace.agreements.end-user'; /** * Service for checking and managing the status of the current end user agreement */ @Injectable() -export class UserAgreementService { +export class EndUserAgreementService { constructor(protected cookie: CookieService, protected authService: AuthService, @@ -28,14 +28,14 @@ export class UserAgreementService { * Whether or not the current user has accepted the End User Agreement */ hasCurrentUserAcceptedAgreement(): Observable { - if (this.cookie.get(USER_AGREEMENT_COOKIE) === true) { + if (this.isCookieAccepted()) { return observableOf(true); } else { return this.authService.isAuthenticated().pipe( switchMap((authenticated) => { if (authenticated) { return this.authService.getAuthenticatedUserFromStore().pipe( - map((user) => hasValue(user) && user.hasMetadata(USER_AGREEMENT_METADATA_FIELD) && user.firstMetadata(USER_AGREEMENT_METADATA_FIELD).value === 'true') + map((user) => hasValue(user) && user.hasMetadata(END_USER_AGREEMENT_METADATA_FIELD) && user.firstMetadata(END_USER_AGREEMENT_METADATA_FIELD).value === 'true') ); } else { return observableOf(false); @@ -58,14 +58,14 @@ export class UserAgreementService { return this.authService.getAuthenticatedUserFromStore().pipe( switchMap((user) => { const updatedUser = cloneDeep(user); - Metadata.setFirstValue(updatedUser.metadata, USER_AGREEMENT_METADATA_FIELD, String(accepted)); + Metadata.setFirstValue(updatedUser.metadata, END_USER_AGREEMENT_METADATA_FIELD, String(accepted)); return this.ePersonService.update(updatedUser); }), getSucceededRemoteData(), map((rd) => hasValue(rd.payload)) ); } else { - this.cookie.set(USER_AGREEMENT_COOKIE, accepted); + this.setCookieAccepted(accepted); return observableOf(true); } }), @@ -73,4 +73,26 @@ export class UserAgreementService { ); } + /** + * Is the End User Agreement accepted in the cookie? + */ + isCookieAccepted(): boolean { + return this.cookie.get(END_USER_AGREEMENT_COOKIE) === true; + } + + /** + * Set the cookie's End User Agreement accepted state + * @param accepted + */ + setCookieAccepted(accepted: boolean) { + this.cookie.set(END_USER_AGREEMENT_COOKIE, accepted); + } + + /** + * Remove the End User Agreement cookie + */ + removeCookieAccepted() { + this.cookie.remove(END_USER_AGREEMENT_COOKIE); + } + } diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.html b/src/app/info/end-user-agreement/end-user-agreement.component.html index 624264e2cc..628718cdcd 100644 --- a/src/app/info/end-user-agreement/end-user-agreement.component.html +++ b/src/app/info/end-user-agreement/end-user-agreement.component.html @@ -7,7 +7,7 @@
- +
diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.ts b/src/app/info/end-user-agreement/end-user-agreement.component.ts index 5da59bf5c4..f86b0c3434 100644 --- a/src/app/info/end-user-agreement/end-user-agreement.component.ts +++ b/src/app/info/end-user-agreement/end-user-agreement.component.ts @@ -5,7 +5,7 @@ import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { AppState } from '../../app.reducer'; import { LogOutAction } from '../../core/auth/auth.actions'; -import { UserAgreementService } from '../../core/user-agreement/user-agreement.service'; +import { EndUserAgreementService } from '../../core/end-user-agreement/end-user-agreement.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; import { hasValue } from '../../shared/empty.util'; @@ -25,7 +25,7 @@ export class EndUserAgreementComponent implements OnInit { */ accepted = false; - constructor(protected userAgreementService: UserAgreementService, + constructor(protected endUserAgreementService: EndUserAgreementService, protected notificationsService: NotificationsService, protected translate: TranslateService, protected authService: AuthService, @@ -44,7 +44,7 @@ export class EndUserAgreementComponent implements OnInit { * Initialize the "accepted" property of this component by checking if the current user has accepted it before */ initAccepted() { - this.userAgreementService.hasCurrentUserAcceptedAgreement().subscribe((accepted) => { + this.endUserAgreementService.hasCurrentUserAcceptedAgreement().subscribe((accepted) => { this.accepted = accepted; }); } @@ -54,7 +54,7 @@ export class EndUserAgreementComponent implements OnInit { * Set the End User Agreement, display a notification and (optionally) redirect the user back to their original destination */ submit() { - this.userAgreementService.setUserAcceptedAgreement(this.accepted).subscribe((success) => { + this.endUserAgreementService.setUserAcceptedAgreement(this.accepted).subscribe((success) => { if (success) { this.notificationsService.success(this.translate.instant('info.end-user-agreement.accept.success')); const redirect = window.history.state.redirect; diff --git a/src/app/info/info-routing.module.ts b/src/app/info/info-routing.module.ts index bfb8b77d96..eab8cd32af 100644 --- a/src/app/info/info-routing.module.ts +++ b/src/app/info/info-routing.module.ts @@ -3,13 +3,19 @@ import { RouterModule } from '@angular/router'; import { EndUserAgreementComponent } from './end-user-agreement/end-user-agreement.component'; import { getInfoModulePath } from '../app-routing.module'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { PrivacyComponent } from './privacy/privacy.component'; const END_USER_AGREEMENT_PATH = 'end-user-agreement'; +const PRIVACY_PATH = 'privacy'; export function getEndUserAgreementPath() { return getSubPath(END_USER_AGREEMENT_PATH); } +export function getPrivacyPath() { + return getSubPath(PRIVACY_PATH); +} + function getSubPath(path: string) { return `${getInfoModulePath()}/${path}`; } @@ -23,6 +29,14 @@ function getSubPath(path: string) { resolve: { breadcrumb: I18nBreadcrumbResolver }, data: { title: 'info.end-user-agreement.title', breadcrumbKey: 'info.end-user-agreement' } } + ]), + RouterModule.forChild([ + { + path: PRIVACY_PATH, + component: PrivacyComponent, + resolve: { breadcrumb: I18nBreadcrumbResolver }, + data: { title: 'info.privacy.title', breadcrumbKey: 'info.privacy' } + } ]) ] }) diff --git a/src/app/info/info.module.ts b/src/app/info/info.module.ts index 93923cffe3..ae8ef89b27 100644 --- a/src/app/info/info.module.ts +++ b/src/app/info/info.module.ts @@ -4,6 +4,8 @@ import { SharedModule } from '../shared/shared.module'; import { EndUserAgreementComponent } from './end-user-agreement/end-user-agreement.component'; import { InfoRoutingModule } from './info-routing.module'; import { EndUserAgreementContentComponent } from './end-user-agreement/end-user-agreement-content/end-user-agreement-content.component'; +import { PrivacyComponent } from './privacy/privacy.component'; +import { PrivacyContentComponent } from './privacy/privacy-content/privacy-content.component'; @NgModule({ imports: [ @@ -13,7 +15,9 @@ import { EndUserAgreementContentComponent } from './end-user-agreement/end-user- ], declarations: [ EndUserAgreementComponent, - EndUserAgreementContentComponent + EndUserAgreementContentComponent, + PrivacyComponent, + PrivacyContentComponent ] }) export class InfoModule { diff --git a/src/app/info/privacy/privacy-content/privacy-content.component.html b/src/app/info/privacy/privacy-content/privacy-content.component.html new file mode 100644 index 0000000000..a5bbb3fe10 --- /dev/null +++ b/src/app/info/privacy/privacy-content/privacy-content.component.html @@ -0,0 +1,37 @@ +

{{ 'info.privacy.head' | translate }}

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Nunc sed velit dignissim sodales ut eu. In ante metus dictum at tempor. Diam phasellus vestibulum lorem sed risus. Sed cras ornare arcu dui vivamus. Sit amet consectetur adipiscing elit pellentesque. Id velit ut tortor pretium viverra suspendisse potenti. Sed euismod nisi porta lorem mollis aliquam ut. Justo laoreet sit amet cursus sit amet dictum sit. Ullamcorper morbi tincidunt ornare massa eget egestas. +

+

+ In iaculis nunc sed augue lacus. Curabitur vitae nunc sed velit dignissim sodales ut eu sem. Tellus id interdum velit laoreet id donec ultrices tincidunt arcu. Quis vel eros donec ac odio tempor. Viverra accumsan in nisl nisi scelerisque eu ultrices vitae. Varius quam quisque id diam vel quam. Nisl tincidunt eget nullam non nisi est sit. Nunc aliquet bibendum enim facilisis. Aenean sed adipiscing diam donec adipiscing. Convallis tellus id interdum velit laoreet. Massa placerat duis ultricies lacus sed turpis tincidunt. Sed cras ornare arcu dui vivamus arcu. Egestas integer eget aliquet nibh praesent tristique. Sit amet purus gravida quis blandit turpis cursus in hac. Porta non pulvinar neque laoreet suspendisse. Quis risus sed vulputate odio ut. Dignissim enim sit amet venenatis urna cursus. +

+

+ Interdum velit laoreet id donec ultrices tincidunt arcu non sodales. Massa sapien faucibus et molestie. Dictumst vestibulum rhoncus est pellentesque elit ullamcorper. Metus dictum at tempor commodo ullamcorper. Tincidunt lobortis feugiat vivamus at augue eget. Non diam phasellus vestibulum lorem sed risus ultricies. Neque aliquam vestibulum morbi blandit cursus risus at ultrices mi. Euismod lacinia at quis risus sed. Lorem mollis aliquam ut porttitor leo a diam. Ipsum dolor sit amet consectetur. Ante in nibh mauris cursus mattis molestie a iaculis at. Commodo ullamcorper a lacus vestibulum. Pellentesque elit eget gravida cum sociis. Sit amet commodo nulla facilisi nullam vehicula. Vehicula ipsum a arcu cursus vitae congue mauris rhoncus aenean. +

+

+ Ac turpis egestas maecenas pharetra convallis. Lacus sed viverra tellus in. Nullam eget felis eget nunc lobortis mattis aliquam faucibus purus. Id aliquet risus feugiat in ante metus dictum at. Quis enim lobortis scelerisque fermentum dui faucibus. Eu volutpat odio facilisis mauris sit amet massa vitae tortor. Tellus elementum sagittis vitae et leo. Cras sed felis eget velit aliquet sagittis. Proin fermentum leo vel orci porta non pulvinar neque laoreet. Dui sapien eget mi proin sed libero enim. Ultrices mi tempus imperdiet nulla malesuada. Mattis molestie a iaculis at. Turpis massa sed elementum tempus egestas. +

+

+ Dui faucibus in ornare quam viverra orci sagittis eu volutpat. Cras adipiscing enim eu turpis. Ac felis donec et odio pellentesque. Iaculis nunc sed augue lacus viverra vitae congue eu consequat. Posuere lorem ipsum dolor sit amet consectetur adipiscing elit duis. Elit eget gravida cum sociis natoque penatibus. Id faucibus nisl tincidunt eget nullam non. Sagittis aliquam malesuada bibendum arcu vitae. Fermentum leo vel orci porta. Aliquam ultrices sagittis orci a scelerisque purus semper. Diam maecenas sed enim ut sem viverra aliquet eget sit. Et ultrices neque ornare aenean euismod. Eu mi bibendum neque egestas congue quisque egestas diam. Eget lorem dolor sed viverra. Ut lectus arcu bibendum at. Rutrum tellus pellentesque eu tincidunt tortor. Vitae congue eu consequat ac. Elit ullamcorper dignissim cras tincidunt. Sit amet volutpat consequat mauris nunc congue nisi. +

+

+ Cursus in hac habitasse platea dictumst quisque sagittis purus. Placerat duis ultricies lacus sed turpis tincidunt. In egestas erat imperdiet sed euismod nisi porta lorem mollis. Non nisi est sit amet facilisis magna. In massa tempor nec feugiat nisl pretium fusce. Pulvinar neque laoreet suspendisse interdum consectetur. Ullamcorper morbi tincidunt ornare massa eget egestas purus viverra accumsan. Fringilla urna porttitor rhoncus dolor purus non enim. Mauris nunc congue nisi vitae suscipit. Commodo elit at imperdiet dui accumsan sit amet nulla. Tempor id eu nisl nunc mi ipsum faucibus. Porta non pulvinar neque laoreet suspendisse. Nec nam aliquam sem et tortor consequat. +

+

+ Eget nunc lobortis mattis aliquam faucibus purus. Odio tempor orci dapibus ultrices. Sed nisi lacus sed viverra tellus. Elit ullamcorper dignissim cras tincidunt. Porttitor rhoncus dolor purus non enim praesent elementum facilisis. Viverra orci sagittis eu volutpat odio. Pharetra massa massa ultricies mi quis. Lectus vestibulum mattis ullamcorper velit sed ullamcorper. Pulvinar neque laoreet suspendisse interdum consectetur. Vitae auctor eu augue ut. Arcu dictum varius duis at consectetur lorem donec. Massa sed elementum tempus egestas sed sed. Risus viverra adipiscing at in tellus integer. Vulputate enim nulla aliquet porttitor lacus luctus accumsan. Pharetra massa massa ultricies mi. Elementum eu facilisis sed odio morbi quis commodo odio. Tincidunt lobortis feugiat vivamus at. Felis donec et odio pellentesque diam volutpat commodo sed. Risus feugiat in ante metus dictum at tempor commodo ullamcorper. Fringilla phasellus faucibus scelerisque eleifend donec pretium vulputate. +

+

+ Lectus proin nibh nisl condimentum id venenatis a condimentum. Id consectetur purus ut faucibus pulvinar elementum integer enim. Non pulvinar neque laoreet suspendisse interdum consectetur. Est pellentesque elit ullamcorper dignissim cras tincidunt lobortis feugiat vivamus. Suscipit tellus mauris a diam maecenas sed enim ut sem. Dolor purus non enim praesent elementum facilisis. Non enim praesent elementum facilisis leo vel. Ultricies leo integer malesuada nunc vel risus commodo viverra maecenas. Nulla porttitor massa id neque aliquam vestibulum. Erat velit scelerisque in dictum non consectetur. Amet cursus sit amet dictum. Nec tincidunt praesent semper feugiat nibh. Rutrum quisque non tellus orci ac auctor. Sagittis aliquam malesuada bibendum arcu vitae elementum. Massa tincidunt dui ut ornare lectus sit amet est. Aliquet porttitor lacus luctus accumsan tortor posuere ac. Quis hendrerit dolor magna eget est lorem ipsum dolor sit. Lectus mauris ultrices eros in. +

+

+ Massa massa ultricies mi quis hendrerit dolor magna. Est ullamcorper eget nulla facilisi etiam dignissim diam. Vulputate sapien nec sagittis aliquam malesuada. Nisi porta lorem mollis aliquam ut porttitor leo a diam. Tempus quam pellentesque nec nam. Faucibus vitae aliquet nec ullamcorper sit amet risus nullam eget. Gravida arcu ac tortor dignissim convallis aenean et tortor. A scelerisque purus semper eget duis at tellus at. Viverra ipsum nunc aliquet bibendum enim. Semper feugiat nibh sed pulvinar proin gravida hendrerit. Et ultrices neque ornare aenean euismod. Consequat semper viverra nam libero justo laoreet. Nunc mattis enim ut tellus elementum sagittis. Consectetur lorem donec massa sapien faucibus et. Vel risus commodo viverra maecenas accumsan lacus vel facilisis. Diam sollicitudin tempor id eu nisl nunc. Dolor magna eget est lorem ipsum dolor. Adipiscing elit pellentesque habitant morbi tristique. +

+

+ Nec sagittis aliquam malesuada bibendum arcu vitae elementum curabitur. Egestas fringilla phasellus faucibus scelerisque eleifend donec pretium vulputate sapien. Porttitor leo a diam sollicitudin tempor. Pellentesque dignissim enim sit amet venenatis urna cursus eget nunc. Posuere sollicitudin aliquam ultrices sagittis orci a scelerisque. Vehicula ipsum a arcu cursus vitae congue mauris rhoncus. Leo urna molestie at elementum. Duis tristique sollicitudin nibh sit amet commodo nulla facilisi. Libero id faucibus nisl tincidunt eget nullam. Tellus elementum sagittis vitae et leo duis ut diam. Sodales ut etiam sit amet nisl purus in mollis. Ipsum faucibus vitae aliquet nec ullamcorper sit amet risus. Lacus laoreet non curabitur gravida arcu ac tortor dignissim convallis. Aliquam malesuada bibendum arcu vitae elementum. Leo vel orci porta non pulvinar neque laoreet. Ipsum suspendisse ultrices gravida dictum fusce. +

+

+ Egestas erat imperdiet sed euismod nisi porta lorem. Venenatis a condimentum vitae sapien pellentesque habitant. Sit amet luctus venenatis lectus magna fringilla urna porttitor. Orci sagittis eu volutpat odio facilisis mauris sit amet massa. Ut enim blandit volutpat maecenas volutpat blandit aliquam. Libero volutpat sed cras ornare. Molestie ac feugiat sed lectus vestibulum mattis ullamcorper velit sed. Diam quis enim lobortis scelerisque fermentum dui. Pellentesque habitant morbi tristique senectus et netus. Auctor urna nunc id cursus metus aliquam eleifend. Elit scelerisque mauris pellentesque pulvinar pellentesque habitant morbi tristique. Sed risus ultricies tristique nulla aliquet enim tortor. Tincidunt arcu non sodales neque sodales ut. Sed lectus vestibulum mattis ullamcorper velit sed ullamcorper morbi tincidunt. +

+

+ Pulvinar etiam non quam lacus suspendisse faucibus. Eu mi bibendum neque egestas congue. Egestas purus viverra accumsan in nisl nisi scelerisque eu. Vulputate enim nulla aliquet porttitor lacus luctus accumsan. Eu non diam phasellus vestibulum. Semper feugiat nibh sed pulvinar. Ante in nibh mauris cursus mattis molestie a. Maecenas accumsan lacus vel facilisis volutpat. Non quam lacus suspendisse faucibus. Quis commodo odio aenean sed adipiscing. Vel elit scelerisque mauris pellentesque pulvinar pellentesque habitant morbi. Sed cras ornare arcu dui vivamus arcu felis. Tortor vitae purus faucibus ornare suspendisse sed. Morbi tincidunt ornare massa eget egestas purus viverra. Nibh cras pulvinar mattis nunc. Luctus venenatis lectus magna fringilla urna porttitor. Enim blandit volutpat maecenas volutpat blandit aliquam etiam erat. Malesuada pellentesque elit eget gravida cum sociis natoque penatibus et. Felis eget nunc lobortis mattis aliquam faucibus purus in. Vivamus arcu felis bibendum ut. +

diff --git a/src/app/info/privacy/privacy-content/privacy-content.component.scss b/src/app/info/privacy/privacy-content/privacy-content.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/info/privacy/privacy-content/privacy-content.component.spec.ts b/src/app/info/privacy/privacy-content/privacy-content.component.spec.ts new file mode 100644 index 0000000000..eca0659147 --- /dev/null +++ b/src/app/info/privacy/privacy-content/privacy-content.component.spec.ts @@ -0,0 +1,24 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { PrivacyContentComponent } from './privacy-content.component'; + +describe('PrivacyContentComponent', () => { + let component: PrivacyContentComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ PrivacyContentComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PrivacyContentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/info/privacy/privacy-content/privacy-content.component.ts b/src/app/info/privacy/privacy-content/privacy-content.component.ts new file mode 100644 index 0000000000..6a7b394cf4 --- /dev/null +++ b/src/app/info/privacy/privacy-content/privacy-content.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-privacy-content', + templateUrl: './privacy-content.component.html', + styleUrls: ['./privacy-content.component.scss'] +}) +/** + * Component displaying the contents of the Privacy Statement + */ +export class PrivacyContentComponent { +} diff --git a/src/app/info/privacy/privacy.component.html b/src/app/info/privacy/privacy.component.html new file mode 100644 index 0000000000..c6772e98f2 --- /dev/null +++ b/src/app/info/privacy/privacy.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/info/privacy/privacy.component.scss b/src/app/info/privacy/privacy.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/info/privacy/privacy.component.spec.ts b/src/app/info/privacy/privacy.component.spec.ts new file mode 100644 index 0000000000..b7daa30029 --- /dev/null +++ b/src/app/info/privacy/privacy.component.spec.ts @@ -0,0 +1,24 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { PrivacyComponent } from './privacy.component'; + +describe('PrivacyComponent', () => { + let component: PrivacyComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ PrivacyComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PrivacyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/info/privacy/privacy.component.ts b/src/app/info/privacy/privacy.component.ts new file mode 100644 index 0000000000..dc9d3d69dc --- /dev/null +++ b/src/app/info/privacy/privacy.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-privacy', + templateUrl: './privacy.component.html', + styleUrls: ['./privacy.component.scss'] +}) +/** + * Component displaying the Privacy Statement + */ +export class PrivacyComponent { +} diff --git a/src/app/process-page/process-page-routing.module.ts b/src/app/process-page/process-page-routing.module.ts index 92d55467bc..6e9ecc4b29 100644 --- a/src/app/process-page/process-page-routing.module.ts +++ b/src/app/process-page/process-page-routing.module.ts @@ -7,7 +7,7 @@ import { ProcessDetailComponent } from './detail/process-detail.component'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { ProcessBreadcrumbResolver } from './process-breadcrumb.resolver'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; -import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; +import { EndUserAgreementGuard } from '../core/end-user-agreement/end-user-agreement.guard'; @NgModule({ imports: [ @@ -16,7 +16,7 @@ import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard' path: '', resolve: { breadcrumb: I18nBreadcrumbResolver }, data: { breadcrumbKey: 'process.overview' }, - canActivate: [AuthenticatedGuard, UserAgreementGuard], + canActivate: [AuthenticatedGuard, EndUserAgreementGuard], children: [ { path: '', diff --git a/src/app/register-page/create-profile/create-profile.component.ts b/src/app/register-page/create-profile/create-profile.component.ts index 2755a17739..589e2d741e 100644 --- a/src/app/register-page/create-profile/create-profile.component.ts +++ b/src/app/register-page/create-profile/create-profile.component.ts @@ -14,6 +14,10 @@ import { AuthenticateAction } from '../../core/auth/auth.actions'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { environment } from '../../../environments/environment'; import { isEmpty } from '../../shared/empty.util'; +import { + END_USER_AGREEMENT_METADATA_FIELD, + EndUserAgreementService +} from '../../core/end-user-agreement/end-user-agreement.service'; /** * Component that renders the create profile page to be used by a user registering through a token @@ -41,7 +45,8 @@ export class CreateProfileComponent implements OnInit { private router: Router, private route: ActivatedRoute, private formBuilder: FormBuilder, - private notificationsService: NotificationsService + private notificationsService: NotificationsService, + private endUserAgreementService: EndUserAgreementService ) { } @@ -137,6 +142,16 @@ export class CreateProfileComponent implements OnInit { requireCertificate: false }; + // If the End User Agreement cookie is accepted, add end-user agreement metadata to the user + if (this.endUserAgreementService.isCookieAccepted()) { + values.metadata[END_USER_AGREEMENT_METADATA_FIELD] = [ + { + value: String(true) + } + ]; + this.endUserAgreementService.removeCookieAccepted(); + } + const eperson = Object.assign(new EPerson(), values); this.ePersonDataService.createEPersonForToken(eperson, this.token).subscribe((response) => { if (response.isSuccessful) { diff --git a/src/app/register-page/register-page-routing.module.ts b/src/app/register-page/register-page-routing.module.ts index c199b51c6c..a57450a329 100644 --- a/src/app/register-page/register-page-routing.module.ts +++ b/src/app/register-page/register-page-routing.module.ts @@ -4,7 +4,7 @@ import { RegisterEmailComponent } from './register-email/register-email.componen import { CreateProfileComponent } from './create-profile/create-profile.component'; import { ItemPageResolver } from '../+item-page/item-page.resolver'; import { RegistrationResolver } from '../register-email-form/registration.resolver'; -import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard'; +import { EndUserAgreementGuard } from '../core/end-user-agreement/end-user-agreement.guard'; @NgModule({ imports: [ @@ -18,7 +18,7 @@ import { UserAgreementGuard } from '../core/user-agreement/user-agreement.guard' path: ':token', component: CreateProfileComponent, resolve: {registration: RegistrationResolver}, - canActivate: [UserAgreementGuard] + canActivate: [EndUserAgreementGuard] } ]) ], diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index ca82aca80b..18c1f927f5 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1154,6 +1154,12 @@ "info.end-user-agreement.title": "End User Agreement", + "info.privacy.breadcrumbs": "Privacy Statement", + + "info.privacy.head": "Privacy Statement", + + "info.privacy.title": "Privacy Statement", + "item.edit.authorizations.heading": "With this editor you can view and alter the policies of an item, plus alter policies of individual item components: bundles and bitstreams. Briefly, an item is a container of bundles, and bundles are containers of bitstreams. Containers usually have ADD/REMOVE/READ/WRITE policies, while bitstreams only have READ/WRITE policies.", From de1e1a70d1a7ebe6f3b2bfc4cfe7a37104391f8a Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 21 Aug 2020 16:05:19 +0200 Subject: [PATCH 056/126] 72541: End User Agreement test cases --- .../end-user-agreement.guard.spec.ts | 48 ++++++ .../end-user-agreement.service.spec.ts | 144 ++++++++++++++++++ ...d-user-agreement-content.component.spec.ts | 9 +- .../end-user-agreement.component.html | 4 +- .../end-user-agreement.component.spec.ts | 135 +++++++++++++++- .../privacy-content.component.spec.ts | 9 +- .../info/privacy/privacy.component.spec.ts | 9 +- .../create-profile.component.spec.ts | 143 +++++++++++++---- src/app/shared/mocks/cookie.service.mock.ts | 3 +- 9 files changed, 458 insertions(+), 46 deletions(-) create mode 100644 src/app/core/end-user-agreement/end-user-agreement.guard.spec.ts create mode 100644 src/app/core/end-user-agreement/end-user-agreement.service.spec.ts diff --git a/src/app/core/end-user-agreement/end-user-agreement.guard.spec.ts b/src/app/core/end-user-agreement/end-user-agreement.guard.spec.ts new file mode 100644 index 0000000000..589b227b5a --- /dev/null +++ b/src/app/core/end-user-agreement/end-user-agreement.guard.spec.ts @@ -0,0 +1,48 @@ +import { EndUserAgreementGuard } from './end-user-agreement.guard'; +import { EndUserAgreementService } from './end-user-agreement.service'; +import { Router, UrlTree } from '@angular/router'; +import { of as observableOf } from 'rxjs'; + +describe('EndUserAgreementGuard', () => { + let guard: EndUserAgreementGuard; + + let endUserAgreementService: EndUserAgreementService; + let router: Router; + + beforeEach(() => { + endUserAgreementService = jasmine.createSpyObj('endUserAgreementService', { + hasCurrentUserAcceptedAgreement: observableOf(true) + }); + router = jasmine.createSpyObj('router', { + navigateByUrl: {}, + parseUrl: new UrlTree() + }); + + guard = new EndUserAgreementGuard(endUserAgreementService, router); + }); + + describe('canActivate', () => { + describe('when the user has accepted the agreement', () => { + it('should return true', (done) => { + guard.canActivate(undefined, undefined).subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + }); + + describe('when the user hasn\'t accepted the agreement', () => { + beforeEach(() => { + (endUserAgreementService.hasCurrentUserAcceptedAgreement as jasmine.Spy).and.returnValue(observableOf(false)); + }); + + it('should navigate the user with a redirect url', (done) => { + const redirect = 'redirect/url'; + guard.canActivate(undefined, Object.assign({ url: redirect })).subscribe(() => { + expect(router.navigateByUrl).toHaveBeenCalledWith(jasmine.anything(), { state: { redirect } }); + done(); + }); + }); + }); + }); +}); diff --git a/src/app/core/end-user-agreement/end-user-agreement.service.spec.ts b/src/app/core/end-user-agreement/end-user-agreement.service.spec.ts new file mode 100644 index 0000000000..a292b2371c --- /dev/null +++ b/src/app/core/end-user-agreement/end-user-agreement.service.spec.ts @@ -0,0 +1,144 @@ +import { + END_USER_AGREEMENT_COOKIE, + END_USER_AGREEMENT_METADATA_FIELD, + EndUserAgreementService +} from './end-user-agreement.service'; +import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; +import { of as observableOf } from 'rxjs'; +import { EPerson } from '../eperson/models/eperson.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; + +describe('EndUserAgreementService', () => { + let service: EndUserAgreementService; + + let userWithMetadata: EPerson; + let userWithoutMetadata: EPerson; + + let cookie; + let authService; + let ePersonService; + + beforeEach(() => { + userWithMetadata = Object.assign(new EPerson(), { + metadata: { + [END_USER_AGREEMENT_METADATA_FIELD]: [ + { + value: 'true' + } + ] + } + }); + userWithoutMetadata = Object.assign(new EPerson()); + + cookie = new CookieServiceMock(); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + getAuthenticatedUserFromStore: observableOf(userWithMetadata) + }); + ePersonService = jasmine.createSpyObj('ePersonService', { + update: createSuccessfulRemoteDataObject$(userWithMetadata) + }); + + service = new EndUserAgreementService(cookie, authService, ePersonService); + }); + + describe('when the cookie is set to true', () => { + beforeEach(() => { + cookie.set(END_USER_AGREEMENT_COOKIE, true); + }); + + it('hasCurrentUserAcceptedAgreement should return true', (done) => { + service.hasCurrentUserAcceptedAgreement().subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + + it('isCookieAccepted should return true', () => { + expect(service.isCookieAccepted()).toEqual(true); + }); + + it('removeCookieAccepted should remove the cookie', () => { + service.removeCookieAccepted(); + expect(cookie.get(END_USER_AGREEMENT_COOKIE)).toBeUndefined(); + }); + }); + + describe('when the cookie isn\'t set', () => { + describe('and the user is authenticated', () => { + beforeEach(() => { + (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true)); + }); + + describe('and the user contains agreement metadata', () => { + beforeEach(() => { + (authService.getAuthenticatedUserFromStore as jasmine.Spy).and.returnValue(observableOf(userWithMetadata)); + }); + + it('hasCurrentUserAcceptedAgreement should return true', (done) => { + service.hasCurrentUserAcceptedAgreement().subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + }); + + describe('and the user doesn\'t contain agreement metadata', () => { + beforeEach(() => { + (authService.getAuthenticatedUserFromStore as jasmine.Spy).and.returnValue(observableOf(userWithoutMetadata)); + }); + + it('hasCurrentUserAcceptedAgreement should return false', (done) => { + service.hasCurrentUserAcceptedAgreement().subscribe((result) => { + expect(result).toEqual(false); + done(); + }); + }); + }); + + it('setUserAcceptedAgreement should update the user with new metadata', (done) => { + service.setUserAcceptedAgreement(true).subscribe(() => { + expect(ePersonService.update).toHaveBeenCalledWith(jasmine.objectContaining({ + metadata: jasmine.objectContaining({ + [END_USER_AGREEMENT_METADATA_FIELD]: [ + { + value: 'true' + } + ] + }) + })); + done(); + }); + }); + }); + + describe('and the user is not authenticated', () => { + beforeEach(() => { + (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(false)); + }); + + it('hasCurrentUserAcceptedAgreement should return false', (done) => { + service.hasCurrentUserAcceptedAgreement().subscribe((result) => { + expect(result).toEqual(false); + done(); + }); + }); + + it('setUserAcceptedAgreement should set the cookie to true', (done) => { + service.setUserAcceptedAgreement(true).subscribe(() => { + expect(cookie.get(END_USER_AGREEMENT_COOKIE)).toEqual(true); + done(); + }); + }); + }); + + it('isCookieAccepted should return false', () => { + expect(service.isCookieAccepted()).toEqual(false); + }); + + it('setCookieAccepted should set the cookie', () => { + service.setCookieAccepted(true); + expect(cookie.get(END_USER_AGREEMENT_COOKIE)).toEqual(true); + }); + }); +}); diff --git a/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.spec.ts b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.spec.ts index c95e60846e..d2290cd01c 100644 --- a/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.spec.ts +++ b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.spec.ts @@ -1,5 +1,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { EndUserAgreementContentComponent } from './end-user-agreement-content.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; describe('EndUserAgreementContentComponent', () => { let component: EndUserAgreementContentComponent; @@ -7,9 +9,10 @@ describe('EndUserAgreementContentComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ EndUserAgreementContentComponent ] - }) - .compileComponents(); + imports: [ TranslateModule.forRoot() ], + declarations: [ EndUserAgreementContentComponent ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); })); beforeEach(() => { diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.html b/src/app/info/end-user-agreement/end-user-agreement.component.html index 628718cdcd..2338bfa460 100644 --- a/src/app/info/end-user-agreement/end-user-agreement.component.html +++ b/src/app/info/end-user-agreement/end-user-agreement.component.html @@ -6,8 +6,8 @@
- - + +
diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts b/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts index 6b87a0fccb..5d6b3f904c 100644 --- a/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts +++ b/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts @@ -1,15 +1,58 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { EndUserAgreementComponent } from './end-user-agreement.component'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { EndUserAgreementService } from '../../core/end-user-agreement/end-user-agreement.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthService } from '../../core/auth/auth.service'; +import { Router } from '@angular/router'; +import { of as observableOf } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { By } from '@angular/platform-browser'; +import { LogOutAction } from '../../core/auth/auth.actions'; describe('EndUserAgreementComponent', () => { let component: EndUserAgreementComponent; let fixture: ComponentFixture; + let endUserAgreementService: EndUserAgreementService; + let notificationsService: NotificationsService; + let authService: AuthService; + let store; + let router: Router; + + let redirectUrl; + + function init() { + endUserAgreementService = jasmine.createSpyObj('endUserAgreementService', { + hasCurrentUserAcceptedAgreement: observableOf(false), + setUserAcceptedAgreement: observableOf(true) + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['success', 'error']); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true) + }); + store = jasmine.createSpyObj('store', ['dispatch']); + router = jasmine.createSpyObj('router', ['navigate', 'navigateByUrl']); + + redirectUrl = 'redirect/url'; + window.history.pushState({ redirect: redirectUrl }, ''); + } + beforeEach(async(() => { + init(); TestBed.configureTestingModule({ - declarations: [ EndUserAgreementComponent ] - }) - .compileComponents(); + imports: [ TranslateModule.forRoot() ], + declarations: [ EndUserAgreementComponent ], + providers: [ + { provide: EndUserAgreementService, useValue: endUserAgreementService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: AuthService, useValue: authService }, + { provide: Store, useValue: store }, + { provide: Router, useValue: router } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); })); beforeEach(() => { @@ -18,7 +61,89 @@ describe('EndUserAgreementComponent', () => { fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + describe('when the user hasn\'t accepted the agreement', () => { + beforeEach(() => { + (endUserAgreementService.hasCurrentUserAcceptedAgreement as jasmine.Spy).and.returnValue(observableOf(false)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should initialize the accepted property', () => { + expect(component.accepted).toEqual(false); + }); + + it('should disable the save button', () => { + const button = fixture.debugElement.query(By.css('#button-save')).nativeElement; + expect(button.disabled).toBeTruthy(); + }); + }); + + describe('when the user has accepted the agreement', () => { + beforeEach(() => { + (endUserAgreementService.hasCurrentUserAcceptedAgreement as jasmine.Spy).and.returnValue(observableOf(true)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should initialize the accepted property', () => { + expect(component.accepted).toEqual(true); + }); + + it('should enable the save button', () => { + const button = fixture.debugElement.query(By.css('#button-save')).nativeElement; + expect(button.disabled).toBeFalsy(); + }); + + describe('submit', () => { + describe('when accepting the agreement was successful', () => { + beforeEach(() => { + (endUserAgreementService.setUserAcceptedAgreement as jasmine.Spy).and.returnValue(observableOf(true)); + component.submit(); + }); + + it('should display a success notification', () => { + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should navigate the user to the redirect url', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith(redirectUrl); + }); + }); + + describe('when accepting the agreement was unsuccessful', () => { + beforeEach(() => { + (endUserAgreementService.setUserAcceptedAgreement as jasmine.Spy).and.returnValue(observableOf(false)); + component.submit(); + }); + + it('should display an error notification', () => { + expect(notificationsService.error).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('cancel', () => { + describe('when the user is authenticated', () => { + beforeEach(() => { + (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true)); + component.cancel(); + }); + + it('should logout the user', () => { + expect(store.dispatch).toHaveBeenCalledWith(new LogOutAction()); + }); + }); + + describe('when the user is not authenticated', () => { + beforeEach(() => { + (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(false)); + component.cancel(); + }); + + it('should navigate the user to the homepage', () => { + expect(router.navigate).toHaveBeenCalledWith(['home']); + }); + }); }); }); diff --git a/src/app/info/privacy/privacy-content/privacy-content.component.spec.ts b/src/app/info/privacy/privacy-content/privacy-content.component.spec.ts index eca0659147..a77e809dc3 100644 --- a/src/app/info/privacy/privacy-content/privacy-content.component.spec.ts +++ b/src/app/info/privacy/privacy-content/privacy-content.component.spec.ts @@ -1,5 +1,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { PrivacyContentComponent } from './privacy-content.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; describe('PrivacyContentComponent', () => { let component: PrivacyContentComponent; @@ -7,9 +9,10 @@ describe('PrivacyContentComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ PrivacyContentComponent ] - }) - .compileComponents(); + imports: [ TranslateModule.forRoot() ], + declarations: [ PrivacyContentComponent ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); })); beforeEach(() => { diff --git a/src/app/info/privacy/privacy.component.spec.ts b/src/app/info/privacy/privacy.component.spec.ts index b7daa30029..a3d47e82f9 100644 --- a/src/app/info/privacy/privacy.component.spec.ts +++ b/src/app/info/privacy/privacy.component.spec.ts @@ -1,5 +1,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { PrivacyComponent } from './privacy.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; describe('PrivacyComponent', () => { let component: PrivacyComponent; @@ -7,9 +9,10 @@ describe('PrivacyComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ PrivacyComponent ] - }) - .compileComponents(); + imports: [ TranslateModule.forRoot() ], + declarations: [ PrivacyComponent ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); })); beforeEach(() => { diff --git a/src/app/register-page/create-profile/create-profile.component.spec.ts b/src/app/register-page/create-profile/create-profile.component.spec.ts index a435e1143e..00c2eef99d 100644 --- a/src/app/register-page/create-profile/create-profile.component.spec.ts +++ b/src/app/register-page/create-profile/create-profile.component.spec.ts @@ -18,6 +18,10 @@ import { EPerson } from '../../core/eperson/models/eperson.model'; import { AuthenticateAction } from '../../core/auth/auth.actions'; import { RouterStub } from '../../shared/testing/router.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { + END_USER_AGREEMENT_METADATA_FIELD, + EndUserAgreementService +} from '../../core/end-user-agreement/end-user-agreement.service'; describe('CreateProfileComponent', () => { let comp: CreateProfileComponent; @@ -28,40 +32,80 @@ describe('CreateProfileComponent', () => { let ePersonDataService: EPersonDataService; let notificationsService; let store: Store; + let endUserAgreementService: EndUserAgreementService; const registration = Object.assign(new Registration(), {email: 'test@email.org', token: 'test-token'}); - const values = { - metadata: { - 'eperson.firstname': [ - { - value: 'First' - } - ], - 'eperson.lastname': [ - { - value: 'Last' - }, - ], - 'eperson.phone': [ - { - value: 'Phone' - } - ], - 'eperson.language': [ - { - value: 'en' - } - ] - }, - email: 'test@email.org', - password: 'password', - canLogIn: true, - requireCertificate: false - }; - const eperson = Object.assign(new EPerson(), values); + let values; + let eperson: EPerson; + let valuesWithAgreement; + let epersonWithAgreement: EPerson; beforeEach(async(() => { + values = { + metadata: { + 'eperson.firstname': [ + { + value: 'First' + } + ], + 'eperson.lastname': [ + { + value: 'Last' + }, + ], + 'eperson.phone': [ + { + value: 'Phone' + } + ], + 'eperson.language': [ + { + value: 'en' + } + ] + }, + email: 'test@email.org', + password: 'password', + canLogIn: true, + requireCertificate: false + }; + eperson = Object.assign(new EPerson(), values); + valuesWithAgreement = { + metadata: { + 'eperson.firstname': [ + { + value: 'First' + } + ], + 'eperson.lastname': [ + { + value: 'Last' + }, + ], + 'eperson.phone': [ + { + value: 'Phone' + } + ], + 'eperson.language': [ + { + value: 'en' + } + ], + [END_USER_AGREEMENT_METADATA_FIELD]: [ + { + value: 'true' + } + ] + }, + email: 'test@email.org', + password: 'password', + canLogIn: true, + requireCertificate: false + }; + epersonWithAgreement = Object.assign(new EPerson(), valuesWithAgreement); + route = {data: observableOf({registration: registration})}; router = new RouterStub(); notificationsService = new NotificationsServiceStub(); @@ -74,6 +118,11 @@ describe('CreateProfileComponent', () => { dispatch: {}, }); + endUserAgreementService = jasmine.createSpyObj('endUserAgreementService', { + isCookieAccepted: false, + removeCookieAccepted: {} + }); + TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), ReactiveFormsModule], declarations: [CreateProfileComponent], @@ -84,6 +133,7 @@ describe('CreateProfileComponent', () => { {provide: EPersonDataService, useValue: ePersonDataService}, {provide: FormBuilder, useValue: new FormBuilder()}, {provide: NotificationsService, useValue: notificationsService}, + {provide: EndUserAgreementService, useValue: endUserAgreementService}, ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); @@ -131,6 +181,41 @@ describe('CreateProfileComponent', () => { expect(notificationsService.success).toHaveBeenCalled(); }); + describe('when the end-user-agreement cookie is accepted', () => { + beforeEach(() => { + (endUserAgreementService.isCookieAccepted as jasmine.Spy).and.returnValue(true); + }); + + it('should submit an eperson with agreement metadata for creation and log in on success', () => { + comp.firstName.patchValue('First'); + comp.lastName.patchValue('Last'); + comp.contactPhone.patchValue('Phone'); + comp.language.patchValue('en'); + comp.password = 'password'; + comp.isInValidPassword = false; + + comp.submitEperson(); + + expect(ePersonDataService.createEPersonForToken).toHaveBeenCalledWith(epersonWithAgreement, 'test-token'); + expect(store.dispatch).toHaveBeenCalledWith(new AuthenticateAction('test@email.org', 'password')); + expect(router.navigate).toHaveBeenCalledWith(['/home']); + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should remove the cookie', () => { + comp.firstName.patchValue('First'); + comp.lastName.patchValue('Last'); + comp.contactPhone.patchValue('Phone'); + comp.language.patchValue('en'); + comp.password = 'password'; + comp.isInValidPassword = false; + + comp.submitEperson(); + + expect(endUserAgreementService.removeCookieAccepted).toHaveBeenCalled(); + }); + }); + it('should submit an eperson for creation and stay on page on error', () => { (ePersonDataService.createEPersonForToken as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 500, 'Error'))); diff --git a/src/app/shared/mocks/cookie.service.mock.ts b/src/app/shared/mocks/cookie.service.mock.ts index f94f3d4a7d..17e1b36981 100644 --- a/src/app/shared/mocks/cookie.service.mock.ts +++ b/src/app/shared/mocks/cookie.service.mock.ts @@ -16,7 +16,8 @@ export class CookieServiceMock { return this.cookies.get(name); } - remove() { + remove(name) { + this.cookies.delete(name); return jasmine.createSpy('remove'); } From 7367f9117666d23e149aeef8af9b0011458a8286 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 26 Aug 2020 14:20:47 +0200 Subject: [PATCH 057/126] 72699: Hard redirect after log in --- src/app/app-routing.module.ts | 3 +- src/app/core/auth/auth.effects.ts | 7 --- src/app/core/auth/auth.service.ts | 22 +++----- src/app/core/auth/authenticated.guard.ts | 52 +++++++++---------- src/app/core/core.module.ts | 2 + src/app/core/reload/reload.guard.ts | 17 ++++++ .../services/browser-hard-redirect.service.ts | 19 +++++++ .../core/services/hard-redirect.service.ts | 21 ++++++++ .../services/server-hard-redirect.service.ts | 52 +++++++++++++++++++ src/modules/app/browser-app.module.ts | 21 +++++++- src/modules/app/server-app.module.ts | 6 +++ 11 files changed, 171 insertions(+), 51 deletions(-) create mode 100644 src/app/core/reload/reload.guard.ts create mode 100644 src/app/core/services/browser-hard-redirect.service.ts create mode 100644 src/app/core/services/hard-redirect.service.ts create mode 100644 src/app/core/services/server-hard-redirect.service.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index c8ee6ecd8b..a317cf9334 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -12,6 +12,7 @@ import { getItemPageRoute } from './+item-page/item-page-routing.module'; import { getCollectionPageRoute } from './+collection-page/collection-page-routing.module'; import { SiteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { UnauthorizedComponent } from './unauthorized/unauthorized.component'; +import { ReloadGuard } from './core/reload/reload.guard'; const ITEM_MODULE_PATH = 'items'; @@ -88,7 +89,7 @@ export function getUnauthorizedPath() { imports: [ RouterModule.forRoot([ { path: '', redirectTo: '/home', pathMatch: 'full' }, - { path: 'reload/:rnd', redirectTo: '/home', pathMatch: 'full' }, + { path: 'reload/:rnd', component: PageNotFoundComponent, pathMatch: 'full', canActivate: [ReloadGuard] }, { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false } }, { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' }, { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 37ef3b79bc..7d1750c04e 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -201,13 +201,6 @@ export class AuthEffects { tap(() => this.authService.refreshAfterLogout()) ); - @Effect({ dispatch: false }) - public redirectToLogin$: Observable = this.actions$ - .pipe(ofType(AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED), - tap(() => this.authService.removeToken()), - tap(() => this.authService.redirectToLogin()) - ); - @Effect({ dispatch: false }) public redirectToLoginTokenExpired$: Observable = this.actions$ .pipe( diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 7d854d9d4d..b8e6c6609a 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -36,6 +36,7 @@ import { RouteService } from '../services/route.service'; import { EPersonDataService } from '../eperson/eperson-data.service'; import { getAllSucceededRemoteDataPayload } from '../shared/operators'; import { AuthMethod } from './models/auth.method'; +import { HardRedirectService } from '../services/hard-redirect.service'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -62,7 +63,8 @@ export class AuthService { protected router: Router, protected routeService: RouteService, protected storage: CookieService, - protected store: Store + protected store: Store, + protected hardRedirectService: HardRedirectService ) { this.store.pipe( select(isAuthenticated), @@ -440,26 +442,18 @@ export class AuthService { } protected navigateToRedirectUrl(redirectUrl: string) { - const url = decodeURIComponent(redirectUrl); - // in case the user navigates directly to /login (via bookmark, etc), or the route history is not found. - if (isEmpty(url) || url.startsWith(LOGIN_ROUTE)) { - this.router.navigateByUrl('/'); - /* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */ - // this._window.nativeWindow.location.href = '/'; - } else { - /* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */ - // this._window.nativeWindow.location.href = url; - this.router.navigateByUrl(url); + let url = `/reload/${new Date().getTime()}`; + if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) { + url += `?redirect=${encodeURIComponent(redirectUrl)}`; } + this.hardRedirectService.redirect(url); } /** * Refresh route navigated */ public refreshAfterLogout() { - // Hard redirect to the reload page with a unique number behind it - // so that all state is definitely lost - this._window.nativeWindow.location.href = `/reload/${new Date().getTime()}`; + this.navigateToRedirectUrl(undefined); } /** diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index 7a2f39854c..2e70a70310 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -1,21 +1,27 @@ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + CanActivate, + Route, + Router, + RouterStateSnapshot, + UrlTree +} from '@angular/router'; import { Observable } from 'rxjs'; -import { take } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { select, Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; import { isAuthenticated } from './selectors'; -import { AuthService } from './auth.service'; -import { RedirectWhenAuthenticationIsRequiredAction } from './auth.actions'; +import { AuthService, LOGIN_ROUTE } from './auth.service'; /** * Prevent unauthorized activating and loading of routes * @class AuthenticatedGuard */ @Injectable() -export class AuthenticatedGuard implements CanActivate, CanLoad { +export class AuthenticatedGuard implements CanActivate { /** * @constructor @@ -24,46 +30,38 @@ export class AuthenticatedGuard implements CanActivate, CanLoad { /** * True when user is authenticated + * UrlTree with redirect to login page when user isn't authenticated * @method canActivate */ - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { const url = state.url; return this.handleAuth(url); } /** * True when user is authenticated + * UrlTree with redirect to login page when user isn't authenticated * @method canActivateChild */ - canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { return this.canActivate(route, state); } - /** - * True when user is authenticated - * @method canLoad - */ - canLoad(route: Route): Observable { - const url = `/${route.path}`; - - return this.handleAuth(url); - } - - private handleAuth(url: string): Observable { + private handleAuth(url: string): Observable { // get observable const observable = this.store.pipe(select(isAuthenticated)); // redirect to sign in page if user is not authenticated - observable.pipe( - // .filter(() => isEmpty(this.router.routerState.snapshot.url) || this.router.routerState.snapshot.url === url) - take(1)) - .subscribe((authenticated) => { - if (!authenticated) { + return observable.pipe( + map((authenticated) => { + if (authenticated) { + return authenticated; + } else { this.authService.setRedirectUrl(url); - this.store.dispatch(new RedirectWhenAuthenticationIsRequiredAction('Login required')); + this.authService.removeToken(); + return this.router.createUrlTree([LOGIN_ROUTE]); } - }); - - return observable; + }) + ); } } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 5aa462d5e0..bc2f80830c 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -162,6 +162,7 @@ import { SubmissionCcLicenceUrl } from './submission/models/submission-cc-licens import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-license-url-data.service'; import { ConfigurationDataService } from './data/configuration-data.service'; import { ConfigurationProperty } from './shared/configuration-property.model'; +import { ReloadGuard } from './reload/reload.guard'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -289,6 +290,7 @@ const PROVIDERS = [ MetadataSchemaDataService, MetadataFieldDataService, TokenResponseParsingService, + ReloadGuard, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, diff --git a/src/app/core/reload/reload.guard.ts b/src/app/core/reload/reload.guard.ts new file mode 100644 index 0000000000..9880fabb69 --- /dev/null +++ b/src/app/core/reload/reload.guard.ts @@ -0,0 +1,17 @@ +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; +import { Injectable } from '@angular/core'; +import { isNotEmpty } from '../../shared/empty.util'; + +@Injectable() +export class ReloadGuard implements CanActivate { + constructor(private router: Router) { + } + + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): UrlTree { + if (isNotEmpty(route.queryParams.redirect)) { + return this.router.parseUrl(route.queryParams.redirect); + } else { + return this.router.createUrlTree(['home']); + } + } +} diff --git a/src/app/core/services/browser-hard-redirect.service.ts b/src/app/core/services/browser-hard-redirect.service.ts new file mode 100644 index 0000000000..56c9054297 --- /dev/null +++ b/src/app/core/services/browser-hard-redirect.service.ts @@ -0,0 +1,19 @@ +import {Inject, Injectable} from '@angular/core'; +import {LocationToken} from '../../../modules/app/browser-app.module'; + +@Injectable() +export class BrowserHardRedirectService { + + constructor( + @Inject(LocationToken) protected location: Location, + ) { + } + + redirect(url: string) { + this.location.href = url; + } + + getOriginFromUrl() { + return this.location.origin; + } +} diff --git a/src/app/core/services/hard-redirect.service.ts b/src/app/core/services/hard-redirect.service.ts new file mode 100644 index 0000000000..e2c18b6138 --- /dev/null +++ b/src/app/core/services/hard-redirect.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@angular/core'; + +/** + * Service to take care of hard redirects + */ +@Injectable() +export abstract class HardRedirectService { + + /** + * Perform a hard redirect to a given location. + * + * @param url + * the page to redirect to + */ + abstract redirect(url: string); + + /** + * Get the origin of a request + */ + abstract getOriginFromUrl(); +} diff --git a/src/app/core/services/server-hard-redirect.service.ts b/src/app/core/services/server-hard-redirect.service.ts new file mode 100644 index 0000000000..4d58443cf4 --- /dev/null +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -0,0 +1,52 @@ +import { Inject, Injectable } from '@angular/core'; +import { Request, Response } from 'express'; +import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; + +@Injectable() +export class ServerHardRedirectService { + + constructor( + @Inject(REQUEST) protected req: Request, + @Inject(RESPONSE) protected res: Response, + ) { + } + + redirect(url: string) { + + if (url === this.req.url) { + return; + } + + if (this.res.finished) { + const req: any = this.req; + req._r_count = (req._r_count || 0) + 1; + + console.warn('Attempted to redirect on a finished response. From', + this.req.url, 'to', url); + + if (req._r_count > 10) { + console.error('Detected a redirection loop. killing the nodejs process'); + process.exit(1); + } + } else { + // attempt to use the already set status + let status = this.res.statusCode || 0; + if (status < 300 || status >= 400) { + // temporary redirect + status = 302; + } + + console.log(`Redirecting from ${this.req.url} to ${url} with ${status}`); + this.res.redirect(status, url); + this.res.end(); + // I haven't found a way to correctly stop Angular rendering. + // So we just let it end its work, though we have already closed + // the response. + } + } + + getOriginFromUrl() { + + return new URL(`${this.req.protocol}://${this.req.get('hostname')}`).toString(); + } +} diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index 73a49b0211..44f00fbae4 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -1,5 +1,5 @@ import { HttpClient, HttpClientModule } from '@angular/common/http'; -import { NgModule } from '@angular/core'; +import { InjectionToken, NgModule } from '@angular/core'; import { BrowserModule, makeStateKey, TransferState } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterModule } from '@angular/router'; @@ -21,6 +21,8 @@ import { AuthService } from '../../app/core/auth/auth.service'; import { Angulartics2RouterlessModule } from 'angulartics2/routerlessmodule'; import { SubmissionService } from '../../app/submission/submission.service'; import { StatisticsModule } from '../../app/statistics/statistics.module'; +import { HardRedirectService } from '../../app/core/services/hard-redirect.service'; +import { BrowserHardRedirectService } from '../../app/core/services/browser-hard-redirect.service'; export const REQ_KEY = makeStateKey('req'); @@ -32,6 +34,13 @@ export function getRequest(transferState: TransferState): any { return transferState.get(REQ_KEY, {}); } +export const LocationToken = new InjectionToken('Location'); + +export function locationProvider(): Location { + return window.location; +} + + @NgModule({ bootstrap: [AppComponent], imports: [ @@ -78,7 +87,15 @@ export function getRequest(transferState: TransferState): any { { provide: SubmissionService, useClass: SubmissionService - } + }, + { + provide: HardRedirectService, + useClass: BrowserHardRedirectService, + }, + { + provide: LocationToken, + useFactory: locationProvider, + }, ] }) export class BrowserAppModule { diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index 0ba09182cc..c8ea81bdec 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -29,6 +29,8 @@ import { ServerLocaleService } from 'src/app/core/locale/server-locale.service'; import { LocaleService } from 'src/app/core/locale/locale.service'; import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { ForwardClientIpInterceptor } from '../../app/core/forward-client-ip/forward-client-ip.interceptor'; +import { HardRedirectService } from '../../app/core/services/hard-redirect.service'; +import { ServerHardRedirectService } from '../../app/core/services/server-hard-redirect.service'; export function createTranslateLoader() { return new TranslateJson5UniversalLoader('dist/server/assets/i18n/', '.json5'); @@ -88,6 +90,10 @@ export function createTranslateLoader() { useClass: ForwardClientIpInterceptor, multi: true }, + { + provide: HardRedirectService, + useClass: ServerHardRedirectService, + }, ] }) export class ServerAppModule { From 3178e5578c7703383fdff8d65008de0c845bf461 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Wed, 26 Aug 2020 14:45:53 +0200 Subject: [PATCH 058/126] Fixed issue after merge --- src/app/shared/shared.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 83bb947569..d04d31faa5 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -480,7 +480,7 @@ const ENTRY_COMPONENTS = [ FileDownloadLinkComponent, CurationFormComponent, ExportMetadataSelectorComponent, - ConfirmationModalComponent + ConfirmationModalComponent, VocabularyTreeviewComponent ]; From 87bed6d2882ded2aee9f6c55f8e2087ee0b0d6cd Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Wed, 26 Aug 2020 15:24:01 +0200 Subject: [PATCH 059/126] Fixed check for vocabulary options on DsDynamicListComponent --- .../models/list/dynamic-list.component.ts | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts index d7c492ccde..ad305cb17a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts @@ -55,20 +55,35 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen super(layoutService, validationService); } + /** + * Initialize the component, setting up the field options + */ ngOnInit() { - if (this.hasAuthorityOptions()) { - this.setOptionsFromAuthority(); + if (this.model.vocabularyOptions && hasValue(this.model.vocabularyOptions.name)) { + this.setOptionsFromVocabulary(); } } + /** + * Emits a blur event containing a given value. + * @param event The value to emit. + */ onBlur(event: Event) { this.blur.emit(event); } + /** + * Emits a focus event containing a given value. + * @param event The value to emit. + */ onFocus(event: Event) { this.focus.emit(event); } + /** + * Updates model value with the current value + * @param event The change event. + */ onChange(event: Event) { const target = event.target as any; if (this.model.repeatable) { @@ -89,7 +104,10 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen this.change.emit(event); } - protected setOptionsFromAuthority() { + /** + * Setting up the field options from vocabulary + */ + protected setOptionsFromVocabulary() { if (this.model.vocabularyOptions.name && this.model.vocabularyOptions.name.length > 0) { const listGroup = this.group.controls[this.model.id] as FormGroup; const pageInfo: PageInfo = new PageInfo({ @@ -138,9 +156,4 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen } } - protected hasAuthorityOptions() { - return (hasValue(this.model.vocabularyOptions.scope) - && hasValue(this.model.vocabularyOptions.name) - && hasValue(this.model.vocabularyOptions.metadata)); - } } From 4080cce8fd32eea26c6d52ee11016b97ef9b4b79 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Wed, 26 Aug 2020 15:57:34 +0200 Subject: [PATCH 060/126] Fixed check for Hierarchical Vocabulary --- .../models/onebox/dynamic-onebox.component.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts index b587f9429e..f1e81fb57e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts @@ -56,6 +56,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple preloadLevel: number; private vocabulary$: Observable; + private isHierarchicalVocabulary$: Observable; private subs: Subscription[] = []; constructor(protected vocabularyService: VocabularyService, @@ -123,6 +124,10 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple distinctUntilChanged() ); + this.isHierarchicalVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => result.hierarchical) + ); + this.subs.push(this.group.get(this.model.id).valueChanges.pipe( filter((value) => this.currentValue !== value)) .subscribe((value) => { @@ -143,9 +148,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple * Checks if configured vocabulary is Hierarchical or not */ isHierarchicalVocabulary(): Observable { - return this.vocabulary$.pipe( - map((result: Vocabulary) => result.hierarchical) - ); + return this.isHierarchicalVocabulary$; } /** From 7fbae8997d76c91f93c7d629d9394acab55aa165 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 26 Aug 2020 16:32:03 +0200 Subject: [PATCH 061/126] 72699: JSDocs and Test cases --- src/app/core/auth/auth.service.spec.ts | 34 ++++++++----- src/app/core/reload/reload.guard.spec.ts | 47 ++++++++++++++++++ src/app/core/reload/reload.guard.ts | 9 ++++ .../browser-hard-redirect.service.spec.ts | 40 ++++++++++++++++ .../services/browser-hard-redirect.service.ts | 10 ++++ .../server-hard-redirect.service.spec.ts | 48 +++++++++++++++++++ .../services/server-hard-redirect.service.ts | 10 ++++ 7 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 src/app/core/reload/reload.guard.spec.ts create mode 100644 src/app/core/services/browser-hard-redirect.service.spec.ts create mode 100644 src/app/core/services/server-hard-redirect.service.spec.ts diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index 7f2c1e29cc..ccc77bd413 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -27,6 +27,7 @@ import { EPersonDataService } from '../eperson/eperson-data.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { authMethodsMock } from '../../shared/testing/auth-service.stub'; import { AuthMethod } from './models/auth.method'; +import { HardRedirectService } from '../services/hard-redirect.service'; describe('AuthService test', () => { @@ -48,6 +49,7 @@ describe('AuthService test', () => { let authenticatedState; let unAuthenticatedState; let linkService; + let hardRedirectService; function init() { mockStore = jasmine.createSpyObj('store', { @@ -77,6 +79,7 @@ describe('AuthService test', () => { linkService = { resolveLinks: {} }; + hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['redirect']); spyOn(linkService, 'resolveLinks').and.returnValue({ authenticated: true, eperson: observableOf({ payload: {} }) }); } @@ -104,6 +107,7 @@ describe('AuthService test', () => { { provide: ActivatedRoute, useValue: routeStub }, { provide: Store, useValue: mockStore }, { provide: EPersonDataService, useValue: mockEpersonDataService }, + { provide: HardRedirectService, useValue: hardRedirectService }, CookieService, AuthService ], @@ -210,7 +214,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService); })); it('should return true when user is logged in', () => { @@ -289,7 +293,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService); storage = (authService as any).storage; routeServiceMock = TestBed.get(RouteService); routerStub = TestBed.get(Router); @@ -320,34 +324,34 @@ describe('AuthService test', () => { it('should set redirect url to previous page', () => { spyOn(routeServiceMock, 'getHistory').and.callThrough(); - spyOn(routerStub, 'navigateByUrl'); authService.redirectAfterLoginSuccess(true); expect(routeServiceMock.getHistory).toHaveBeenCalled(); - expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/collection/123'); + // Reload with redirect URL set to /collection/123 + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123')))); }); it('should set redirect url to current page', () => { spyOn(routeServiceMock, 'getHistory').and.callThrough(); - spyOn(routerStub, 'navigateByUrl'); authService.redirectAfterLoginSuccess(false); expect(routeServiceMock.getHistory).toHaveBeenCalled(); - expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/home'); + // Reload with redirect URL set to /home + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/home')))); }); it('should redirect to / and not to /login', () => { spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf(['/login', '/login'])); - spyOn(routerStub, 'navigateByUrl'); authService.redirectAfterLoginSuccess(true); expect(routeServiceMock.getHistory).toHaveBeenCalled(); - expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/'); + // Reload without a redirect URL + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); }); it('should redirect to / when no redirect url is found', () => { spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf([''])); - spyOn(routerStub, 'navigateByUrl'); authService.redirectAfterLoginSuccess(true); expect(routeServiceMock.getHistory).toHaveBeenCalled(); - expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/'); + // Reload without a redirect URL + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); }); describe('impersonate', () => { @@ -464,6 +468,14 @@ describe('AuthService test', () => { }); }); }); + + describe('refreshAfterLogout', () => { + it('should call navigateToRedirectUrl with no url', () => { + spyOn(authService as any, 'navigateToRedirectUrl').and.stub(); + authService.refreshAfterLogout(); + expect((authService as any).navigateToRedirectUrl).toHaveBeenCalled(); + }); + }); }); describe('when user is not logged in', () => { @@ -496,7 +508,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = unAuthenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService); })); it('should return null for the shortlived token', () => { diff --git a/src/app/core/reload/reload.guard.spec.ts b/src/app/core/reload/reload.guard.spec.ts new file mode 100644 index 0000000000..317245bafa --- /dev/null +++ b/src/app/core/reload/reload.guard.spec.ts @@ -0,0 +1,47 @@ +import { ReloadGuard } from './reload.guard'; +import { Router } from '@angular/router'; + +describe('ReloadGuard', () => { + let guard: ReloadGuard; + let router: Router; + + beforeEach(() => { + router = jasmine.createSpyObj('router', ['parseUrl', 'createUrlTree']); + guard = new ReloadGuard(router); + }); + + describe('canActivate', () => { + let route; + + describe('when the route\'s query params contain a redirect url', () => { + let redirectUrl; + + beforeEach(() => { + redirectUrl = '/redirect/url?param=extra'; + route = { + queryParams: { + redirect: redirectUrl + } + }; + }); + + it('should create a UrlTree with the redirect URL', () => { + guard.canActivate(route, undefined); + expect(router.parseUrl).toHaveBeenCalledWith(redirectUrl); + }); + }); + + describe('when the route\'s query params doesn\'t contain a redirect url', () => { + beforeEach(() => { + route = { + queryParams: {} + }; + }); + + it('should create a UrlTree to home', () => { + guard.canActivate(route, undefined); + expect(router.createUrlTree).toHaveBeenCalledWith(['home']); + }); + }); + }); +}); diff --git a/src/app/core/reload/reload.guard.ts b/src/app/core/reload/reload.guard.ts index 9880fabb69..78f9dcf642 100644 --- a/src/app/core/reload/reload.guard.ts +++ b/src/app/core/reload/reload.guard.ts @@ -2,11 +2,20 @@ import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTr import { Injectable } from '@angular/core'; import { isNotEmpty } from '../../shared/empty.util'; +/** + * A guard redirecting the user to the URL provided in the route's query params + * When no redirect url is found, the user is redirected to the homepage + */ @Injectable() export class ReloadGuard implements CanActivate { constructor(private router: Router) { } + /** + * Get the UrlTree of the URL to redirect to + * @param route + * @param state + */ canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): UrlTree { if (isNotEmpty(route.queryParams.redirect)) { return this.router.parseUrl(route.queryParams.redirect); diff --git a/src/app/core/services/browser-hard-redirect.service.spec.ts b/src/app/core/services/browser-hard-redirect.service.spec.ts new file mode 100644 index 0000000000..b94b52d46e --- /dev/null +++ b/src/app/core/services/browser-hard-redirect.service.spec.ts @@ -0,0 +1,40 @@ +import {async, TestBed} from '@angular/core/testing'; +import {BrowserHardRedirectService} from './browser-hard-redirect.service'; + +describe('BrowserHardRedirectService', () => { + + const mockLocation = { + href: undefined, + origin: 'test origin', + } as Location; + + const service: BrowserHardRedirectService = new BrowserHardRedirectService(mockLocation); + + beforeEach(() => { + TestBed.configureTestingModule({}); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('when performing a redirect', () => { + + const redirect = 'test redirect'; + + beforeEach(() => { + service.redirect(redirect); + }); + + it('should update the location', () => { + expect(mockLocation.href).toEqual(redirect); + }) + }); + + describe('when requesting the origin', () => { + + it('should return the location origin', () => { + expect(service.getOriginFromUrl()).toEqual('test origin'); + }); + }); +}); diff --git a/src/app/core/services/browser-hard-redirect.service.ts b/src/app/core/services/browser-hard-redirect.service.ts index 56c9054297..71ce7577d2 100644 --- a/src/app/core/services/browser-hard-redirect.service.ts +++ b/src/app/core/services/browser-hard-redirect.service.ts @@ -1,6 +1,9 @@ import {Inject, Injectable} from '@angular/core'; import {LocationToken} from '../../../modules/app/browser-app.module'; +/** + * Service for performing hard redirects within the browser app module + */ @Injectable() export class BrowserHardRedirectService { @@ -9,10 +12,17 @@ export class BrowserHardRedirectService { ) { } + /** + * Perform a hard redirect to URL + * @param url + */ redirect(url: string) { this.location.href = url; } + /** + * Get the origin of a request + */ getOriginFromUrl() { return this.location.origin; } diff --git a/src/app/core/services/server-hard-redirect.service.spec.ts b/src/app/core/services/server-hard-redirect.service.spec.ts new file mode 100644 index 0000000000..9704c64c74 --- /dev/null +++ b/src/app/core/services/server-hard-redirect.service.spec.ts @@ -0,0 +1,48 @@ +import { TestBed } from '@angular/core/testing'; +import { ServerHardRedirectService } from './server-hard-redirect.service'; + +describe('ServerHardRedirectService', () => { + + const mockRequest = jasmine.createSpyObj(['get']); + const mockResponse = jasmine.createSpyObj(['redirect', 'end']); + + const service: ServerHardRedirectService = new ServerHardRedirectService(mockRequest, mockResponse); + + beforeEach(() => { + TestBed.configureTestingModule({}); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('when performing a redirect', () => { + + const redirect = 'test redirect'; + + beforeEach(() => { + service.redirect(redirect); + }); + + it('should update the response object', () => { + expect(mockResponse.redirect).toHaveBeenCalledWith(302, redirect); + expect(mockResponse.end).toHaveBeenCalled(); + }) + }); + + describe('when requesting the origin', () => { + + beforeEach(() => { + mockRequest.protocol = 'test-protocol'; + mockRequest.get.and.callFake((name) => { + if (name === 'hostname') { + return 'test-host'; + } + }); + }); + + it('should return the location origin', () => { + expect(service.getOriginFromUrl()).toEqual('test-protocol://test-host'); + }); + }); +}); diff --git a/src/app/core/services/server-hard-redirect.service.ts b/src/app/core/services/server-hard-redirect.service.ts index 4d58443cf4..69b6739445 100644 --- a/src/app/core/services/server-hard-redirect.service.ts +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -2,6 +2,9 @@ import { Inject, Injectable } from '@angular/core'; import { Request, Response } from 'express'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; +/** + * Service for performing hard redirects within the server app module + */ @Injectable() export class ServerHardRedirectService { @@ -11,6 +14,10 @@ export class ServerHardRedirectService { ) { } + /** + * Perform a hard redirect to URL + * @param url + */ redirect(url: string) { if (url === this.req.url) { @@ -45,6 +52,9 @@ export class ServerHardRedirectService { } } + /** + * Get the origin of a request + */ getOriginFromUrl() { return new URL(`${this.req.protocol}://${this.req.get('hostname')}`).toString(); From 92207cf66d18148aab06429d7288bc4d8752a79b Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 27 Aug 2020 09:30:38 +0200 Subject: [PATCH 062/126] Added missing TypeDocs --- .../core/data/entries-response-parsing.service.ts | 12 ++++++++++++ .../models/onebox/dynamic-onebox.component.ts | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/src/app/core/data/entries-response-parsing.service.ts b/src/app/core/data/entries-response-parsing.service.ts index ed13c3f228..09ae8ae1c5 100644 --- a/src/app/core/data/entries-response-parsing.service.ts +++ b/src/app/core/data/entries-response-parsing.service.ts @@ -9,6 +9,9 @@ import { RestRequest } from './request.models'; import { CacheableObject } from '../cache/object-cache.reducer'; import { GenericConstructor } from '../shared/generic-constructor'; +/** + * An abstract class to extend, responsible for parsing data for an entries response + */ export abstract class EntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { protected toCache = false; @@ -19,8 +22,17 @@ export abstract class EntriesResponseParsingService e super(); } + /** + * Abstract method to implement that must return the dspace serializer Constructor to use during parse + */ abstract getSerializerModel(): GenericConstructor; + /** + * Parse response + * + * @param request + * @param data + */ parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload)) { let entries = []; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts index f1e81fb57e..11002204f5 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts @@ -30,6 +30,10 @@ import { Vocabulary } from '../../../../../../core/submission/vocabularies/model import { VocabularyTreeviewComponent } from '../../../../../vocabulary-treeview/vocabulary-treeview.component'; import { VocabularyEntryDetail } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +/** + * Component representing a onebox input field. + * If field has a Hierarchical Vocabulary configured, it's rendered with vocabulary tree + */ @Component({ selector: 'ds-dynamic-onebox', styleUrls: ['./dynamic-onebox.component.scss'], From 55c45f5f6c97a9795ba5d61d755e0c8ed372796b Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 27 Aug 2020 14:50:13 +0200 Subject: [PATCH 063/126] 72699: Hard redirect after log in - loading fixes --- src/app/app.component.html | 5 +- src/app/app.component.scss | 4 ++ src/app/app.component.spec.ts | 28 +++++---- src/app/app.component.ts | 13 ++++- src/app/core/auth/auth.actions.ts | 19 +++++- src/app/core/auth/auth.effects.ts | 22 ++++++- src/app/core/auth/auth.reducer.ts | 7 ++- src/app/core/auth/auth.service.spec.ts | 25 ++++---- src/app/core/auth/auth.service.ts | 58 ++++++++++--------- src/app/core/auth/authenticated.guard.ts | 11 ++-- src/app/core/auth/server-auth.service.ts | 28 --------- .../browser-hard-redirect.service.spec.ts | 7 ++- .../services/browser-hard-redirect.service.ts | 11 ++-- .../core/services/hard-redirect.service.ts | 5 +- .../server-hard-redirect.service.spec.ts | 11 +--- .../services/server-hard-redirect.service.ts | 8 +-- .../log-in-container.component.spec.ts | 8 +++ .../shared/log-in/log-in.component.spec.ts | 7 +++ src/app/shared/log-in/log-in.component.ts | 32 +--------- .../log-in-password.component.spec.ts | 10 +++- .../password/log-in-password.component.ts | 8 +++ .../log-in-shibboleth.component.spec.ts | 7 +++ .../shibboleth/log-in-shibboleth.component.ts | 7 +++ src/app/shared/testing/auth-service.stub.ts | 8 +++ src/modules/app/browser-app.module.ts | 1 - 25 files changed, 204 insertions(+), 146 deletions(-) diff --git a/src/app/app.component.html b/src/app/app.component.html index 8656970f31..6d6f89ea35 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,4 +1,7 @@ -
+
+ +
+
{ }); it('should set redirect url to previous page', () => { - spyOn(routeServiceMock, 'getHistory').and.callThrough(); - authService.redirectAfterLoginSuccess(true); - expect(routeServiceMock.getHistory).toHaveBeenCalled(); + (storage.get as jasmine.Spy).and.returnValue('/collection/123'); + authService.redirectAfterLoginSuccess(); // Reload with redirect URL set to /collection/123 expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123')))); }); it('should set redirect url to current page', () => { - spyOn(routeServiceMock, 'getHistory').and.callThrough(); - authService.redirectAfterLoginSuccess(false); - expect(routeServiceMock.getHistory).toHaveBeenCalled(); + (storage.get as jasmine.Spy).and.returnValue('/home'); + authService.redirectAfterLoginSuccess(); // Reload with redirect URL set to /home expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/home')))); }); - it('should redirect to / and not to /login', () => { - spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf(['/login', '/login'])); - authService.redirectAfterLoginSuccess(true); - expect(routeServiceMock.getHistory).toHaveBeenCalled(); + it('should redirect to regular reload and not to /login', () => { + (storage.get as jasmine.Spy).and.returnValue('/login'); + authService.redirectAfterLoginSuccess(); // Reload without a redirect URL expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); }); - it('should redirect to / when no redirect url is found', () => { - spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf([''])); - authService.redirectAfterLoginSuccess(true); - expect(routeServiceMock.getHistory).toHaveBeenCalled(); + it('should not redirect when no redirect url is found', () => { + authService.redirectAfterLoginSuccess(); // Reload without a redirect URL - expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); + expect(hardRedirectService.redirect).not.toHaveBeenCalled(); }); describe('impersonate', () => { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index b8e6c6609a..f79ae5d0fd 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -14,7 +14,15 @@ import { AuthRequestService } from './auth-request.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; -import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; +import { + hasValue, + hasValueOperator, + isEmpty, + isNotEmpty, + isNotNull, + isNotUndefined, + hasNoValue +} from '../../shared/empty.util'; import { CookieService } from '../services/cookie.service'; import { getAuthenticatedUserId, @@ -413,35 +421,19 @@ export class AuthService { /** * Redirect to the route navigated before the login */ - public redirectAfterLoginSuccess(isStandalonePage: boolean) { + public redirectAfterLoginSuccess() { this.getRedirectUrl().pipe( take(1)) .subscribe((redirectUrl) => { - - if (isNotEmpty(redirectUrl)) { + if (hasValue(redirectUrl)) { this.clearRedirectUrl(); - this.router.onSameUrlNavigation = 'reload'; this.navigateToRedirectUrl(redirectUrl); - } else { - // If redirectUrl is empty use history. - this.routeService.getHistory().pipe( - take(1) - ).subscribe((history) => { - let redirUrl; - if (isStandalonePage) { - // For standalone login pages, use the previous route. - redirUrl = history[history.length - 2] || ''; - } else { - redirUrl = history[history.length - 1] || ''; - } - this.navigateToRedirectUrl(redirUrl); - }); } }); } - protected navigateToRedirectUrl(redirectUrl: string) { + public navigateToRedirectUrl(redirectUrl: string) { let url = `/reload/${new Date().getTime()}`; if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) { url += `?redirect=${encodeURIComponent(redirectUrl)}`; @@ -460,12 +452,16 @@ export class AuthService { * Get redirect url */ getRedirectUrl(): Observable { - const redirectUrl = this.storage.get(REDIRECT_COOKIE); - if (isNotEmpty(redirectUrl)) { - return observableOf(redirectUrl); - } else { - return this.store.pipe(select(getRedirectUrl)); - } + return this.store.pipe( + select(getRedirectUrl), + map((urlFromStore: string) => { + if (hasValue(urlFromStore)) { + return urlFromStore; + } else { + return this.storage.get(REDIRECT_COOKIE); + } + }) + ); } /** @@ -482,6 +478,16 @@ export class AuthService { this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : '')); } + setRedirectUrlIfNotSet(newRedirectUrl: string) { + this.getRedirectUrl().pipe( + take(1)) + .subscribe((currentRedirectUrl) => { + if (hasNoValue(currentRedirectUrl)) { + this.setRedirectUrl(newRedirectUrl); + } + }) + } + /** * Clear redirect url */ diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index 2e70a70310..4feb6ebce0 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -9,11 +9,11 @@ import { } from '@angular/router'; import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { map, find, switchMap } from 'rxjs/operators'; import { select, Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; -import { isAuthenticated } from './selectors'; +import { isAuthenticated, isAuthenticationLoading } from './selectors'; import { AuthService, LOGIN_ROUTE } from './auth.service'; /** @@ -48,11 +48,10 @@ export class AuthenticatedGuard implements CanActivate { } private handleAuth(url: string): Observable { - // get observable - const observable = this.store.pipe(select(isAuthenticated)); - // redirect to sign in page if user is not authenticated - return observable.pipe( + return this.store.pipe(select(isAuthenticationLoading)).pipe( + find((isLoading: boolean) => isLoading === false), + switchMap(() => this.store.pipe(select(isAuthenticated))), map((authenticated) => { if (authenticated) { return authenticated; diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index 7b78255001..84a74548ce 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -58,32 +58,4 @@ export class ServerAuthService extends AuthService { map((status: AuthStatus) => Object.assign(new AuthStatus(), status)) ); } - - /** - * Redirect to the route navigated before the login - */ - public redirectAfterLoginSuccess(isStandalonePage: boolean) { - this.getRedirectUrl().pipe( - take(1)) - .subscribe((redirectUrl) => { - if (isNotEmpty(redirectUrl)) { - // override the route reuse strategy - this.router.routeReuseStrategy.shouldReuseRoute = () => { - return false; - }; - this.router.navigated = false; - const url = decodeURIComponent(redirectUrl); - this.router.navigateByUrl(url); - } else { - // If redirectUrl is empty use history. For ssr the history array should contain the requested url. - this.routeService.getHistory().pipe( - filter((history) => history.length > 0), - take(1) - ).subscribe((history) => { - this.navigateToRedirectUrl(history[history.length - 1] || ''); - }); - } - }) - } - } diff --git a/src/app/core/services/browser-hard-redirect.service.spec.ts b/src/app/core/services/browser-hard-redirect.service.spec.ts index b94b52d46e..7eb2c6f494 100644 --- a/src/app/core/services/browser-hard-redirect.service.spec.ts +++ b/src/app/core/services/browser-hard-redirect.service.spec.ts @@ -5,7 +5,8 @@ describe('BrowserHardRedirectService', () => { const mockLocation = { href: undefined, - origin: 'test origin', + pathname: '/pathname', + search: '/search', } as Location; const service: BrowserHardRedirectService = new BrowserHardRedirectService(mockLocation); @@ -31,10 +32,10 @@ describe('BrowserHardRedirectService', () => { }) }); - describe('when requesting the origin', () => { + describe('when requesting the current route', () => { it('should return the location origin', () => { - expect(service.getOriginFromUrl()).toEqual('test origin'); + expect(service.getCurrentRoute()).toEqual(mockLocation.pathname + mockLocation.search); }); }); }); diff --git a/src/app/core/services/browser-hard-redirect.service.ts b/src/app/core/services/browser-hard-redirect.service.ts index 71ce7577d2..725848212f 100644 --- a/src/app/core/services/browser-hard-redirect.service.ts +++ b/src/app/core/services/browser-hard-redirect.service.ts @@ -1,11 +1,12 @@ -import {Inject, Injectable} from '@angular/core'; -import {LocationToken} from '../../../modules/app/browser-app.module'; +import { Inject, Injectable } from '@angular/core'; +import { LocationToken } from '../../../modules/app/browser-app.module'; +import { HardRedirectService } from './hard-redirect.service'; /** * Service for performing hard redirects within the browser app module */ @Injectable() -export class BrowserHardRedirectService { +export class BrowserHardRedirectService implements HardRedirectService { constructor( @Inject(LocationToken) protected location: Location, @@ -23,7 +24,7 @@ export class BrowserHardRedirectService { /** * Get the origin of a request */ - getOriginFromUrl() { - return this.location.origin; + getCurrentRoute() { + return this.location.pathname + this.location.search; } } diff --git a/src/app/core/services/hard-redirect.service.ts b/src/app/core/services/hard-redirect.service.ts index e2c18b6138..09757a1250 100644 --- a/src/app/core/services/hard-redirect.service.ts +++ b/src/app/core/services/hard-redirect.service.ts @@ -15,7 +15,8 @@ export abstract class HardRedirectService { abstract redirect(url: string); /** - * Get the origin of a request + * Get the current route, with query params included + * e.g. /search?page=1&query=open%20access&f.dateIssued.min=1980&f.dateIssued.max=2020 */ - abstract getOriginFromUrl(); + abstract getCurrentRoute(); } diff --git a/src/app/core/services/server-hard-redirect.service.spec.ts b/src/app/core/services/server-hard-redirect.service.spec.ts index 9704c64c74..2d09c21eb9 100644 --- a/src/app/core/services/server-hard-redirect.service.spec.ts +++ b/src/app/core/services/server-hard-redirect.service.spec.ts @@ -30,19 +30,14 @@ describe('ServerHardRedirectService', () => { }) }); - describe('when requesting the origin', () => { + describe('when requesting the current route', () => { beforeEach(() => { - mockRequest.protocol = 'test-protocol'; - mockRequest.get.and.callFake((name) => { - if (name === 'hostname') { - return 'test-host'; - } - }); + mockRequest.originalUrl = 'original/url'; }); it('should return the location origin', () => { - expect(service.getOriginFromUrl()).toEqual('test-protocol://test-host'); + expect(service.getCurrentRoute()).toEqual(mockRequest.originalUrl); }); }); }); diff --git a/src/app/core/services/server-hard-redirect.service.ts b/src/app/core/services/server-hard-redirect.service.ts index 69b6739445..79755d2dc9 100644 --- a/src/app/core/services/server-hard-redirect.service.ts +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -1,12 +1,13 @@ import { Inject, Injectable } from '@angular/core'; import { Request, Response } from 'express'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; +import { HardRedirectService } from './hard-redirect.service'; /** * Service for performing hard redirects within the server app module */ @Injectable() -export class ServerHardRedirectService { +export class ServerHardRedirectService implements HardRedirectService { constructor( @Inject(REQUEST) protected req: Request, @@ -55,8 +56,7 @@ export class ServerHardRedirectService { /** * Get the origin of a request */ - getOriginFromUrl() { - - return new URL(`${this.req.protocol}://${this.req.get('hostname')}`).toString(); + getCurrentRoute() { + return this.req.originalUrl; } } diff --git a/src/app/shared/log-in/container/log-in-container.component.spec.ts b/src/app/shared/log-in/container/log-in-container.component.spec.ts index 41673cc773..aa4888104f 100644 --- a/src/app/shared/log-in/container/log-in-container.component.spec.ts +++ b/src/app/shared/log-in/container/log-in-container.component.spec.ts @@ -12,6 +12,7 @@ import { AuthService } from '../../../core/auth/auth.service'; import { AuthMethod } from '../../../core/auth/models/auth.method'; import { AuthServiceStub } from '../../testing/auth-service.stub'; import { createTestComponent } from '../../testing/utils.test'; +import { HardRedirectService } from '../../../core/services/hard-redirect.service'; describe('LogInContainerComponent', () => { @@ -20,7 +21,13 @@ describe('LogInContainerComponent', () => { const authMethod = new AuthMethod('password'); + let hardRedirectService: HardRedirectService; + beforeEach(async(() => { + hardRedirectService = jasmine.createSpyObj('hardRedirectService', { + redirect: {}, + getCurrentRoute: {} + }); // refine the test module by declaring the test component TestBed.configureTestingModule({ imports: [ @@ -35,6 +42,7 @@ describe('LogInContainerComponent', () => { ], providers: [ {provide: AuthService, useClass: AuthServiceStub}, + { provide: HardRedirectService, useValue: hardRedirectService }, LogInContainerComponent ], schemas: [ diff --git a/src/app/shared/log-in/log-in.component.spec.ts b/src/app/shared/log-in/log-in.component.spec.ts index a9a42bf3dd..2536949849 100644 --- a/src/app/shared/log-in/log-in.component.spec.ts +++ b/src/app/shared/log-in/log-in.component.spec.ts @@ -18,6 +18,7 @@ import { NativeWindowService } from '../../core/services/window.service'; import { provideMockStore } from '@ngrx/store/testing'; import { createTestComponent } from '../testing/utils.test'; import { RouterTestingModule } from '@angular/router/testing'; +import { HardRedirectService } from '../../core/services/hard-redirect.service'; describe('LogInComponent', () => { @@ -33,8 +34,13 @@ describe('LogInComponent', () => { } } }; + let hardRedirectService: HardRedirectService; beforeEach(async(() => { + hardRedirectService = jasmine.createSpyObj('hardRedirectService', { + redirect: {}, + getCurrentRoute: {} + }); // refine the test module by declaring the test component TestBed.configureTestingModule({ imports: [ @@ -58,6 +64,7 @@ describe('LogInComponent', () => { { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, // { provide: Router, useValue: new RouterStub() }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + { provide: HardRedirectService, useValue: hardRedirectService }, provideMockStore({ initialState }), LogInComponent ], diff --git a/src/app/shared/log-in/log-in.component.ts b/src/app/shared/log-in/log-in.component.ts index 32e10fef45..301eb1736b 100644 --- a/src/app/shared/log-in/log-in.component.ts +++ b/src/app/shared/log-in/log-in.component.ts @@ -1,13 +1,9 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; - +import { Component, Input, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; -import { filter, takeWhile, } from 'rxjs/operators'; import { select, Store } from '@ngrx/store'; - import { AuthMethod } from '../../core/auth/models/auth.method'; import { getAuthenticationMethods, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors'; import { CoreState } from '../../core/core.reducers'; -import { AuthService } from '../../core/auth/auth.service'; import { getForgotPasswordPath, getRegisterPath } from '../../app-routing.module'; /** @@ -19,7 +15,7 @@ import { getForgotPasswordPath, getRegisterPath } from '../../app-routing.module templateUrl: './log-in.component.html', styleUrls: ['./log-in.component.scss'] }) -export class LogInComponent implements OnInit, OnDestroy { +export class LogInComponent implements OnInit { /** * A boolean representing if LogInComponent is in a standalone page @@ -45,14 +41,7 @@ export class LogInComponent implements OnInit, OnDestroy { */ public loading: Observable; - /** - * Component state. - * @type {boolean} - */ - private alive = true; - - constructor(private store: Store, - private authService: AuthService,) { + constructor(private store: Store) { } ngOnInit(): void { @@ -66,21 +55,6 @@ export class LogInComponent implements OnInit, OnDestroy { // set isAuthenticated this.isAuthenticated = this.store.pipe(select(isAuthenticated)); - - // subscribe to success - this.store.pipe( - select(isAuthenticated), - takeWhile(() => this.alive), - filter((authenticated) => authenticated)) - .subscribe(() => { - this.authService.redirectAfterLoginSuccess(this.isStandalonePage); - } - ); - - } - - ngOnDestroy(): void { - this.alive = false; } getRegisterPath() { diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts b/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts index 68c939d1bc..cac1052238 100644 --- a/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts +++ b/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts @@ -15,6 +15,7 @@ import { AuthServiceStub } from '../../../testing/auth-service.stub'; import { AppState } from '../../../../app.reducer'; import { AuthMethod } from '../../../../core/auth/models/auth.method'; import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; +import { HardRedirectService } from '../../../../core/services/hard-redirect.service'; describe('LogInPasswordComponent', () => { @@ -29,8 +30,14 @@ describe('LogInPasswordComponent', () => { loading: false, }; + let hardRedirectService: HardRedirectService; + beforeEach(() => { user = EPersonMock; + + hardRedirectService = jasmine.createSpyObj('hardRedirectService', { + getCurrentRoute: {} + }); }); beforeEach(async(() => { @@ -47,7 +54,8 @@ describe('LogInPasswordComponent', () => { ], providers: [ { provide: AuthService, useClass: AuthServiceStub }, - { provide: 'authMethodProvider', useValue: new AuthMethod(AuthMethodType.Password) } + { provide: 'authMethodProvider', useValue: new AuthMethod(AuthMethodType.Password) }, + { provide: HardRedirectService, useValue: hardRedirectService }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.ts b/src/app/shared/log-in/methods/password/log-in-password.component.ts index 8b0dd8cc04..1d144a280e 100644 --- a/src/app/shared/log-in/methods/password/log-in-password.component.ts +++ b/src/app/shared/log-in/methods/password/log-in-password.component.ts @@ -13,6 +13,8 @@ import { fadeOut } from '../../../animations/fade'; import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; import { renderAuthMethodFor } from '../log-in.methods-decorator'; import { AuthMethod } from '../../../../core/auth/models/auth.method'; +import { AuthService } from '../../../../core/auth/auth.service'; +import { HardRedirectService } from '../../../../core/services/hard-redirect.service'; /** * /users/sign-in @@ -66,11 +68,15 @@ export class LogInPasswordComponent implements OnInit { /** * @constructor * @param {AuthMethod} injectedAuthMethodModel + * @param {AuthService} authService + * @param {HardRedirectService} hardRedirectService * @param {FormBuilder} formBuilder * @param {Store} store */ constructor( @Inject('authMethodProvider') public injectedAuthMethodModel: AuthMethod, + private authService: AuthService, + private hardRedirectService: HardRedirectService, private formBuilder: FormBuilder, private store: Store ) { @@ -134,6 +140,8 @@ export class LogInPasswordComponent implements OnInit { email.trim(); password.trim(); + this.authService.setRedirectUrlIfNotSet(this.hardRedirectService.getCurrentRoute()); + // dispatch AuthenticationAction this.store.dispatch(new AuthenticateAction(email, password)); diff --git a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.spec.ts b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.spec.ts index 7c1e782ee0..b029ec63f2 100644 --- a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.spec.ts +++ b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.spec.ts @@ -17,6 +17,7 @@ import { NativeWindowService } from '../../../../core/services/window.service'; import { RouterStub } from '../../../testing/router.stub'; import { ActivatedRouteStub } from '../../../testing/active-router.stub'; import { NativeWindowMockFactory } from '../../../mocks/mock-native-window-ref'; +import { HardRedirectService } from '../../../../core/services/hard-redirect.service'; describe('LogInShibbolethComponent', () => { @@ -30,6 +31,7 @@ describe('LogInShibbolethComponent', () => { let location; let authState; + let hardRedirectService: HardRedirectService; beforeEach(() => { user = EPersonMock; @@ -41,6 +43,10 @@ describe('LogInShibbolethComponent', () => { loaded: false, loading: false, }; + + hardRedirectService = jasmine.createSpyObj('hardRedirectService', { + getCurrentRoute: {} + }); }); beforeEach(async(() => { @@ -59,6 +65,7 @@ describe('LogInShibbolethComponent', () => { { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, { provide: Router, useValue: new RouterStub() }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + { provide: HardRedirectService, useValue: hardRedirectService }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA diff --git a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts index 6321e6119f..bb5791bd60 100644 --- a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts +++ b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts @@ -12,6 +12,8 @@ import { isAuthenticated, isAuthenticationLoading } from '../../../../core/auth/ import { RouteService } from '../../../../core/services/route.service'; import { NativeWindowRef, NativeWindowService } from '../../../../core/services/window.service'; import { isNotNull } from '../../../empty.util'; +import { AuthService } from '../../../../core/auth/auth.service'; +import { HardRedirectService } from '../../../../core/services/hard-redirect.service'; @Component({ selector: 'ds-log-in-shibboleth', @@ -51,12 +53,16 @@ export class LogInShibbolethComponent implements OnInit { * @param {AuthMethod} injectedAuthMethodModel * @param {NativeWindowRef} _window * @param {RouteService} route + * @param {AuthService} authService + * @param {HardRedirectService} hardRedirectService * @param {Store} store */ constructor( @Inject('authMethodProvider') public injectedAuthMethodModel: AuthMethod, @Inject(NativeWindowService) protected _window: NativeWindowRef, private route: RouteService, + private authService: AuthService, + private hardRedirectService: HardRedirectService, private store: Store ) { this.authMethod = injectedAuthMethodModel; @@ -75,6 +81,7 @@ export class LogInShibbolethComponent implements OnInit { } redirectToShibboleth() { + this.authService.setRedirectUrlIfNotSet(this.hardRedirectService.getCurrentRoute()) let newLocationUrl = this.location; const currentUrl = this._window.nativeWindow.location.href; const myRegexp = /\?redirectUrl=(.*)/g; diff --git a/src/app/shared/testing/auth-service.stub.ts b/src/app/shared/testing/auth-service.stub.ts index 7e7e70a754..2f1e9e3bac 100644 --- a/src/app/shared/testing/auth-service.stub.ts +++ b/src/app/shared/testing/auth-service.stub.ts @@ -154,4 +154,12 @@ export class AuthServiceStub { resetAuthenticationError() { return; } + + setRedirectUrlIfNotSet(url: string) { + return; + } + + redirectAfterLoginSuccess() { + return; + } } diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index 44f00fbae4..7306e7db6c 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -40,7 +40,6 @@ export function locationProvider(): Location { return window.location; } - @NgModule({ bootstrap: [AppComponent], imports: [ From a3df6ce47469506688c656a42fd9d3c7998e464c Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 27 Aug 2020 14:56:20 +0200 Subject: [PATCH 064/126] 72699: Remove unnecessary method --- src/app/core/auth/auth.service.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index f79ae5d0fd..1a3dfbbdc0 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -419,20 +419,9 @@ export class AuthService { } /** - * Redirect to the route navigated before the login + * Perform a hard redirect to the URL + * @param redirectUrl */ - public redirectAfterLoginSuccess() { - this.getRedirectUrl().pipe( - take(1)) - .subscribe((redirectUrl) => { - if (hasValue(redirectUrl)) { - this.clearRedirectUrl(); - this.navigateToRedirectUrl(redirectUrl); - } - }); - - } - public navigateToRedirectUrl(redirectUrl: string) { let url = `/reload/${new Date().getTime()}`; if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) { From 32cf92eba94657767db7f1fb6581ff26095c6666 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 28 Aug 2020 10:37:55 +0200 Subject: [PATCH 065/126] 72541: Spacing between checkbox and buttons --- .../info/end-user-agreement/end-user-agreement.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.html b/src/app/info/end-user-agreement/end-user-agreement.component.html index 2338bfa460..2ab0005c69 100644 --- a/src/app/info/end-user-agreement/end-user-agreement.component.html +++ b/src/app/info/end-user-agreement/end-user-agreement.component.html @@ -5,7 +5,7 @@ -
+
From 61e0b9efb09288021e5fd9a34cbb560fdfa6748c Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 28 Aug 2020 11:40:22 +0200 Subject: [PATCH 066/126] 72699: Remove redundant subscribe + fix tests --- src/app/core/auth/auth.service.spec.ts | 19 +++++++--------- src/app/core/auth/auth.service.ts | 31 -------------------------- 2 files changed, 8 insertions(+), 42 deletions(-) diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index be309b15fb..d3c2b6c44d 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -322,31 +322,28 @@ describe('AuthService test', () => { expect(storage.remove).toHaveBeenCalled(); }); - it('should set redirect url to previous page', () => { - (storage.get as jasmine.Spy).and.returnValue('/collection/123'); - authService.redirectAfterLoginSuccess(); + it('should redirect to reload with redirect url', () => { + authService.navigateToRedirectUrl('/collection/123'); // Reload with redirect URL set to /collection/123 expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123')))); }); - it('should set redirect url to current page', () => { - (storage.get as jasmine.Spy).and.returnValue('/home'); - authService.redirectAfterLoginSuccess(); + it('should redirect to reload with /home', () => { + authService.navigateToRedirectUrl('/home'); // Reload with redirect URL set to /home expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/home')))); }); it('should redirect to regular reload and not to /login', () => { - (storage.get as jasmine.Spy).and.returnValue('/login'); - authService.redirectAfterLoginSuccess(); + authService.navigateToRedirectUrl('/login'); // Reload without a redirect URL expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); }); - it('should not redirect when no redirect url is found', () => { - authService.redirectAfterLoginSuccess(); + it('should redirect to regular reload when no redirect url is found', () => { + authService.navigateToRedirectUrl(undefined); // Reload without a redirect URL - expect(hardRedirectService.redirect).not.toHaveBeenCalled(); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); }); describe('impersonate', () => { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 1a3dfbbdc0..150e44296e 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -78,37 +78,6 @@ export class AuthService { select(isAuthenticated), startWith(false) ).subscribe((authenticated: boolean) => this._authenticated = authenticated); - - // If current route is different from the one setted in authentication guard - // and is not the login route, clear redirect url and messages - const routeUrl$ = this.store.pipe( - select(routerStateSelector), - filter((routerState: RouterReducerState) => isNotUndefined(routerState) - && isNotUndefined(routerState.state) && isNotEmpty(routerState.state.url)), - filter((routerState: RouterReducerState) => !this.isLoginRoute(routerState.state.url)), - map((routerState: RouterReducerState) => routerState.state.url) - ); - const redirectUrl$ = this.store.pipe(select(getRedirectUrl), distinctUntilChanged()); - routeUrl$.pipe( - withLatestFrom(redirectUrl$), - map(([routeUrl, redirectUrl]) => [routeUrl, redirectUrl]) - ).pipe(filter(([routeUrl, redirectUrl]) => isNotEmpty(redirectUrl) && (routeUrl !== redirectUrl))) - .subscribe(() => { - this.clearRedirectUrl(); - }); - } - - /** - * Check if is a login page route - * - * @param {string} url - * @returns {Boolean}. - */ - protected isLoginRoute(url: string) { - const urlTree: UrlTree = this.router.parseUrl(url); - const g: UrlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET]; - const segment = '/' + g.toString(); - return segment === LOGIN_ROUTE; } /** From cb3ef1dde4ec3831ce1783f33c0d99033665f92c Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 28 Aug 2020 13:05:02 +0200 Subject: [PATCH 067/126] 72541: End-User-Agreement redirect via store --- .../end-user-agreement.guard.spec.ts | 8 ++++-- .../end-user-agreement.guard.ts | 5 +++- .../end-user-agreement.component.spec.ts | 8 +++--- .../end-user-agreement.component.ts | 26 ++++++++++++------- 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/app/core/end-user-agreement/end-user-agreement.guard.spec.ts b/src/app/core/end-user-agreement/end-user-agreement.guard.spec.ts index 589b227b5a..a7f3f32a6b 100644 --- a/src/app/core/end-user-agreement/end-user-agreement.guard.spec.ts +++ b/src/app/core/end-user-agreement/end-user-agreement.guard.spec.ts @@ -2,23 +2,26 @@ import { EndUserAgreementGuard } from './end-user-agreement.guard'; import { EndUserAgreementService } from './end-user-agreement.service'; import { Router, UrlTree } from '@angular/router'; import { of as observableOf } from 'rxjs'; +import { AuthService } from '../auth/auth.service'; describe('EndUserAgreementGuard', () => { let guard: EndUserAgreementGuard; let endUserAgreementService: EndUserAgreementService; + let authService: AuthService; let router: Router; beforeEach(() => { endUserAgreementService = jasmine.createSpyObj('endUserAgreementService', { hasCurrentUserAcceptedAgreement: observableOf(true) }); + authService = jasmine.createSpyObj('authService', ['setRedirectUrl']); router = jasmine.createSpyObj('router', { navigateByUrl: {}, parseUrl: new UrlTree() }); - guard = new EndUserAgreementGuard(endUserAgreementService, router); + guard = new EndUserAgreementGuard(endUserAgreementService, authService, router); }); describe('canActivate', () => { @@ -39,7 +42,8 @@ describe('EndUserAgreementGuard', () => { it('should navigate the user with a redirect url', (done) => { const redirect = 'redirect/url'; guard.canActivate(undefined, Object.assign({ url: redirect })).subscribe(() => { - expect(router.navigateByUrl).toHaveBeenCalledWith(jasmine.anything(), { state: { redirect } }); + expect(authService.setRedirectUrl).toHaveBeenCalledWith(redirect); + expect(router.navigateByUrl).toHaveBeenCalled(); done(); }); }); diff --git a/src/app/core/end-user-agreement/end-user-agreement.guard.ts b/src/app/core/end-user-agreement/end-user-agreement.guard.ts index 450385984a..e42d885133 100644 --- a/src/app/core/end-user-agreement/end-user-agreement.guard.ts +++ b/src/app/core/end-user-agreement/end-user-agreement.guard.ts @@ -4,6 +4,7 @@ import { Observable } from 'rxjs/internal/Observable'; import { returnEndUserAgreementUrlTreeOnFalse } from '../shared/operators'; import { EndUserAgreementService } from './end-user-agreement.service'; import { tap } from 'rxjs/operators'; +import { AuthService } from '../auth/auth.service'; /** * A guard redirecting users to the end agreement page when they haven't accepted the latest user agreement @@ -12,6 +13,7 @@ import { tap } from 'rxjs/operators'; export class EndUserAgreementGuard implements CanActivate { constructor(protected endUserAgreementService: EndUserAgreementService, + protected authService: AuthService, protected router: Router) { } @@ -26,7 +28,8 @@ export class EndUserAgreementGuard implements CanActivate { returnEndUserAgreementUrlTreeOnFalse(this.router), tap((result) => { if (result instanceof UrlTree) { - this.router.navigateByUrl(result, { state: { redirect: state.url } }) + this.authService.setRedirectUrl(state.url); + this.router.navigateByUrl(result); } }) ); diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts b/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts index 5d6b3f904c..875d6e2dbd 100644 --- a/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts +++ b/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts @@ -24,19 +24,19 @@ describe('EndUserAgreementComponent', () => { let redirectUrl; function init() { + redirectUrl = 'redirect/url'; + endUserAgreementService = jasmine.createSpyObj('endUserAgreementService', { hasCurrentUserAcceptedAgreement: observableOf(false), setUserAcceptedAgreement: observableOf(true) }); notificationsService = jasmine.createSpyObj('notificationsService', ['success', 'error']); authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + isAuthenticated: observableOf(true), + getRedirectUrl: observableOf(redirectUrl) }); store = jasmine.createSpyObj('store', ['dispatch']); router = jasmine.createSpyObj('router', ['navigate', 'navigateByUrl']); - - redirectUrl = 'redirect/url'; - window.history.pushState({ redirect: redirectUrl }, ''); } beforeEach(async(() => { diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.ts b/src/app/info/end-user-agreement/end-user-agreement.component.ts index f86b0c3434..a60db6a496 100644 --- a/src/app/info/end-user-agreement/end-user-agreement.component.ts +++ b/src/app/info/end-user-agreement/end-user-agreement.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from '@angular/core'; import { AuthService } from '../../core/auth/auth.service'; -import { take } from 'rxjs/operators'; +import { switchMap, take } from 'rxjs/operators'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { AppState } from '../../app.reducer'; @@ -8,7 +8,8 @@ import { LogOutAction } from '../../core/auth/auth.actions'; import { EndUserAgreementService } from '../../core/end-user-agreement/end-user-agreement.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; -import { hasValue } from '../../shared/empty.util'; +import { of as observableOf } from 'rxjs'; +import { isNotEmpty } from '../../shared/empty.util'; @Component({ selector: 'ds-end-user-agreement', @@ -54,15 +55,20 @@ export class EndUserAgreementComponent implements OnInit { * Set the End User Agreement, display a notification and (optionally) redirect the user back to their original destination */ submit() { - this.endUserAgreementService.setUserAcceptedAgreement(this.accepted).subscribe((success) => { - if (success) { - this.notificationsService.success(this.translate.instant('info.end-user-agreement.accept.success')); - const redirect = window.history.state.redirect; - if (hasValue(redirect)) { - this.router.navigateByUrl(redirect); + this.endUserAgreementService.setUserAcceptedAgreement(this.accepted).pipe( + switchMap((success) => { + if (success) { + this.notificationsService.success(this.translate.instant('info.end-user-agreement.accept.success')); + return this.authService.getRedirectUrl(); + } else { + this.notificationsService.error(this.translate.instant('info.end-user-agreement.accept.error')); + return observableOf(undefined); } - } else { - this.notificationsService.error(this.translate.instant('info.end-user-agreement.accept.error')); + }), + take(1) + ).subscribe((redirectUrl) => { + if (isNotEmpty(redirectUrl)) { + this.router.navigateByUrl(redirectUrl); } }); } From 724e5d1f12574a5454d5828d425abd3b2c283634 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Tue, 1 Sep 2020 10:10:39 +0200 Subject: [PATCH 068/126] add blocking state to make dealing with log in errors more user friendly --- src/app/app-routing.module.ts | 82 ++++++++++--------- src/app/app.component.html | 10 ++- src/app/app.component.ts | 15 ++-- src/app/app.module.ts | 14 +++- src/app/core/auth/auth-blocking.guard.spec.ts | 62 ++++++++++++++ src/app/core/auth/auth-blocking.guard.ts | 31 +++++++ src/app/core/auth/auth.reducer.spec.ts | 54 +++++++++++- src/app/core/auth/auth.reducer.ts | 21 ++++- src/app/core/auth/selectors.ts | 18 ++++ .../auth-nav-menu.component.html | 2 +- .../auth-nav-menu.component.spec.ts | 2 + .../user-menu/user-menu.component.spec.ts | 2 + .../impersonate-navbar.component.spec.ts | 1 + 13 files changed, 253 insertions(+), 61 deletions(-) create mode 100644 src/app/core/auth/auth-blocking.guard.spec.ts create mode 100644 src/app/core/auth/auth-blocking.guard.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index a317cf9334..51a84d0a1e 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; +import { AuthBlockingGuard } from './core/auth/auth-blocking.guard'; import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; import { AuthenticatedGuard } from './core/auth/authenticated.guard'; @@ -88,45 +89,48 @@ export function getUnauthorizedPath() { @NgModule({ imports: [ RouterModule.forRoot([ - { path: '', redirectTo: '/home', pathMatch: 'full' }, - { path: 'reload/:rnd', component: PageNotFoundComponent, pathMatch: 'full', canActivate: [ReloadGuard] }, - { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false } }, - { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' }, - { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, - { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, - { path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule' }, - { path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule' }, - { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' }, - { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, - { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' }, - { path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule' }, - { - path: 'mydspace', - loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', - canActivate: [AuthenticatedGuard] - }, - { path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule' }, - { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'}, - { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard] }, - { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, - { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, - { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, - { - path: 'workspaceitems', - loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' - }, - { - path: WORKFLOW_ITEM_MODULE_PATH, - loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' - }, - { - path: PROFILE_MODULE_PATH, - loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] - }, - { path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard] }, - { path: UNAUTHORIZED_PATH, component: UnauthorizedComponent }, - { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, - ], + { path: '', canActivate: [AuthBlockingGuard], + children: [ + { path: '', redirectTo: '/home', pathMatch: 'full' }, + { path: 'reload/:rnd', component: PageNotFoundComponent, pathMatch: 'full', canActivate: [ReloadGuard] }, + { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false } }, + { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' }, + { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, + { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, + { path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule' }, + { path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule' }, + { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' }, + { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, + { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' }, + { path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule' }, + { + path: 'mydspace', + loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', + canActivate: [AuthenticatedGuard] + }, + { path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule' }, + { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'}, + { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard] }, + { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, + { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, + { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, + { + path: 'workspaceitems', + loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' + }, + { + path: WORKFLOW_ITEM_MODULE_PATH, + loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' + }, + { + path: PROFILE_MODULE_PATH, + loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] + }, + { path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard] }, + { path: UNAUTHORIZED_PATH, component: UnauthorizedComponent }, + { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, + ]} + ], { onSameUrlNavigation: 'reload', }) diff --git a/src/app/app.component.html b/src/app/app.component.html index 6d6f89ea35..b628424cd4 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,7 +1,4 @@ -
- -
-
+
+ +
+ diff --git a/src/app/app.component.ts b/src/app/app.component.ts index d5488be610..3f03cd8dbe 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { delay, filter, map, take, distinctUntilChanged } from 'rxjs/operators'; +import { delay, map, distinctUntilChanged } from 'rxjs/operators'; import { AfterViewInit, ChangeDetectionStrategy, @@ -19,7 +19,7 @@ import { MetadataService } from './core/metadata/metadata.service'; import { HostWindowResizeAction } from './shared/host-window.actions'; import { HostWindowState } from './shared/search/host-window.reducer'; import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; -import { isAuthenticated, isAuthenticationLoading } from './core/auth/selectors'; +import { isAuthenticationBlocking, isAuthenticationLoading } from './core/auth/selectors'; import { AuthService } from './core/auth/auth.service'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { MenuService } from './shared/menu/menu.service'; @@ -55,7 +55,7 @@ export class AppComponent implements OnInit, AfterViewInit { /** * Whether or not the authenticated has finished loading */ - hasAuthFinishedLoading$: Observable; + isAuthBlocking$: Observable; constructor( @Inject(NativeWindowService) private _window: NativeWindowRef, @@ -94,8 +94,8 @@ export class AppComponent implements OnInit, AfterViewInit { } ngOnInit() { - this.hasAuthFinishedLoading$ = this.store.pipe(select(isAuthenticationLoading)).pipe( - map((isLoading: boolean) => isLoading === false), + this.isAuthBlocking$ = this.store.pipe(select(isAuthenticationBlocking)).pipe( + map((isBlocking: boolean) => isBlocking === false), distinctUntilChanged() ); const env: string = environment.production ? 'Production' : 'Development'; @@ -103,11 +103,6 @@ export class AppComponent implements OnInit, AfterViewInit { console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`); this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight); - // Whether is not authenticathed try to retrieve a possible stored auth token - this.store.pipe(select(isAuthenticated), - take(1), - filter((authenticated) => !authenticated) - ).subscribe((authenticated) => this.authService.checkAuthenticationToken()); this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN); this.collapsedSidebarWidth = this.cssService.getVariable('collapsedSidebarWidth'); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 33454ed6c5..f1cdd5f2e5 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,11 +1,11 @@ import { APP_BASE_HREF, CommonModule } from '@angular/common'; import { HttpClientModule } from '@angular/common/http'; -import { NgModule } from '@angular/core'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { EffectsModule } from '@ngrx/effects'; import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store'; -import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; +import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core'; import { TranslateModule } from '@ngx-translate/core'; @@ -21,6 +21,7 @@ import { AppComponent } from './app.component'; import { appEffects } from './app.effects'; import { appMetaReducers, debugMetaReducers } from './app.metareducers'; import { appReducers, AppState, storeModuleConfig } from './app.reducer'; +import { CheckAuthenticationTokenAction } from './core/auth/auth.actions'; import { CoreModule } from './core/core.module'; import { ClientCookieService } from './core/services/client-cookie.service'; @@ -91,6 +92,15 @@ const PROVIDERS = [ useClass: DSpaceRouterStateSerializer }, ClientCookieService, + // Check the authentication token when the app initializes + { + provide: APP_INITIALIZER, + useFactory: (store: Store,) => { + return () => store.dispatch(new CheckAuthenticationTokenAction()); + }, + deps: [ Store ], + multi: true + }, ...DYNAMIC_MATCHER_PROVIDERS, ]; diff --git a/src/app/core/auth/auth-blocking.guard.spec.ts b/src/app/core/auth/auth-blocking.guard.spec.ts new file mode 100644 index 0000000000..2a89b01a85 --- /dev/null +++ b/src/app/core/auth/auth-blocking.guard.spec.ts @@ -0,0 +1,62 @@ +import { Store } from '@ngrx/store'; +import * as ngrx from '@ngrx/store'; +import { cold, getTestScheduler, initTestScheduler, resetTestScheduler } from 'jasmine-marbles/es6'; +import { of as observableOf } from 'rxjs'; +import { AppState } from '../../app.reducer'; +import { AuthBlockingGuard } from './auth-blocking.guard'; + +describe('AuthBlockingGuard', () => { + let guard: AuthBlockingGuard; + beforeEach(() => { + guard = new AuthBlockingGuard(new Store(undefined, undefined, undefined)); + initTestScheduler(); + }); + + afterEach(() => { + getTestScheduler().flush(); + resetTestScheduler(); + }); + + describe(`canActivate`, () => { + + describe(`when authState.loading is undefined`, () => { + beforeEach(() => { + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf(undefined); + }; + }) + }); + it(`should not emit anything`, () => { + expect(guard.canActivate()).toBeObservable(cold('|')); + }); + }); + + describe(`when authState.loading is true`, () => { + beforeEach(() => { + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf(true); + }; + }) + }); + it(`should not emit anything`, () => { + expect(guard.canActivate()).toBeObservable(cold('|')); + }); + }); + + describe(`when authState.loading is false`, () => { + beforeEach(() => { + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf(false); + }; + }) + }); + it(`should succeed`, () => { + expect(guard.canActivate()).toBeObservable(cold('(a|)', { a: true })); + }); + }); + }); + +}); diff --git a/src/app/core/auth/auth-blocking.guard.ts b/src/app/core/auth/auth-blocking.guard.ts new file mode 100644 index 0000000000..7488c0c508 --- /dev/null +++ b/src/app/core/auth/auth-blocking.guard.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { CanActivate } from '@angular/router'; +import { select, Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { distinctUntilChanged, filter, map, take, tap } from 'rxjs/operators'; +import { AppState } from '../../app.reducer'; +import { isAuthenticationBlocking } from './selectors'; + +/** + * A guard that blocks the loading of any + * route until the authentication status has loaded. + * To ensure all rest requests get the correct auth header. + */ +@Injectable({ + providedIn: 'root' +}) +export class AuthBlockingGuard implements CanActivate { + + constructor(private store: Store) { + } + + canActivate(): Observable { + return this.store.pipe(select(isAuthenticationBlocking)).pipe( + map((isBlocking: boolean) => isBlocking === false), + distinctUntilChanged(), + filter((finished: boolean) => finished === true), + take(1), + ); + } + +} diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index cf934a7f47..649002903c 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -42,6 +42,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: true, loading: false, }; const action = new AuthenticateAction('user', 'password'); @@ -49,6 +50,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: true, error: undefined, loading: true, info: undefined @@ -62,6 +64,7 @@ describe('authReducer', () => { authenticated: false, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -76,6 +79,7 @@ describe('authReducer', () => { authenticated: false, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -84,6 +88,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, info: undefined, authToken: undefined, @@ -96,6 +101,7 @@ describe('authReducer', () => { it('should properly set the state, in response to a AUTHENTICATED action', () => { initialState = { authenticated: false, + blocking: false, loaded: false, error: undefined, loading: true, @@ -103,8 +109,15 @@ describe('authReducer', () => { }; const action = new AuthenticatedAction(mockTokenInfo); const newState = authReducer(initialState, action); - - expect(newState).toEqual(initialState); + state = { + authenticated: false, + blocking: true, + loaded: false, + error: undefined, + loading: true, + info: undefined + }; + expect(newState).toEqual(state); }); it('should properly set the state, in response to a AUTHENTICATED_SUCCESS action', () => { @@ -112,6 +125,7 @@ describe('authReducer', () => { authenticated: false, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -122,6 +136,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -133,6 +148,7 @@ describe('authReducer', () => { authenticated: false, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -143,6 +159,7 @@ describe('authReducer', () => { authToken: undefined, error: 'Test error message', loaded: true, + blocking: false, loading: false, info: undefined }; @@ -153,6 +170,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: false, }; const action = new CheckAuthenticationTokenAction(); @@ -160,6 +178,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: true, loading: true, }; expect(newState).toEqual(state); @@ -169,6 +188,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: true, }; const action = new CheckAuthenticationTokenCookieAction(); @@ -176,6 +196,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: true, loading: true, }; expect(newState).toEqual(state); @@ -187,6 +208,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -204,6 +226,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -216,6 +239,7 @@ describe('authReducer', () => { authToken: undefined, error: undefined, loaded: false, + blocking: false, loading: false, info: undefined, refreshing: false, @@ -230,6 +254,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -242,6 +267,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: 'Test error message', + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -255,6 +281,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -265,6 +292,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -277,6 +305,7 @@ describe('authReducer', () => { authenticated: false, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -287,6 +316,7 @@ describe('authReducer', () => { authToken: undefined, error: 'Test error message', loaded: true, + blocking: false, loading: false, info: undefined }; @@ -299,6 +329,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -311,6 +342,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id, @@ -325,6 +357,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id, @@ -338,6 +371,7 @@ describe('authReducer', () => { authToken: newTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id, @@ -352,6 +386,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id, @@ -364,6 +399,7 @@ describe('authReducer', () => { authToken: undefined, error: undefined, loaded: false, + blocking: false, loading: false, info: undefined, refreshing: false, @@ -378,6 +414,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -387,6 +424,7 @@ describe('authReducer', () => { authenticated: false, authToken: undefined, loaded: false, + blocking: false, loading: false, error: undefined, info: 'Message', @@ -410,6 +448,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: false, }; const action = new AddAuthenticationMessageAction('Message'); @@ -417,6 +456,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, info: 'Message' }; @@ -427,6 +467,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: false, error: 'Error', info: 'Message' @@ -436,6 +477,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, error: undefined, info: undefined @@ -447,6 +489,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: false }; const action = new SetRedirectUrlAction('redirect.url'); @@ -454,6 +497,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, redirectUrl: 'redirect.url' }; @@ -464,6 +508,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: false, authMethods: [] }; @@ -472,6 +517,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: true, loading: true, authMethods: [] }; @@ -482,6 +528,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: true, loading: true, authMethods: [] }; @@ -494,6 +541,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, authMethods: authMethods }; @@ -504,6 +552,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: true, loading: true, authMethods: [] }; @@ -513,6 +562,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, authMethods: [new AuthMethod(AuthMethodType.Password)] }; diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 0ffd7d0519..9435dd1b1d 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -39,6 +39,10 @@ export interface AuthState { // true when loading loading: boolean; + // true when everything else should wait for authorization + // to complete + blocking: boolean; + // info message info?: string; @@ -62,7 +66,8 @@ export interface AuthState { const initialState: AuthState = { authenticated: false, loaded: false, - loading: undefined, + blocking: true, + loading: false, authMethods: [] }; @@ -86,7 +91,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE: return Object.assign({}, state, { - loading: true + loading: true, + blocking: true }); case AuthActionTypes.AUTHENTICATED_ERROR: @@ -96,6 +102,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut authToken: undefined, error: (action as AuthenticationErrorAction).payload.message, loaded: true, + blocking: false, loading: false }); @@ -110,6 +117,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loaded: true, error: undefined, loading: false, + blocking: false, info: undefined, userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload }); @@ -119,6 +127,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut authenticated: false, authToken: undefined, error: (action as AuthenticationErrorAction).payload.message, + blocking: false, loading: false }); @@ -139,6 +148,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut authToken: undefined, error: undefined, loaded: false, + blocking: false, loading: false, info: undefined, refreshing: false, @@ -151,6 +161,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut authenticated: false, authToken: undefined, loaded: false, + blocking: false, loading: false, info: (action as RedirectWhenTokenExpiredAction as RedirectWhenAuthenticationIsRequiredAction).payload, userId: undefined @@ -181,18 +192,21 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut // next three cases are used by dynamic rendering of login methods case AuthActionTypes.RETRIEVE_AUTH_METHODS: return Object.assign({}, state, { - loading: true + loading: true, + blocking: true }); case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS: return Object.assign({}, state, { loading: false, + blocking: false, authMethods: (action as RetrieveAuthMethodsSuccessAction).payload }); case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR: return Object.assign({}, state, { loading: false, + blocking: false, authMethods: [new AuthMethod(AuthMethodType.Password)] }); @@ -204,6 +218,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS: return Object.assign({}, state, { loading: true, + blocking: true, }); default: diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index 173f82e810..c4e95a0fb3 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -65,6 +65,14 @@ const _getAuthenticationInfo = (state: AuthState) => state.info; */ const _isLoading = (state: AuthState) => state.loading; +/** + * Returns true if everything else should wait for authentication. + * @function _isBlocking + * @param {State} state + * @returns {boolean} + */ +const _isBlocking = (state: AuthState) => state.blocking; + /** * Returns true if a refresh token request is in progress. * @function _isRefreshing @@ -170,6 +178,16 @@ export const isAuthenticatedLoaded = createSelector(getAuthState, _isAuthenticat */ export const isAuthenticationLoading = createSelector(getAuthState, _isLoading); +/** + * Returns true if the authentication should block everything else + * + * @function isAuthenticationBlocking + * @param {AuthState} state + * @param {any} props + * @return {boolean} + */ +export const isAuthenticationBlocking = createSelector(getAuthState, _isBlocking); + /** * Returns true if the refresh token request is loading. * @function isTokenRefreshing diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html index a05381fee8..fa92939e0f 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html @@ -1,7 +1,7 @@