diff --git a/src/app/community-list-page/community-list-page.module.ts b/src/app/community-list-page/community-list-page.module.ts index 2e3914fe03..57b016bc6e 100644 --- a/src/app/community-list-page/community-list-page.module.ts +++ b/src/app/community-list-page/community-list-page.module.ts @@ -4,7 +4,6 @@ import { SharedModule } from '../shared/shared.module'; import { CommunityListPageComponent } from './community-list-page.component'; import { CommunityListPageRoutingModule } from './community-list-page.routing.module'; import { CommunityListComponent } from './community-list/community-list.component'; -import { CdkTreeModule } from '@angular/cdk/tree'; /** * The page which houses a title and the community list, as described in community-list.component @@ -13,8 +12,7 @@ import { CdkTreeModule } from '@angular/cdk/tree'; imports: [ CommonModule, SharedModule, - CommunityListPageRoutingModule, - CdkTreeModule, + CommunityListPageRoutingModule ], declarations: [ CommunityListPageComponent, diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index f4e7aa2fd3..3366cdb3d8 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -251,7 +251,6 @@ export class AuthInterceptor implements HttpInterceptor { // Pass on the new request instead of the original request. return next.handle(newReq).pipe( - // tap((response) => console.log('next.handle: ', response)), map((response) => { // Intercept a Login/Logout response if (response instanceof HttpResponse && this.isSuccess(response) && this.isAuthRequest(response)) { diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 5f19185d1c..b33080b641 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -5,7 +5,6 @@ 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 { PaginatedList } from '../data/paginated-list'; import { SubmissionObject } from '../submission/models/submission-object.model'; import { DSpaceObject } from '../shared/dspace-object.model'; @@ -181,17 +180,6 @@ export class TokenResponse 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, diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index bc2f80830c..2b6328d38a 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http'; import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; + import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; import { EffectsModule } from '@ngrx/effects'; @@ -15,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'; @@ -80,9 +81,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'; @@ -160,6 +158,12 @@ 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 { 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'; +import { VocabularyTreeviewService } from '../shared/vocabulary-treeview/vocabulary-treeview.service'; import { ConfigurationDataService } from './data/configuration-data.service'; import { ConfigurationProperty } from './shared/configuration-property.model'; import { ReloadGuard } from './reload/reload.guard'; @@ -196,7 +200,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, @@ -238,8 +242,6 @@ const PROVIDERS = [ SubmissionResponseParsingService, SubmissionJsonPatchOperationsService, JsonPatchOperationsBuilder, - AuthorityService, - IntegrationResponseParsingService, UploaderService, UUIDService, NotificationsService, @@ -305,7 +307,10 @@ const PROVIDERS = [ }, NotificationsService, FilteredDiscoveryPageResponseParsingService, - { provide: NativeWindowService, useFactory: NativeWindowFactory } + { provide: NativeWindowService, useFactory: NativeWindowFactory }, + VocabularyService, + VocabularyEntriesResponseParsingService, + VocabularyTreeviewService ]; /** @@ -336,7 +341,6 @@ export const models = SubmissionSectionModel, SubmissionUploadsModel, AuthStatus, - AuthorityValue, BrowseEntry, BrowseDefinition, ClaimedTask, @@ -356,6 +360,9 @@ export const models = Feature, Authorization, Registration, + Vocabulary, + VocabularyEntry, + VocabularyEntryDetail, ConfigurationProperty ]; 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/bundle-data.service.spec.ts b/src/app/core/data/bundle-data.service.spec.ts index 1e1bf0eb9c..6c63ca8978 100644 --- a/src/app/core/data/bundle-data.service.spec.ts +++ b/src/app/core/data/bundle-data.service.spec.ts @@ -1,21 +1,11 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { compare, Operation } from 'fast-json-patch'; -import { Observable, of as observableOf } from 'rxjs'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { followLink } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { ChangeAnalyzer } from './change-analyzer'; -import { DataService } from './data.service'; -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 { BundleDataService } from './bundle-data.service'; diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index 76aad4ad56..4b0dee7df7 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -6,18 +6,18 @@ import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-servic import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { fakeAsync, tick } from '@angular/core/testing'; -import { ContentSourceRequest, GetRequest, RequestError, UpdateContentSourceRequest } from './request.models'; +import { ContentSourceRequest, GetRequest, UpdateContentSourceRequest } from './request.models'; import { ContentSource } from '../shared/content-source.model'; import { of as observableOf } from 'rxjs/internal/observable/of'; import { RequestEntry } from './request.reducer'; -import { ErrorResponse, RestResponse } from '../cache/response.models'; +import { ErrorResponse } from '../cache/response.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Collection } from '../shared/collection.model'; import { PageInfo } from '../shared/page-info.model'; import { PaginatedList } from './paginated-list'; import { createSuccessfulRemoteDataObject } from 'src/app/shared/remote-data.utils'; -import { hot, getTestScheduler, cold } from 'jasmine-marbles'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; const url = 'fake-url'; 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( 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 70c5b00441..2c96fa85f1 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -71,13 +71,17 @@ 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 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))); } /** @@ -89,18 +93,12 @@ 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 getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable { + public getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable { let result$: Observable; const args = []; result$ = this.getSearchEndpoint(searchMethod); - if (hasValue(options.searchParams)) { - options.searchParams.forEach((param: RequestParam) => { - args.push(`${param.fieldName}=${param.fieldValue}`); - }) - } - return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); } @@ -114,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') { @@ -130,6 +128,11 @@ export abstract class DataService implements UpdateDa 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(); 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..09ae8ae1c5 --- /dev/null +++ b/src/app/core/data/entries-response-parsing.service.ts @@ -0,0 +1,54 @@ +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'; + +/** + * An abstract class to extend, responsible for parsing data for an entries response + */ +export abstract class EntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected toCache = false; + + constructor( + protected objectCache: ObjectCacheService, + ) { + 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 = []; + if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { + const serializer = new DSpaceSerializer(this.getSerializerModel()); + entries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); + } + return new GenericSuccessResponse(entries, 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/data/request.models.ts b/src/app/core/data/request.models.ts index bd497d4ddb..6730487660 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'; @@ -20,12 +19,13 @@ import { ContentSourceResponseParsingService } from './content-source-response-p import { MappedCollectionsReponseParsingService } from './mapped-collections-reponse-parsing.service'; import { ProcessFilesResponseParsingService } from './process-files-response-parsing.service'; import { TokenResponseParsingService } from '../auth/token-response-parsing.service'; +import { VocabularyEntriesResponseParsingService } from '../submission/vocabularies/vocabulary-entries-response-parsing.service'; /* tslint:disable:max-classes-per-file */ // uuid and handle requests have separate endpoints export enum IdentifierType { - UUID ='uuid', + UUID = 'uuid', HANDLE = 'handle' } @@ -60,7 +60,7 @@ export class GetRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.GET, body, options) } } @@ -71,7 +71,7 @@ export class PostRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.POST, body) } } @@ -97,7 +97,7 @@ export class PutRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.PUT, body) } } @@ -108,7 +108,7 @@ export class DeleteRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.DELETE, body) } } @@ -119,7 +119,7 @@ export class OptionsRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.OPTIONS, body) } } @@ -130,7 +130,7 @@ export class HeadRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.HEAD, body) } } @@ -143,7 +143,7 @@ export class PatchRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.PATCH, body) } } @@ -276,16 +276,6 @@ export class TokenPostRequest extends PostRequest { } } -export class IntegrationRequest extends GetRequest { - constructor(uuid: string, href: string) { - super(uuid, href); - } - - getResponseParser(): GenericConstructor { - return IntegrationResponseParsingService; - } -} - /** * Class representing a submission HTTP GET request object */ @@ -425,6 +415,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/eperson/group-data.service.ts b/src/app/core/eperson/group-data.service.ts index c186bc8dcd..d42ba392f3 100644 --- a/src/app/core/eperson/group-data.service.ts +++ b/src/app/core/eperson/group-data.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core'; import { createSelector, select, Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, switchMap, take, tap } from 'rxjs/operators'; +import { filter, map, take, tap } from 'rxjs/operators'; import { GroupRegistryCancelGroupAction, GroupRegistryEditGroupAction @@ -21,18 +21,12 @@ import { DataService } from '../data/data.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { PaginatedList } from '../data/paginated-list'; import { RemoteData } from '../data/remote-data'; -import { - CreateRequest, - DeleteRequest, - FindListOptions, - FindListRequest, - PostRequest -} from '../data/request.models'; +import { CreateRequest, DeleteRequest, FindListOptions, FindListRequest, PostRequest } from '../data/request.models'; import { RequestService } from '../data/request.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { configureRequest, getResponseFromEntry} from '../shared/operators'; +import { getResponseFromEntry } from '../shared/operators'; import { EPerson } from './models/eperson.model'; import { Group } from './models/group.model'; import { dataService } from '../cache/builders/build-decorators'; 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 4af10034b2..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 { 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'; -import { PLACEHOLDER_PARENT_METADATA } from '../../../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-constants'; - -/** - * 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 - } - -} 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 eb54265318..ced3750834 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,11 +1,16 @@ 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 { 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'; @@ -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 { @@ -125,10 +130,12 @@ 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); + } else if (value.hasOwnProperty('authority')) { + operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.authority); } else if (value.hasOwnProperty('value')) { operationValue = new FormFieldMetadataValueObject(value.value); } else { @@ -144,10 +151,10 @@ export class JsonPatchOperationsBuilder { return operationValue; } - protected prepareAuthorityValue(value: any) { - let operationValue: any = null; - if (isNotEmpty(value.id)) { - operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.id); + protected prepareAuthorityValue(value: any): FormFieldMetadataValueObject { + let operationValue: FormFieldMetadataValueObject; + 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/json-patch/json-patch-operations.service.spec.ts b/src/app/core/json-patch/json-patch-operations.service.spec.ts index fb9e641441..4ada78172e 100644 --- a/src/app/core/json-patch/json-patch-operations.service.spec.ts +++ b/src/app/core/json-patch/json-patch-operations.service.spec.ts @@ -1,9 +1,8 @@ -import { async, TestBed } from '@angular/core/testing'; - import { getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; import { of as observableOf } from 'rxjs'; -import { Store, StoreModule } from '@ngrx/store'; +import { catchError } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { RequestService } from '../data/request.service'; @@ -22,7 +21,6 @@ import { StartTransactionPatchOperationsAction } from './json-patch-operations.actions'; import { RequestEntry } from '../data/request.reducer'; -import { catchError } from 'rxjs/operators'; class TestService extends JsonPatchOperationsService { protected linkPath = ''; diff --git a/src/app/core/locale/locale.service.ts b/src/app/core/locale/locale.service.ts index 32f782ef01..315fc02833 100644 --- a/src/app/core/locale/locale.service.ts +++ b/src/app/core/locale/locale.service.ts @@ -19,7 +19,7 @@ export enum LANG_ORIGIN { UI, EPERSON, BROWSER -}; +} /** * Service to provide localization handler @@ -75,8 +75,9 @@ export class LocaleService { return obs$.pipe( take(1), flatMap(([isAuthenticated, isLoaded]) => { - let epersonLang$: Observable = observableOf([]); - if (isAuthenticated && isLoaded) { + // TODO to enabled again when https://github.com/DSpace/dspace-angular/issues/739 will be resolved + const epersonLang$: Observable = observableOf([]); +/* if (isAuthenticated && isLoaded) { epersonLang$ = this.authService.getAuthenticatedUserFromStore().pipe( take(1), map((eperson) => { @@ -91,7 +92,7 @@ export class LocaleService { return languages; }) ); - } + }*/ return epersonLang$.pipe( map((epersonLang: string[]) => { const languages: string[] = []; 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/submission-object-data.service.spec.ts b/src/app/core/submission/submission-object-data.service.spec.ts index 931a7ae7d5..781036e950 100644 --- a/src/app/core/submission/submission-object-data.service.spec.ts +++ b/src/app/core/submission/submission-object-data.service.spec.ts @@ -1,8 +1,6 @@ -import { Observable } from 'rxjs'; import { SubmissionService } from '../../submission/submission.service'; import { RemoteData } from '../data/remote-data'; import { SubmissionObject } from './models/submission-object.model'; -import { WorkspaceItem } from './models/workspaceitem.model'; import { SubmissionObjectDataService } from './submission-object-data.service'; import { SubmissionScopeType } from './submission-scope-type'; import { WorkflowItemDataService } from './workflowitem-data.service'; diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts index 4bbd93b18d..b588c919a1 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/vocabularies.resource-type.ts b/src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts new file mode 100644 index 0000000000..5902fe4e17 --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts @@ -0,0 +1,12 @@ +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'); +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..2e066bae95 --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabulary-entry-detail.model.ts @@ -0,0 +1,39 @@ +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; + + /** + * The unique id of the entry + */ + @autoserialize + id: string; + + /** + * 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 new file mode 100644 index 0000000000..ca26c1b41e --- /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/ds-dynamic-form-constants'; +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 VocabularyEntry + */ +@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-find-options.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts new file mode 100644 index 0000000000..bd9bd55b95 --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts @@ -0,0 +1,37 @@ +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 query: string = '', + public filter?: string, + public exact?: boolean, + public entryID?: string, + public elementsPerPage?: number, + public currentPage?: number, + public sort?: SortOptions + ) { + super(); + + const searchParams = []; + + 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.toString())) + } + if (isNotEmpty(entryID)) { + searchParams.push(new RequestParam('entryID', entryID)) + } + this.searchParams = searchParams; + } +} 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..fd103718e1 --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts @@ -0,0 +1,21 @@ +/** + * Representing vocabulary properties + */ +export class VocabularyOptions { + + /** + * The name of the vocabulary + */ + name: string; + + /** + * A boolean representing if value is closely related to a vocabulary entry or not + */ + closed: boolean; + + constructor(name: string, + closed: boolean = false) { + this.name = name; + this.closed = closed; + } +} 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..8e3b63df74 --- /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'; + +describe('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/rest/api/submission/vocabularies/types/entries/first?page=0&size=5' + }, + self: { + href: 'https://rest.api/rest/api/submission/vocabularies/types/entries' + }, + next: { + href: 'https://rest.api/rest/api/submission/vocabularies/types/entries/next?page=1&size=5' + }, + last: { + href: 'https://rest.api/rest/api/submission/vocabularies/types/entries/last?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..f0c20fe7c5 --- /dev/null +++ b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@angular/core'; + +import { ObjectCacheService } from '../../cache/object-cache.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 EntriesResponseParsingService { + + protected toCache = false; + + constructor( + protected objectCache: ObjectCacheService, + ) { + super(objectCache); + } + + getSerializerModel(): GenericConstructor { + return VocabularyEntry; + } + +} 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..1119d4f6e6 --- /dev/null +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -0,0 +1,569 @@ +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 { 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, 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'; +import { VocabularyOptions } from './models/vocabulary-options.model'; +import { VocabularyFindOptions } from './models/vocabulary-find-options.model'; + +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 hierarchicalVocabulary: 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 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', + value: 'testParent', + otherInformation: { + id: 'authorityId2', + 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 vocabularyEntryChildDetail: any = { + authority: 'authoritytestChild1', + display: 'testChild1', + value: 'testChild1', + otherInformation: { + id: 'authoritytestChild1', + hasChildren: 'true', + note: 'Familjeforskning' + }, + type: 'vocabularyEntryDetail', + _links: { + self: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:authoritytestChild1' + }, + parent: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:parent' + }, + children: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:children' + } + } + }; + + const vocabularyEntryChild2Detail: any = { + authority: 'authoritytestChild2', + display: 'testChild2', + value: 'testChild2', + otherInformation: { + id: 'authoritytestChild2', + hasChildren: 'true', + note: 'Familjeforskning' + }, + type: 'vocabularyEntryDetail', + _links: { + self: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:authoritytestChild2' + }, + parent: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:parent' + }, + children: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:children' + } + } + }; + + 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`; + const entryDetailRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/${hierarchicalVocabulary.id}:testValue`; + const entryDetailParentRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/${hierarchicalVocabulary.id}:testValue/parent`; + const entryDetailChildrenRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/${hierarchicalVocabulary.id}:testValue/children`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const vocabularyId = 'types'; + const metadata = 'dc.type'; + 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`; + 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, + closed: false + } + 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 vocabularyRD$ = createSuccessfulRemoteDataObject$(vocabulary); + const vocabularyEntriesRD = createSuccessfulRemoteDataObject$(paginatedListEntries); + const vocabularyEntryDetailParentRD = createSuccessfulRemoteDataObject(vocabularyEntryParentDetail); + const vocabularyEntryChildrenRD = createSuccessfulRemoteDataObject(childrenPaginatedList); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + const getRequestEntries$ = (successful: boolean) => { + return observableOf({ + response: { isSuccessful: successful, payload: arrayEntries } as any + } as RequestEntry) + }; + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const comparatorEntry = {} as any; + + function initTestService() { + return new VocabularyService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + http, + comparator, + comparatorEntry + ); + } + + describe('vocabularies endpoint', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + }); + + afterEach(() => { + service = null; + }); + + describe('', () => { + beforeEach(() => { + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.completed = true; + 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(); + + 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('', () => { + + beforeEach(() => { + requestService = getMockRequestService(getRequestEntries$(true)); + 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); + + scheduler.schedule(() => service.getVocabularyEntries(vocabularyOptions, pageInfo).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + scheduler.schedule(() => service.getVocabularyEntries(vocabularyOptions, pageInfo)); + scheduler.flush(); + + 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', () => { + scheduler.schedule(() => service.getVocabularyEntriesByValue('test', false, vocabularyOptions, pageInfo)); + scheduler.flush(); + + 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', () => { + scheduler.schedule(() => service.getVocabularyEntryByValue('test', vocabularyOptions)); + scheduler.flush(); + + 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', () => { + scheduler.schedule(() => service.getVocabularyEntryByID('test', vocabularyOptions)); + scheduler.flush(); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + + }); + }); + + }); + + }); + + describe('vocabularyEntryDetails endpoint', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: entryDetailEndpointURL }) + }); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.completed = true; + 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: vocabularyEntryDetailParentRD + }), + buildList: hot('a|', { + a: vocabularyEntryChildrenRD + }), + }); + + 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, 'findAllByHref').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(entryDetailChildrenRequestURL)); + spyOn((service as any).vocabularyEntryDetailDataService, 'getBrowseEndpoint').and.returnValue(observableOf(entryDetailEndpointURL)); + }); + + afterEach(() => { + service = null; + }); + + describe('findEntryDetailByHref', () => { + it('should proxy the call to vocabularyDataService.findEntryDetailByHref', () => { + 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: vocabularyEntryDetailParentRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('findEntryDetailById', () => { + it('should proxy the call to vocabularyDataService.findVocabularyById', () => { + 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.findEntryDetailById('testValue', hierarchicalVocabulary.id); + const expected = cold('a|', { + a: vocabularyEntryDetailParentRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getEntryDetailParent', () => { + it('should proxy the call to vocabularyDataService.getEntryDetailParent', () => { + scheduler.schedule(() => service.getEntryDetailParent('testValue', hierarchicalVocabulary.id).subscribe()); + scheduler.flush(); + + expect((service as any).vocabularyEntryDetailDataService.findByHref).toHaveBeenCalledWith(entryDetailParentRequestURL); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.getEntryDetailParent('testValue', hierarchicalVocabulary.id); + const expected = cold('a|', { + a: vocabularyEntryDetailParentRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getEntryDetailChildren', () => { + it('should proxy the call to vocabularyDataService.getEntryDetailChildren', () => { + const options: VocabularyFindOptions = new VocabularyFindOptions( + null, + null, + null, + null, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + scheduler.schedule(() => service.getEntryDetailChildren('testValue', hierarchicalVocabulary.id, pageInfo).subscribe()); + scheduler.flush(); + + expect((service as any).vocabularyEntryDetailDataService.findAllByHref).toHaveBeenCalledWith(entryDetailChildrenRequestURL, options); + }); + + it('should return a RemoteData> for the object with the given URL', () => { + const result = service.getEntryDetailChildren('testValue', hierarchicalVocabulary.id, new PageInfo()); + const expected = cold('a|', { + a: vocabularyEntryChildrenRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('searchByTop', () => { + it('should proxy the call to vocabularyEntryDetailDataService.searchBy', () => { + const options: VocabularyFindOptions = new VocabularyFindOptions( + null, + null, + null, + null, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + 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', pageInfo); + const expected = cold('a|', { + a: vocabularyEntryChildrenRD + }); + expect(result).toBeObservable(expected); + }); + + }); + + describe('clearSearchTopRequests', () => { + it('should remove requests on the data service\'s endpoint', (done) => { + service.clearSearchTopRequests(); + + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(`search/${(service as any).searchTopMethod}`); + done(); + }); + }); + + }); +}); 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..595edfc861 --- /dev/null +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -0,0 +1,389 @@ +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 { Vocabulary } from './models/vocabulary.model'; +import { VOCABULARY } from './models/vocabularies.resource-type'; +import { VocabularyEntry } from './models/vocabulary-entry.model'; +import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; +import { + configureRequest, + filterSuccessfulResponses, + getFirstSucceededRemoteDataPayload, + 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 */ + +/** + * A private DataService implementation to delegate specific methods to. + */ +class VocabularyDataServiceImpl 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 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 + */ +@Injectable() +@dataService(VOCABULARY) +export class VocabularyService { + protected searchByMetadataAndCollectionMethod = 'byMetadataAndCollection'; + protected searchTopMethod = 'top'; + private vocabularyDataService: VocabularyDataServiceImpl; + private vocabularyEntryDetailDataService: VocabularyEntryDetailDataServiceImpl; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + 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); + } + + /** + * 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 + */ + findVocabularyByHref(href: string, ...linksToFollow: Array>): Observable> { + return this.vocabularyDataService.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 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(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 linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAllVocabularies(options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + return this.vocabularyDataService.findAll(options, ...linksToFollow); + } + + /** + * Return the {@link VocabularyEntry} list for a given {@link Vocabulary} + * + * @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(vocabularyOptions: VocabularyOptions, pageInfo: PageInfo): Observable>> { + + const options: VocabularyFindOptions = new VocabularyFindOptions( + null, + null, + null, + null, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + + 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) + ) + } + + /** + * Return the {@link VocabularyEntry} list for a given value + * + * @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( + null, + value, + exact, + null, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + + 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) + ) + } + + /** + * 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( + null, + null, + null, + ID, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + + 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), + getFirstSucceededRemoteListPayload(), + map((list: VocabularyEntry[]) => { + if (isNotEmpty(list)) { + return list[0] + } else { + return null; + } + }) + ); + } + + /** + * 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 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 + */ + findEntryDetailById(id: string, name: string, ...linksToFollow: Array>): Observable> { + const findId = `${name}:${id}`; + return this.vocabularyEntryDetailDataService.findById(findId, ...linksToFollow); + } + + /** + * Returns the parent detail entry for a given detail entry, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the object + * @param value The entry value for which to provide parent. + * @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 a PaginatedList of VocabularyEntryDetail + */ + getEntryDetailParent(value: string, name: string, ...linksToFollow: Array>): Observable> { + const linkPath = `${name}:${value}/parent`; + + return this.vocabularyEntryDetailDataService.getBrowseEndpoint().pipe( + map((href: string) => `${href}/${linkPath}`), + flatMap((href) => this.vocabularyEntryDetailDataService.findByHref(href, ...linksToFollow)) + ); + } + + /** + * Returns the list of children detail entries for a given detail entry, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the object + * @param value The entry value for which to provide children list. + * @param name The name of {@link Vocabulary} to which the entry 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 + * @return {Observable>>} + * Return an observable that emits a PaginatedList of VocabularyEntryDetail + */ + getEntryDetailChildren(value: string, name: string, pageInfo: PageInfo, ...linksToFollow: Array>): Observable>> { + const linkPath = `${name}:${value}/children`; + const options: VocabularyFindOptions = new VocabularyFindOptions( + null, + null, + null, + null, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + return this.vocabularyEntryDetailDataService.getFindAllHref(options, linkPath).pipe( + flatMap((href) => this.vocabularyEntryDetailDataService.findAllByHref(href, options, ...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 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, pageInfo: PageInfo, ...linksToFollow: Array>): Observable>> { + const options: VocabularyFindOptions = new VocabularyFindOptions( + null, + null, + null, + null, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + options.searchParams = [new RequestParam('vocabulary', name)]; + return this.vocabularyEntryDetailDataService.searchBy(this.searchTopMethod, options, ...linksToFollow) + } + + /** + * Clear all search Top Requests + */ + clearSearchTopRequests(): void { + this.requestService.removeByHrefSubstring(`search/${this.searchTopMethod}`); + } +} + +/** + * 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$); + }; diff --git a/src/app/core/submission/workflowitem-data.service.ts b/src/app/core/submission/workflowitem-data.service.ts index 9b7555808d..7cd745fd7f 100644 --- a/src/app/core/submission/workflowitem-data.service.ts +++ b/src/app/core/submission/workflowitem-data.service.ts @@ -9,7 +9,7 @@ import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; import { WorkflowItem } from './models/workflowitem.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DeleteByIDRequest, FindListOptions } from '../data/request.models'; +import { DeleteByIDRequest } from '../data/request.models'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts index 224bb64706..2fc95bdd00 100644 --- a/src/app/core/submission/workspaceitem-data.service.ts +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -8,7 +8,6 @@ import { CoreState } from '../core.reducers'; import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { FindListOptions } from '../data/request.models'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts index 7d39d4d314..272331aaf6 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts @@ -3,7 +3,7 @@ import { ExternalSourceEntry } from '../../../../../core/shared/external-source- import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ViewMode } from '../../../../../core/shared/view-mode.model'; import { Context } from '../../../../../core/shared/context.model'; -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { Metadata } from '../../../../../core/shared/metadata.utils'; import { MetadataValue } from '../../../../../core/shared/metadata.models'; diff --git a/src/app/shared/authority-confidence/authority-confidence-state.directive.ts b/src/app/shared/authority-confidence/authority-confidence-state.directive.ts index 410dadfc5f..c862452a99 100644 --- a/src/app/shared/authority-confidence/authority-confidence-state.directive.ts +++ b/src/app/shared/authority-confidence/authority-confidence-state.directive.ts @@ -13,12 +13,13 @@ import { import { findIndex } from 'lodash'; -import { AuthorityValue } from '../../core/integration/models/authority.value'; +import { VocabularyEntry } from '../../core/submission/vocabularies/models/vocabulary-entry.model'; import { FormFieldMetadataValueObject } from '../form/builder/models/form-field-metadata-value.model'; -import { ConfidenceType } from '../../core/integration/models/confidence-type'; +import { ConfidenceType } from '../../core/shared/confidence-type'; import { isNotEmpty, isNull } from '../empty.util'; import { ConfidenceIconConfig } from '../../../config/submission-config.interface'; import { environment } from '../../../environments/environment'; +import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; /** * Directive to add to the element a bootstrap utility class based on metadata confidence value @@ -31,7 +32,7 @@ export class AuthorityConfidenceStateDirective implements OnChanges, AfterViewIn /** * The metadata value */ - @Input() authorityValue: AuthorityValue | FormFieldMetadataValueObject | string; + @Input() authorityValue: VocabularyEntry | FormFieldMetadataValueObject | string; /** * A boolean representing if to show html icon if authority value is empty @@ -65,7 +66,6 @@ export class AuthorityConfidenceStateDirective implements OnChanges, AfterViewIn /** * Initialize instance variables * - * @param {GlobalConfig} EnvConfig * @param {ElementRef} elem * @param {Renderer2} renderer */ @@ -114,7 +114,8 @@ export class AuthorityConfidenceStateDirective implements OnChanges, AfterViewIn private getConfidenceByValue(value: any): ConfidenceType { let confidence: ConfidenceType = ConfidenceType.CF_UNSET; - if (isNotEmpty(value) && value instanceof AuthorityValue && value.hasAuthority()) { + if (isNotEmpty(value) && (value instanceof VocabularyEntry || value instanceof VocabularyEntryDetail) + && value.hasAuthority()) { confidence = ConfidenceType.CF_ACCEPTED; } diff --git a/src/app/shared/chips/chips.component.spec.ts b/src/app/shared/chips/chips.component.spec.ts index accbaf8f34..c8330a6bfa 100644 --- a/src/app/shared/chips/chips.component.spec.ts +++ b/src/app/shared/chips/chips.component.spec.ts @@ -11,7 +11,7 @@ import { FormFieldMetadataValueObject } from '../form/builder/models/form-field- import { createTestComponent } from '../testing/utils.test'; import { AuthorityConfidenceStateDirective } from '../authority-confidence/authority-confidence-state.directive'; import { TranslateModule } from '@ngx-translate/core'; -import { ConfidenceType } from '../../core/integration/models/confidence-type'; +import { ConfidenceType } from '../../core/shared/confidence-type'; import { SortablejsModule } from 'ngx-sortablejs'; import { environment } from '../../../environments/environment'; diff --git a/src/app/shared/chips/models/chips-item.model.ts b/src/app/shared/chips/models/chips-item.model.ts index 6bc513022c..5d0ff20e2f 100644 --- a/src/app/shared/chips/models/chips-item.model.ts +++ b/src/app/shared/chips/models/chips-item.model.ts @@ -1,7 +1,7 @@ import { isObject, uniqueId } from 'lodash'; import { hasValue, isNotEmpty } from '../../empty.util'; import { FormFieldMetadataValueObject } from '../../form/builder/models/form-field-metadata-value.model'; -import { ConfidenceType } from '../../../core/integration/models/confidence-type'; +import { ConfidenceType } from '../../../core/shared/confidence-type'; import { PLACEHOLDER_PARENT_METADATA } from '../../form/builder/ds-dynamic-form-ui/ds-dynamic-form-constants'; export interface ChipsItemIcon { diff --git a/src/app/shared/chips/models/chips.model.ts b/src/app/shared/chips/models/chips.model.ts index 936e13a3cd..c15badb976 100644 --- a/src/app/shared/chips/models/chips.model.ts +++ b/src/app/shared/chips/models/chips.model.ts @@ -4,7 +4,7 @@ import { ChipsItem, ChipsItemIcon } from './chips-item.model'; import { hasValue, isNotEmpty } from '../../empty.util'; import { MetadataIconConfig } from '../../../../config/submission-config.interface'; import { FormFieldMetadataValueObject } from '../../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'; import { PLACEHOLDER_PARENT_METADATA } from '../../form/builder/ds-dynamic-form-ui/ds-dynamic-form-constants'; export class Chips { @@ -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 7b95f2396e..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 @@ -44,15 +44,15 @@ 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'; 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'; @@ -77,11 +77,9 @@ import { FormBuilderService } from '../form-builder.service'; describe('DsDynamicFormControlContainerComponent test suite', () => { - const authorityOptions: AuthorityOptions = { - closed: false, - metadata: 'list', + const vocabularyOptions: VocabularyOptions = { name: 'type_programme', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + closed: false }; const formModel = [ new DynamicCheckboxModel({ id: 'checkbox' }), @@ -104,10 +102,10 @@ 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', hasSelectableMetadata: false }), + new DynamicOneboxModel({ id: 'typeahead', metadataFields: [], repeatable: false, submissionId: '1234', hasSelectableMetadata: false }), new DynamicScrollableDropdownModel({ id: 'scrollableDropdown', - authorityOptions: authorityOptions, + vocabularyOptions: vocabularyOptions, metadataFields: [], repeatable: false, submissionId: '1234', @@ -116,12 +114,12 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { new DynamicTagModel({ id: 'tag', metadataFields: [], repeatable: false, submissionId: '1234', hasSelectableMetadata: false }), new DynamicListCheckboxGroupModel({ id: 'checkboxList', - authorityOptions: authorityOptions, + vocabularyOptions: vocabularyOptions, repeatable: true }), new DynamicListRadioGroupModel({ id: 'radioList', - authorityOptions: authorityOptions, + vocabularyOptions: vocabularyOptions, repeatable: false }), new DynamicRelationGroupModel({ @@ -319,7 +317,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 f03050155b..f762a8c1c4 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 @@ -58,7 +58,7 @@ import { import { TranslateService } from '@ngx-translate/core'; 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'; @@ -70,7 +70,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'; @@ -89,7 +89,13 @@ 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, getFirstSucceededRemoteDataPayload, getPaginatedListPayload, getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; +import { + getAllSucceededRemoteData, + getFirstSucceededRemoteDataPayload, + getPaginatedListPayload, + 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'; @@ -110,6 +116,7 @@ import { paginatedRelationsToItems } from '../../../../+item-page/simple/item-ty import { RelationshipOptions } from '../models/relationship-options.model'; import { FormBuilderService } from '../form-builder.service'; import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from './ds-dynamic-form-constants'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type | null { switch (model.type) { @@ -145,8 +152,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; @@ -297,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/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts index ff2fd0c798..4a47ce5903 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts @@ -1,9 +1,12 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { ExistingMetadataListElementComponent, Reorderable, ReorderableRelationship } from './existing-metadata-list-element.component'; +import { + ExistingMetadataListElementComponent, + ReorderableRelationship +} from './existing-metadata-list-element.component'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service'; -import { select, Store } from '@ngrx/store'; +import { Store } from '@ngrx/store'; import { Item } from '../../../../../core/shared/item.model'; import { Relationship } from '../../../../../core/shared/item-relationships/relationship.model'; import { RelationshipOptions } from '../../models/relationship-options.model'; @@ -11,7 +14,6 @@ import { createSuccessfulRemoteDataObject$ } from '../../../../remote-data.utils import { RemoveRelationshipAction } from '../relation-lookup-modal/relationship.actions'; import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model'; import { of as observableOf } from 'rxjs'; -import { RelationshipService } from '../../../../../core/data/relationship.service'; describe('ExistingMetadataListElementComponent', () => { let component: ExistingMetadataListElementComponent; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.spec.ts index 6b6c518bb0..a337dd480e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.spec.ts @@ -3,7 +3,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ExistingRelationListElementComponent } from './existing-relation-list-element.component'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service'; -import { select, Store } from '@ngrx/store'; +import { Store } from '@ngrx/store'; import { Item } from '../../../../../core/shared/item.model'; import { Relationship } from '../../../../../core/shared/item-relationships/relationship.model'; import { RelationshipOptions } from '../../models/relationship-options.model'; 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 7573b67912..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,15 +1,19 @@ -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'; -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'; -import { MetadataValue } from '../../../../../core/shared/metadata.models'; export interface DsDynamicInputModelConfig extends DynamicInputModelConfig { - authorityOptions?: AuthorityOptions; + vocabularyOptions?: VocabularyOptions; languageCodes?: LanguageCode[]; language?: string; place?: number; @@ -19,13 +23,13 @@ export interface DsDynamicInputModelConfig extends DynamicInputModelConfig { metadataFields: string[]; submissionId: string; hasSelectableMetadata: boolean; - metadataValue?: MetadataValue; + metadataValue?: FormFieldMetadataValueObject; } export class DsDynamicInputModel extends DynamicInputModel { - @serializable() authorityOptions: AuthorityOptions; + @serializable() vocabularyOptions: VocabularyOptions; @serializable() private _languageCodes: LanguageCode[]; @serializable() private _language: string; @serializable() languageUpdates: Subject; @@ -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); @@ -50,7 +54,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)) { @@ -67,11 +71,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 { @@ -92,7 +96,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/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..83bacf6ea8 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts @@ -0,0 +1,134 @@ +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; + + protected 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)) { + // Integrate FormFieldMetadataValueObject with retrieved information + return new FormFieldMetadataValueObject( + initEntry.value, + null, + initEntry.authority, + initEntry.display, + (this.model.value as any).place, + null, + initEntry.otherInformation || null + ); + } else { + return this.model.value as any; + } + })); + } else if (isNotEmpty(this.model.value) && (this.model.value instanceof VocabularyEntry)) { + initValue$ = observableOf( + new FormFieldMetadataValueObject( + this.model.value.value, + null, + this.model.value.authority, + this.model.value.display, + 0, + null, + this.model.value.otherInformation || null + ) + ); + } 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-checkbox-group.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model.ts index f6b58c1504..1bc427f9a6 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,41 +19,41 @@ 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[]; + @serializable() _value: VocabularyEntry[]; 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); + return this.vocabularyOptions && hasValue(this.vocabularyOptions.name); } 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 } } 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..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 @@ -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,10 @@ export const LIST_TEST_GROUP = new FormGroup({ }); export const LIST_CHECKBOX_TEST_MODEL_CONFIG = { - authorityOptions: { - closed: false, - metadata: 'listCheckbox', + vocabularyOptions: { name: 'type_programme', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, + closed: false + } as VocabularyOptions, disabled: false, id: 'listCheckbox', label: 'Programme', @@ -52,12 +50,10 @@ export const LIST_CHECKBOX_TEST_MODEL_CONFIG = { }; export const LIST_RADIO_TEST_MODEL_CONFIG = { - authorityOptions: { - closed: false, - metadata: 'listRadio', + vocabularyOptions: { name: 'type_programme', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, + closed: false + } as VocabularyOptions, disabled: false, id: 'listRadio', label: 'Programme', @@ -77,7 +73,7 @@ describe('DsDynamicListComponent test suite', () => { let html; let modelValue; - const authorityServiceStub = new AuthorityServiceStub(); + const vocabularyServiceStub = new VocabularyServiceStub(); // async beforeEach beforeEach(async(() => { @@ -99,9 +95,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 +143,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 +179,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 +190,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 +225,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 +249,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 +260,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..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 @@ -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 { 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'; +import { PageInfo } from '../../../../../../core/shared/page-info.model'; export interface ListItem { id: string, @@ -23,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; @@ -39,10 +44,9 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen @Output() focus: EventEmitter = new EventEmitter(); public items: ListItem[][] = []; - protected optionsList: AuthorityValue[]; - protected searchOptions: IntegrationSearchOptions; + protected optionsList: VocabularyEntry[]; - constructor(private authorityService: AuthorityService, + constructor(private vocabularyService: VocabularyService, private cdr: ChangeDetectorRef, private formBuilderService: FormBuilderService, protected layoutService: DynamicFormLayoutService, @@ -51,39 +55,46 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen super(layoutService, validationService); } + /** + * Initialize the component, setting up the field options + */ 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, - '', - 1000, // Max elements - 1);// Current Page - 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) { // 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); } @@ -93,17 +104,25 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen this.change.emit(event); } - protected setOptionsFromAuthority() { - if (this.model.authorityOptions.name && this.model.authorityOptions.name.length > 0) { + /** + * 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; - this.authorityService.getEntriesByName(this.searchOptions).subscribe((authorities: IntegrationData) => { + 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; 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)); @@ -137,9 +156,4 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen } } - protected hasAuthorityOptions() { - return (hasValue(this.model.authorityOptions.scope) - && hasValue(this.model.authorityOptions.name) - && hasValue(this.model.authorityOptions.metadata)); - } } 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 b11aa2cb20..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 @@ -2,33 +2,32 @@ 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 { of as observableOf } from 'rxjs'; 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, DynamicLookupModelConfig } 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: DynamicLookupModelConfig = { - authorityOptions: { - closed: false, - metadata: 'lookup', + vocabularyOptions: { name: 'RPAuthority', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, + closed: false + } as VocabularyOptions, disabled: false, errorMessages: { required: 'Required field.' }, id: 'lookup', @@ -47,12 +46,10 @@ let LOOKUP_TEST_MODEL_CONFIG: DynamicLookupModelConfig = { }; let LOOKUP_NAME_TEST_MODEL_CONFIG = { - authorityOptions: { - closed: false, - metadata: 'lookup-name', + vocabularyOptions: { name: 'RPAuthority', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, + closed: false + } as VocabularyOptions, disabled: false, errorMessages: { required: 'Required field.' }, id: 'lookupName', @@ -78,12 +75,10 @@ let LOOKUP_TEST_GROUP = new FormGroup({ describe('Dynamic Lookup component', () => { function init() { LOOKUP_TEST_MODEL_CONFIG = { - authorityOptions: { - closed: false, - metadata: 'lookup', + vocabularyOptions: { name: 'RPAuthority', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, + closed: false + } as VocabularyOptions, disabled: false, errorMessages: { required: 'Required field.' }, id: 'lookup', @@ -102,12 +97,10 @@ describe('Dynamic Lookup component', () => { }; LOOKUP_NAME_TEST_MODEL_CONFIG = { - authorityOptions: { - closed: false, - metadata: 'lookup-name', + vocabularyOptions: { name: 'RPAuthority', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, + closed: false + } as VocabularyOptions, disabled: false, errorMessages: { required: 'Required field.' }, id: 'lookupName', @@ -137,12 +130,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 +154,7 @@ describe('Dynamic Lookup component', () => { providers: [ ChangeDetectorRef, DsDynamicLookupComponent, - { provide: AuthorityService, useValue: authorityService }, + { provide: VocabularyService, useValue: vocabularyServiceStub }, { provide: DynamicFormLayoutService, useValue: {} }, { provide: DynamicFormValidationService, useValue: {} } ], @@ -247,7 +239,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 +247,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 +274,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 +283,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(); @@ -312,7 +302,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'); @@ -321,9 +317,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; @@ -389,26 +428,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(); @@ -433,6 +472,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(); }); @@ -440,10 +486,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 d5516df6d9..ff3279e108 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,33 +1,31 @@ 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 { - DynamicFormControlComponent, - DynamicFormLayoutService, - DynamicFormValidationService -} from '@ng-dynamic-forms/core'; +import { DynamicFormLayoutService, 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 { hasValue, isEmpty, isNotEmpty, isNull, isUndefined } from '../../../../../empty.util'; -import { IntegrationData } from '../../../../../../core/integration/integration-data'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +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 { 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'; +import { DsDynamicVocabularyComponent } from '../dynamic-vocabulary.component'; +/** + * 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; @@ -43,42 +41,262 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem public pageInfo: PageInfo; public optionsList: any; - protected searchOptions: IntegrationSearchOptions; protected subs: Subscription[] = []; - constructor(private authorityService: AuthorityService, + 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 IntegrationSearchOptions( - this.model.authorityOptions.scope, - this.model.authorityOptions.name, - this.model.authorityOptions.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); } })); } + /** + * Check if model value has an authority + */ + public hasAuthorityValue() { + return hasValue(this.model.value) + && typeof this.model.value === 'object' + && 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() { + 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; + } + + /** + * 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) { + if (isNotEmpty(this.getCurrentValue())) { + const currentValue = new FormFieldMetadataValueObject(this.getCurrentValue()); + if (!this.editMode) { + this.updateModel(currentValue); + } + } else { + this.remove(); + } + } + } + + /** + * Load more result entries + */ + public onScroll() { + if (!this.loading && this.pageInfo.currentPage <= this.pageInfo.totalPages) { + 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.setCurrentValue(''); + } + } + } + + /** + * Reset the model value + */ + public remove() { + this.group.markAsPristine(); + this.dispatchUpdate(null) + } + + /** + * Saves all changes + */ + 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(); + } + + /** + * 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.updatePageInfo(this.model.maxOptions, 1); + this.loading = true; + + this.subs.push(this.vocabularyService.getVocabularyEntriesByValue( + this.getCurrentValue(), + false, + this.model.vocabularyOptions, + this.pageInfo + ).pipe( + getFirstSucceededRemoteDataPayload(), + catchError(() => + observableOf(new PaginatedList( + new PageInfo(), + [] + )) + ), + distinctUntilChanged()) + .subscribe((list: PaginatedList) => { + this.optionsList = list.page; + 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(); + this.search(); + } + } + + ngOnDestroy() { + this.subs + .filter((sub) => hasValue(sub)) + .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((formValue: FormFieldMetadataValueObject) => this.setDisplayInputValue(formValue.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 || ''; + } + this.cdr.detectChanges(); + } + } + + /** + * Gets the current text present in the input field(s) + */ protected getCurrentValue(): string { let result = ''; if (!this.isLookupName()) { @@ -96,6 +314,9 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem return result; } + /** + * Clear text present in the input field(s) + */ protected resetFields() { this.firstInputValue = ''; if (this.isLookupName()) { @@ -103,173 +324,12 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem } } - protected setInputsValue(value) { - if (hasValue(value)) { - let displayValue = value; - if (value instanceof FormFieldMetadataValueObject || value instanceof AuthorityValue) { - 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; } - 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/typeahead/dynamic-typeahead.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html similarity index 73% 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 index 449481152d..023fef665c 100644 --- 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 @@ -20,7 +20,7 @@ -
+
Sorry, suggestions could not be loaded.
+ + 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 87% 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 index 3857d96e78..d6ce88eed9 100644 --- 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 @@ -16,3 +16,8 @@ color: $dropdown-link-hover-color !important; background-color: $dropdown-link-hover-bg !important; } + +.treeview .modal-body { + max-height: 85vh !important; + overflow-y: auto; +} 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 new file mode 100644 index 0000000000..7a18bcc6e4 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts @@ -0,0 +1,462 @@ +// 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 { 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'; +import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; +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 { 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'; +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'; + +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(), + }); + + ONEBOX_TEST_MODEL_CONFIG = { + vocabularyOptions: { + closed: false, + name: 'vocabulary' + } as VocabularyOptions, + disabled: false, + id: 'onebox', + label: 'Conference', + minChars: 3, + name: 'onebox', + placeholder: 'Conference', + readOnly: false, + required: false, + repeatable: false, + value: undefined + }; +} + +describe('DsDynamicOneboxComponent test suite', () => { + + let scheduler: TestScheduler; + let testComp: TestComponent; + let oneboxComponent: DsDynamicOneboxComponent; + let testFixture: ComponentFixture; + let oneboxCompFixture: ComponentFixture; + let vocabularyServiceStub: any; + let modalService: any; + let html; + let modal; + const vocabulary = { + id: 'vocabulary', + name: 'vocabulary', + scrollable: true, + hierarchical: false, + preloadLevel: 0, + type: 'vocabulary', + _links: { + self: { + url: 'self' + }, + entries: { + url: 'entries' + } + } + } + + const hierarchicalVocabulary = { + id: 'hierarchicalVocabulary', + name: 'hierarchicalVocabulary', + scrollable: true, + hierarchical: true, + preloadLevel: 2, + type: 'vocabulary', + _links: { + self: { + url: 'self' + }, + entries: { + url: 'entries' + } + } + } + + // async beforeEach + beforeEach(() => { + vocabularyServiceStub = new VocabularyServiceStub(); + + modal = jasmine.createSpyObj('modal', + { + open: jasmine.createSpy('open'), + close: jasmine.createSpy('close'), + dismiss: jasmine.createSpy('dismiss'), + } + ); + init(); + TestBed.configureTestingModule({ + imports: [ + DynamicFormsCoreModule, + DynamicFormsNGBootstrapUIModule, + FormsModule, + NgbModule, + ReactiveFormsModule, + TranslateModule.forRoot(), + CdkTreeModule + ], + declarations: [ + DsDynamicOneboxComponent, + TestComponent, + AuthorityConfidenceStateDirective, + ObjNgFor, + VocabularyTreeviewComponent + ], // declare the test component + providers: [ + ChangeDetectorRef, + DsDynamicOneboxComponent, + { provide: VocabularyService, useValue: vocabularyServiceStub }, + { provide: DynamicFormLayoutService, useValue: {} }, + { provide: DynamicFormValidationService, useValue: {} }, + { provide: NgbModal, useValue: modal } + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + + }); + + describe('', () => { + // synchronous beforeEach + beforeEach(() => { + html = ` + `; + + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + it('should create DsDynamicOneboxComponent', inject([DsDynamicOneboxComponent], (app: DsDynamicOneboxComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('Has not hierarchical vocabulary', () => { + beforeEach(() => { + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); + }); + + 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(); + }); + + afterEach(() => { + oneboxCompFixture.destroy(); + oneboxComponent = null; + }); + + it('should init component properly', () => { + expect(oneboxComponent.currentValue).not.toBeDefined(); + }); + + it('should search when 3+ characters typed', fakeAsync(() => { + + spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntriesByValue').and.callThrough(); + + oneboxComponent.search(observableOf('test')).subscribe(); + + tick(300); + oneboxCompFixture.detectChanges(); + + expect((oneboxComponent as any).vocabularyService.getVocabularyEntriesByValue).toHaveBeenCalled(); + })); + + it('should set model.value on input type when VocabularyOptions.closed is false', () => { + const inputDe = oneboxCompFixture.debugElement.query(By.css('input.form-control')); + const inputElement = inputDe.nativeElement; + + inputElement.value = 'test value'; + inputElement.dispatchEvent(new Event('input')); + + expect(oneboxComponent.inputValue).toEqual(new FormFieldMetadataValueObject('test value')) + + }); + + it('should not set model.value on input type when VocabularyOptions.closed is true', () => { + 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(oneboxComponent.model.value).not.toBeDefined(); + + }); + + it('should emit blur Event onBlur when popup is closed', () => { + 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(oneboxComponent.blur, 'emit'); + spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(true); + const input = oneboxCompFixture.debugElement.query(By.css('input')); + + input.nativeElement.blur(); + expect(oneboxComponent.blur.emit).not.toHaveBeenCalled(); + }); + + it('should emit change Event onBlur when VocabularyOptions.closed is false and inputValue is changed', () => { + 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', () => { + 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', () => { + 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(oneboxComponent.focus, 'emit'); + oneboxComponent.onFocus(new Event('focus')); + expect(oneboxComponent.focus.emit).toHaveBeenCalled(); + }); + + }); + + 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(), { + authority: null, + value: 'test', + display: 'testDisplay' + })); + 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(() => { + oneboxCompFixture.destroy(); + oneboxComponent = null; + }); + + it('should init component properly', fakeAsync(() => { + tick(); + 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', () => { + 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(() => { + 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((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(() => { + oneboxCompFixture.destroy(); + oneboxComponent = null; + }); + + it('should init component properly', fakeAsync(() => { + tick(); + 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', () => { + oneboxComponent.currentValue = null; + spyOn(oneboxComponent.change, 'emit'); + oneboxComponent.onChange(new Event('change')); + expect(oneboxComponent.change.emit).toHaveBeenCalled(); + expect(oneboxComponent.model.value).toBeNull(); + }); + }); + }); + + describe('Has hierarchical vocabulary', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(hierarchicalVocabulary)); + 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', () => { + beforeEach(() => { + oneboxComponent.group = ONEBOX_TEST_GROUP; + oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); + oneboxCompFixture.detectChanges(); + }); + + afterEach(() => { + oneboxCompFixture.destroy(); + oneboxComponent = null; + }); + + it('should init component properly', fakeAsync(() => { + tick(); + expect(oneboxComponent.currentValue).not.toBeDefined(); + })); + + it('should open tree properly', (done) => { + scheduler.schedule(() => oneboxComponent.openTree(new Event('click'))); + scheduler.flush(); + + expect((oneboxComponent as any).modalService.open).toHaveBeenCalled(); + done(); + }); + }); + + describe('when init model value is not empty', () => { + beforeEach(() => { + 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((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(() => { + oneboxCompFixture.destroy(); + oneboxComponent = null; + }); + + it('should init component properly', fakeAsync(() => { + tick(); + expect(oneboxComponent.currentValue).toEqual(new FormFieldMetadataValueObject('test', null, null, 'testDisplay')); + expect((oneboxComponent as any).vocabularyService.getVocabularyEntryByValue).toHaveBeenCalled(); + })); + + it('should open tree properly', (done) => { + scheduler.schedule(() => oneboxComponent.openTree(new Event('click'))); + scheduler.flush(); + + expect((oneboxComponent as any).modalService.open).toHaveBeenCalled(); + done(); + }); + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + group: FormGroup = ONEBOX_TEST_GROUP; + + model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); + +} + +/* tslint:enable:max-classes-per-file */ 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 new file mode 100644 index 0000000000..43ea03228d --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts @@ -0,0 +1,278 @@ +import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { + catchError, + debounceTime, + distinctUntilChanged, + filter, + map, + merge, + switchMap, + take, + tap +} from 'rxjs/operators'; +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 { 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'; +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'; +import { DsDynamicVocabularyComponent } from '../dynamic-vocabulary.component'; +import { Vocabulary } from '../../../../../../core/submission/vocabularies/models/vocabulary.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'], + templateUrl: './dynamic-onebox.component.html' +}) +export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent implements OnInit { + @Input() bindId = true; + @Input() group: FormGroup; + @Input() model: DynamicOneboxModel; + + @Output() blur: EventEmitter = new EventEmitter(); + @Output() change: EventEmitter = new EventEmitter(); + @Output() focus: EventEmitter = new EventEmitter(); + + @ViewChild('instance', { static: false }) instance: NgbTypeahead; + + pageInfo: PageInfo = new PageInfo(); + searching = false; + searchFailed = false; + hideSearchingWhenUnsubscribed$ = new Observable(() => () => this.changeSearchingStatus(false)); + click$ = new Subject(); + currentValue: any; + inputValue: any; + preloadLevel: number; + + private vocabulary$: Observable; + private isHierarchicalVocabulary$: Observable; + private subs: Subscription[] = []; + + constructor(protected vocabularyService: VocabularyService, + protected cdr: ChangeDetectorRef, + protected layoutService: DynamicFormLayoutService, + protected modalService: NgbModal, + protected validationService: DynamicFormValidationService + ) { + super(vocabularyService, layoutService, validationService); + } + + /** + * Converts an item from the result list to a `string` to display in the `` field. + */ + formatter = (x: { display: string }) => { + return (typeof x === 'object') ? x.display : x + }; + + /** + * Converts a stream of text values from the `` element to the stream of the array of items + * to display in the onebox popup. + */ + search = (text$: Observable) => { + return text$.pipe( + merge(this.click$), + debounceTime(300), + distinctUntilChanged(), + tap(() => this.changeSearchingStatus(true)), + switchMap((term) => { + if (term === '' || term.length < this.model.minChars) { + return observableOf({ list: [] }); + } else { + return this.vocabularyService.getVocabularyEntriesByValue( + term, + false, + this.model.vocabularyOptions, + this.pageInfo).pipe( + getFirstSucceededRemoteDataPayload(), + tap(() => this.searchFailed = false), + catchError(() => { + this.searchFailed = true; + return observableOf(new PaginatedList( + new PageInfo(), + [] + )); + })); + } + }), + map((list: PaginatedList) => list.page), + tap(() => this.changeSearchingStatus(false)), + merge(this.hideSearchingWhenUnsubscribed$) + ) + }; + + /** + * Initialize the component, setting up the init form value + */ + ngOnInit() { + if (this.model.value) { + this.setCurrentValue(this.model.value, true); + } + + this.vocabulary$ = this.vocabularyService.findVocabularyById(this.model.vocabularyOptions.name).pipe( + getFirstSucceededRemoteDataPayload(), + 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) => { + this.setCurrentValue(this.model.value); + })); + } + + /** + * Changes the searching status + * @param status + */ + changeSearchingStatus(status: boolean) { + this.searching = status; + this.cdr.detectChanges(); + } + + /** + * Checks if configured vocabulary is Hierarchical or not + */ + isHierarchicalVocabulary(): Observable { + return this.isHierarchicalVocabulary$; + } + + /** + * Update the input value with a FormFieldMetadataValueObject + * @param event + */ + onInput(event) { + if (!this.model.vocabularyOptions.closed && isNotEmpty(event.target.value)) { + this.inputValue = new FormFieldMetadataValueObject(event.target.value); + } + } + + /** + * Emits a blur event containing a given value. + * @param event The value to emit. + */ + onBlur(event: Event) { + if (!this.instance.isPopupOpen()) { + if (!this.model.vocabularyOptions.closed && isNotEmpty(this.inputValue)) { + if (isNotNull(this.inputValue) && this.model.value !== this.inputValue) { + this.dispatchUpdate(this.inputValue); + } + this.inputValue = null; + } + this.blur.emit(event); + } else { + // prevent on blur propagation if typeahed suggestions are showed + event.preventDefault(); + event.stopImmediatePropagation(); + // set focus on input again, this is to avoid to lose changes when no suggestion is selected + (event.target as HTMLInputElement).focus(); + } + } + + /** + * Updates model value with the current value + * @param event The change event. + */ + onChange(event: Event) { + event.stopPropagation(); + if (isEmpty(this.currentValue)) { + this.dispatchUpdate(null); + } + } + + /** + * Updates current value and model value with the selected value. + * @param event The value to set. + */ + onSelectItem(event: NgbTypeaheadSelectItemEvent) { + this.inputValue = null; + this.setCurrentValue(event.item); + this.dispatchUpdate(event.item); + } + + /** + * Open modal to show tree for hierarchical vocabulary + * @param event The click event fired + */ + openTree(event) { + event.preventDefault(); + event.stopImmediatePropagation(); + this.subs.push(this.vocabulary$.pipe( + map((vocabulary: Vocabulary) => vocabulary.preloadLevel), + take(1) + ).subscribe((preloadLevel) => { + const modalRef: NgbModalRef = this.modalService.open(VocabularyTreeviewComponent, { size: 'lg', windowClass: 'treeview' }); + modalRef.componentInstance.vocabularyOptions = this.model.vocabularyOptions; + modalRef.componentInstance.preloadLevel = preloadLevel; + modalRef.componentInstance.selectedItem = this.currentValue ? this.currentValue : ''; + modalRef.result.then((result: VocabularyEntryDetail) => { + if (result) { + this.currentValue = result; + this.dispatchUpdate(result); + } + }, () => { + return; + }); + })) + } + + /** + * Callback functions for whenClickOnConfidenceNotAccepted event + */ + public whenClickOnConfidenceNotAccepted(confidence: ConfidenceType) { + if (!this.model.readOnly) { + this.click$.next(this.formatter(this.currentValue)); + } + } + + /** + * Sets the current value with the given value. + * @param value The value to set. + * @param init Representing if is init value or not. + */ + setCurrentValue(value: any, init = false): void { + let result: string; + if (init) { + this.getInitValueFromModel() + .subscribe((formValue: FormFieldMetadataValueObject) => { + this.currentValue = formValue; + this.cdr.detectChanges(); + }); + } else { + if (isEmpty(value)) { + result = ''; + } else { + result = value.value; + } + + this.currentValue = result; + this.cdr.detectChanges(); + } + + } + + 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/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/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 b5cb153db2..8fc579fb1b 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' }] @@ -129,7 +128,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 e7a519e2b4..4f6c776497 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,14 +23,16 @@ 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 { 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 { PLACEHOLDER_PARENT_METADATA } from '../../ds-dynamic-form-constants'; +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'], @@ -65,9 +57,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, @@ -178,6 +170,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); @@ -236,20 +234,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.findEntryDetailById( 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 { @@ -316,10 +310,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.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html index 8cb44bc733..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 @@ -1,7 +1,8 @@ -
- + -