diff --git a/.travis.yml b/.travis.yml index ce1213f483..c35e903d0e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,8 +10,8 @@ addons: language: node_js node_js: - - "6" - "8" + - "9" cache: yarn: true diff --git a/README.md b/README.md index dceb415d58..8f2320dbf3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ If you're looking for the 2016 Angular 2 DSpace UI prototype, you can find it [h Quick start ----------- -**Ensure you're running [Node](https://nodejs.org) >= `v6.9.x`, [npm](https://www.npmjs.com/) >= `v3.x` and [yarn](https://yarnpkg.com) >= `v0.20.x`** +**Ensure you're running [Node](https://nodejs.org) >= `v8.0.x`, [npm](https://www.npmjs.com/) >= `v3.x` and [yarn](https://yarnpkg.com) >= `v0.20.x`** ```bash # clone the repo diff --git a/config/environment.default.js b/config/environment.default.js index 4f3aee5f0e..a6ef738f41 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -22,6 +22,14 @@ module.exports = { // msToLive: 1000, // 15 minutes control: 'max-age=60' // revalidate browser }, + // Form settings + form: { + // NOTE: Map server-side validators to comparative Angular form validators + validatorMap: { + required: 'required', + regex: 'pattern' + } + }, // Notifications notifications: { rtl: false, diff --git a/package.json b/package.json index 76c1f07a63..7ded007e83 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "license": "BSD-2-Clause", "engines": { - "node": ">=6.0.0" + "node": ">=8.0.0" }, "scripts": { "global": "npm install -g @angular/cli marked node-gyp nodemon node-nightly npm-check-updates npm-run-all rimraf typescript ts-node typedoc webpack webpack-bundle-analyzer pm2 rollup", @@ -80,13 +80,18 @@ "@angular/router": "^5.2.5", "@angularclass/bootloader": "1.0.1", "@ng-bootstrap/ng-bootstrap": "^1.0.0", + "@ng-dynamic-forms/core": "5.4.7", + "@ng-dynamic-forms/ui-ng-bootstrap": "5.4.7", "@ngrx/effects": "^5.1.0", "@ngrx/router-store": "^5.0.1", "@ngrx/store": "^5.1.0", - "@nguniversal/express-engine": "5.0.0-beta.5", + "@nguniversal/express-engine": "5.0.0", "@ngx-translate/core": "9.1.1", "@ngx-translate/http-loader": "2.0.1", + "@nicky-lenaers/ngx-scroll-to": "^0.6.0", "angular-idle-preload": "2.0.4", + "angular-sortablejs": "^2.5.0", + "angular2-text-mask": "8.0.4", "angulartics2": "^5.2.0", "body-parser": "1.18.2", "bootstrap": "^4.0.0", @@ -105,10 +110,14 @@ "jwt-decode": "^2.2.0", "methods": "1.1.2", "morgan": "1.9.0", + "ng2-file-upload": "1.2.1", + "ngx-infinite-scroll": "0.8.2", "ngx-pagination": "3.0.3", "pem": "1.12.3", "reflect-metadata": "0.1.12", "rxjs": "5.5.6", + "sortablejs": "1.7.0", + "text-mask-core": "5.0.1", "ts-md5": "^1.2.4", "uuid": "^3.2.1", "webfontloader": "1.6.28", diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 3169dcbae9..ba70b87e12 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -204,7 +204,28 @@ "recent-submissions": "Error fetching recent submissions", "item": "Error fetching item", "objects": "Error fetching objects", - "search-results": "Error fetching search results" + "search-results": "Error fetching search results", + "validation": { + "pattern": "This input is restricted by the current pattern: {{ pattern }}.", + "license": { + "notgranted": "You must grant this license to complete your submission. If you are unable to grant this license at this time you may save your work and return later or remove the submission." + } + } + }, + "form": { + "submit": "Submit", + "cancel": "Cancel", + "search": "Search", + "remove": "Remove", + "first-name": "First name", + "last-name": "Last name", + "loading": "Loading...", + "no-results": "No results found", + "no-value": "No value entered", + "group-collapse": "Collapse", + "group-expand": "Expand", + "group-collapse-help": "Click here to collapse", + "group-expand-help": "Click here to expand and add more element" }, "login": { "title": "Login", diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index 51914ccc16..8dc82dfb6f 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -3,6 +3,7 @@ import * as fromRouter from '@ngrx/router-store'; import { headerReducer, HeaderState } from './header/header.reducer'; import { hostWindowReducer, HostWindowState } from './shared/host-window.reducer'; +import { formReducer, FormState } from './shared/form/form.reducer'; import { SearchSidebarState, sidebarReducer @@ -18,6 +19,7 @@ export interface AppState { router: fromRouter.RouterReducerState; hostWindow: HostWindowState; header: HeaderState; + forms: FormState; notifications: NotificationsState; searchSidebar: SearchSidebarState; searchFilter: SearchFiltersState; @@ -28,6 +30,7 @@ export const appReducers: ActionReducerMap = { router: fromRouter.routerReducer, hostWindow: hostWindowReducer, header: headerReducer, + forms: formReducer, notifications: notificationsReducer, searchSidebar: sidebarReducer, searchFilter: filterReducer, diff --git a/src/app/core/cache/response-cache.models.ts b/src/app/core/cache/response-cache.models.ts index dbf8c1760d..9b1b5b89eb 100644 --- a/src/app/core/cache/response-cache.models.ts +++ b/src/app/core/cache/response-cache.models.ts @@ -1,26 +1,25 @@ import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model'; import { RequestError } from '../data/request.models'; -import { BrowseEntry } from '../shared/browse-entry.model'; import { PageInfo } from '../shared/page-info.model'; -import { BrowseDefinition } from '../shared/browse-definition.model'; import { ConfigObject } from '../shared/config/config.model'; import { FacetValue } from '../../+search-page/search-service/facet-value.model'; import { SearchFilterConfig } from '../../+search-page/search-service/search-filter-config.model'; +import { IntegrationModel } from '../integration/models/integration.model'; import { RegistryMetadataschemasResponse } from '../registry/registry-metadataschemas-response.model'; import { MetadataSchema } from '../metadata/metadataschema.model'; import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model'; import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model'; -import { AuthTokenInfo } from '../auth/models/auth-token-info.model'; -import { NormalizedAuthStatus } from '../auth/models/normalized-auth-status.model'; import { AuthStatus } from '../auth/models/auth-status.model'; /* tslint:disable:max-classes-per-file */ export class RestResponse { public toCache = true; + constructor( public isSuccessful: boolean, public statusCode: string, - ) { } + ) { + } } export class DSOSuccessResponse extends RestResponse { @@ -158,6 +157,7 @@ export class ConfigSuccessResponse extends RestResponse { export class AuthStatusResponse extends RestResponse { public toCache = false; + constructor( public response: AuthStatus, public statusCode: string @@ -166,4 +166,14 @@ export class AuthStatusResponse extends RestResponse { } } +export class IntegrationSuccessResponse extends RestResponse { + constructor( + public dataDefinition: IntegrationModel[], + public statusCode: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode); + } +} + /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index ffdb828420..8536169688 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -8,6 +8,7 @@ import { CommonModule } from '@angular/common'; import { StoreModule } from '@ngrx/store'; import { EffectsModule } from '@ngrx/effects'; +import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; import { coreEffects } from './core.effects'; import { coreReducers } from './core.reducers'; @@ -22,6 +23,8 @@ import { DebugResponseParsingService } from './data/debug-response-parsing.servi import { DSOResponseParsingService } from './data/dso-response-parsing.service'; import { SearchResponseParsingService } from './data/search-response-parsing.service'; import { DSpaceRESTv2Service } from './dspace-rest-v2/dspace-rest-v2.service'; +import { FormBuilderService } from '../shared/form/builder/form-builder.service'; +import { FormService } from '../shared/form/form.service'; import { HostWindowService } from '../shared/host-window.service'; import { ItemDataService } from './data/item-data.service'; import { MetadataService } from './metadata/metadata.service'; @@ -40,6 +43,8 @@ import { RouteService } from '../shared/services/route.service'; import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service'; import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { SubmissionSectionsConfigService } from './config/submission-sections-config.service'; +import { AuthorityService } from './integration/authority.service'; +import { IntegrationResponseParsingService } from './integration/integration-response-parsing.service'; import { UUIDService } from './shared/uuid.service'; import { AuthenticatedGuard } from './auth/authenticated.guard'; import { AuthRequestService } from './auth/auth-request.service'; @@ -56,6 +61,7 @@ import { MetadataschemaParsingService } from './data/metadataschema-parsing.serv import { RegistryMetadatafieldsResponseParsingService } from './data/registry-metadatafields-response-parsing.service'; import { RegistryBitstreamformatsResponseParsingService } from './data/registry-bitstreamformats-response-parsing.service'; import { NotificationsService } from '../shared/notifications/notifications.service'; +import { UploaderService } from '../shared/uploader/uploader.service'; const IMPORTS = [ CommonModule, @@ -80,6 +86,11 @@ const PROVIDERS = [ CollectionDataService, DSOResponseParsingService, DSpaceRESTv2Service, + DynamicFormLayoutService, + DynamicFormService, + DynamicFormValidationService, + FormBuilderService, + FormService, HALEndpointService, HostWindowService, ItemDataService, @@ -109,6 +120,9 @@ const PROVIDERS = [ SubmissionDefinitionsConfigService, SubmissionFormsConfigService, SubmissionSectionsConfigService, + AuthorityService, + IntegrationResponseParsingService, + UploaderService, UUIDService, // register AuthInterceptor as HttpInterceptor { diff --git a/src/app/core/data/config-response-parsing.service.ts b/src/app/core/data/config-response-parsing.service.ts index 033c9ddc68..dfbbfc50c7 100644 --- a/src/app/core/data/config-response-parsing.service.ts +++ b/src/app/core/data/config-response-parsing.service.ts @@ -27,7 +27,7 @@ export class ConfigResponseParsingService extends BaseResponseParsingService imp } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && data.statusCode === '200') { + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '201' || data.statusCode === '200' || data.statusCode === 'OK')) { const configDefinition = this.process(data.payload, request.href); return new ConfigSuccessResponse(configDefinition[Object.keys(configDefinition)[0]], data.statusCode, this.processPageInfo(data.payload)); } else { diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 5bfc71d90e..7015b0b0f1 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -11,6 +11,7 @@ import { ConfigResponseParsingService } from './config-response-parsing.service' import { AuthResponseParsingService } from '../auth/auth-response-parsing.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HttpHeaders } from '@angular/common/http'; +import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service'; /* tslint:disable:max-classes-per-file */ @@ -212,6 +213,15 @@ export class AuthGetRequest extends GetRequest { } } +export class IntegrationRequest extends GetRequest { + constructor(uuid: string, href: string) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return IntegrationResponseParsingService; + } +} export class RequestError extends Error { statusText: string; } diff --git a/src/app/core/integration/authority.service.ts b/src/app/core/integration/authority.service.ts new file mode 100644 index 0000000000..cb2595adc4 --- /dev/null +++ b/src/app/core/integration/authority.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; + +import { ResponseCacheService } from '../cache/response-cache.service'; +import { RequestService } from '../data/request.service'; +import { IntegrationService } from './integration.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; + +@Injectable() +export class AuthorityService extends IntegrationService { + protected linkPath = 'authorities'; + protected browseEndpoint = 'entries'; + + constructor( + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected halService: HALEndpointService) { + super(); + } +} diff --git a/src/app/core/integration/integration-data.ts b/src/app/core/integration/integration-data.ts new file mode 100644 index 0000000000..b93ce36dad --- /dev/null +++ b/src/app/core/integration/integration-data.ts @@ -0,0 +1,12 @@ +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-object-factory.ts b/src/app/core/integration/integration-object-factory.ts new file mode 100644 index 0000000000..4f69dbd6fe --- /dev/null +++ b/src/app/core/integration/integration-object-factory.ts @@ -0,0 +1,17 @@ +import { GenericConstructor } from '../shared/generic-constructor'; +import { IntegrationType } from './intergration-type'; +import { AuthorityValueModel } from './models/authority-value.model'; +import { IntegrationModel } from './models/integration.model'; + +export class IntegrationObjectFactory { + public static getConstructor(type): GenericConstructor { + switch (type) { + case IntegrationType.Authority: { + return AuthorityValueModel; + } + default: { + return undefined; + } + } + } +} diff --git a/src/app/core/integration/integration-response-parsing.service.spec.ts b/src/app/core/integration/integration-response-parsing.service.spec.ts new file mode 100644 index 0000000000..e2e2f92d5a --- /dev/null +++ b/src/app/core/integration/integration-response-parsing.service.spec.ts @@ -0,0 +1,196 @@ +import { ErrorResponse, IntegrationSuccessResponse } from '../cache/response-cache.models'; + +import { ObjectCacheService } from '../cache/object-cache.service'; +import { GlobalConfig } from '../../../config/global-config.interface'; + +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { IntegrationResponseParsingService } from './integration-response-parsing.service'; +import { IntegrationRequest } from '../data/request.models'; +import { AuthorityValueModel } from './models/authority-value.model'; + +describe('IntegrationResponseParsingService', () => { + let service: IntegrationResponseParsingService; + + const EnvConfig = {} as GlobalConfig; + const store = {} as Store; + const objectCacheService = new ObjectCacheService(store); + 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}`; + + beforeEach(() => { + service = new IntegrationResponseParsingService(EnvConfig, objectCacheService); + }); + + describe('parse', () => { + const validRequest = new IntegrationRequest('69f375b5-19f4-4453-8c7a-7dc5c55aafbb', entriesEndpoint); + + const 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: 'https://rest.api/integration/authorities/type/entries' + } + }, + statusCode: '200' + }; + + const invalidResponse1 = { + payload: {}, + statusCode: '200' + }; + + const 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: '200' + }; + + const definitions = [ + Object.assign(new AuthorityValueModel(), { + display: 'One', + id: 'One', + otherInformation: {}, + value: 'One' + }), + Object.assign(new AuthorityValueModel(), { + display: 'Two', + id: 'Two', + otherInformation: {}, + value: 'Two' + }), + Object.assign(new AuthorityValueModel(), { + display: 'Three', + id: 'Three', + otherInformation: {}, + value: 'Three' + }), + Object.assign(new AuthorityValueModel(), { + display: 'Four', + id: 'Four', + otherInformation: {}, + value: 'Four' + }), + Object.assign(new AuthorityValueModel(), { + display: 'Five', + id: 'Five', + otherInformation: {}, + value: 'Five' + }) + ]; + + 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 new file mode 100644 index 0000000000..5d6ce09114 --- /dev/null +++ b/src/app/core/integration/integration-response-parsing.service.ts @@ -0,0 +1,47 @@ +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-cache.models'; +import { isNotEmpty } from '../../shared/empty.util'; +import { IntegrationObjectFactory } from './integration-object-factory'; + +import { BaseResponseParsingService } from '../data/base-response-parsing.service'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { IntegrationModel } from './models/integration.model'; +import { IntegrationType } from './intergration-type'; + +@Injectable() +export class IntegrationResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected objectFactory = IntegrationObjectFactory; + protected toCache = false; + + constructor( + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + 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.href); + return new IntegrationSuccessResponse(dataDefinition[Object.keys(dataDefinition)[0]], data.statusCode, this.processPageInfo(data.payload.page)); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from Integration endpoint'), + {statusText: data.statusCode} + ) + ); + } + } + +} diff --git a/src/app/core/integration/integration.service.spec.ts b/src/app/core/integration/integration.service.spec.ts new file mode 100644 index 0000000000..b7f4e019f7 --- /dev/null +++ b/src/app/core/integration/integration.service.spec.ts @@ -0,0 +1,83 @@ +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/Rx'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { ResponseCacheService } from '../cache/response-cache.service'; + +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'; + +const LINK_NAME = 'authorities'; +const BROWSE = 'entries'; + +class TestService extends IntegrationService { + protected linkPath = LINK_NAME; + protected browseEndpoint = BROWSE; + + constructor( + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected halService: HALEndpointService) { + super(); + } +} + +describe('IntegrationService', () => { + let scheduler: TestScheduler; + let service: TestService; + let responseCache: ResponseCacheService; + let requestService: RequestService; + let halService: any; + let findOptions: IntegrationSearchOptions; + + const name = 'type'; + const metadata = 'dc.type'; + const query = ''; + 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}`; + + findOptions = new IntegrationSearchOptions(uuid, name, metadata); + + function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService { + return jasmine.createSpyObj('responseCache', { + get: cold('c-', { + c: {response: {isSuccessful}} + }) + }); + } + + function initTestService(): TestService { + return new TestService( + responseCache, + requestService, + halService + ); + } + + beforeEach(() => { + responseCache = initMockResponseCacheService(true); + requestService = getMockRequestService(); + 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); + }); + }); + +}); diff --git a/src/app/core/integration/integration.service.ts b/src/app/core/integration/integration.service.ts new file mode 100644 index 0000000000..f1c770336a --- /dev/null +++ b/src/app/core/integration/integration.service.ts @@ -0,0 +1,85 @@ +import { Observable } from 'rxjs/Observable'; +import { RequestService } from '../data/request.service'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { ErrorResponse, IntegrationSuccessResponse, RestResponse } from '../cache/response-cache.models'; +import { GetRequest, IntegrationRequest } from '../data/request.models'; +import { ResponseCacheEntry } from '../cache/response-cache.reducer'; +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'; + +export abstract class IntegrationService { + protected request: IntegrationRequest; + protected abstract responseCache: ResponseCacheService; + protected abstract requestService: RequestService; + protected abstract linkPath: string; + protected abstract browseEndpoint: string; + protected abstract halService: HALEndpointService; + + protected getData(request: GetRequest): Observable { + const [successResponse, errorResponse] = this.responseCache.get(request.href) + .map((entry: ResponseCacheEntry) => entry.response) + .partition((response: RestResponse) => response.isSuccessful); + return Observable.merge( + errorResponse.flatMap((response: ErrorResponse) => + Observable.throw(new Error(`Couldn't retrieve the integration data`))), + successResponse + .filter((response: IntegrationSuccessResponse) => isNotEmpty(response)) + .map((response: IntegrationSuccessResponse) => new IntegrationData(response.pageInfo, response.dataDefinition)) + .distinctUntilChanged()); + } + + protected getIntegrationHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string { + let result; + const args = []; + + if (hasValue(options.name)) { + result = `${endpoint}/${options.name}/${this.browseEndpoint}`; + } 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; + } + + public getEntriesByName(options: IntegrationSearchOptions): Observable { + return this.halService.getEndpoint(this.linkPath) + .map((endpoint: string) => this.getIntegrationHref(endpoint, options)) + .filter((href: string) => isNotEmpty(href)) + .distinctUntilChanged() + .map((endpointURL: string) => new IntegrationRequest(this.requestService.generateRequestId(), endpointURL)) + .do((request: GetRequest) => this.requestService.configure(request)) + .flatMap((request: GetRequest) => this.getData(request)) + .distinctUntilChanged(); + } + +} diff --git a/src/app/core/integration/intergration-type.ts b/src/app/core/integration/intergration-type.ts new file mode 100644 index 0000000000..882dc6d8ce --- /dev/null +++ b/src/app/core/integration/intergration-type.ts @@ -0,0 +1,4 @@ + +export enum IntegrationType { + Authority = 'authority' +} diff --git a/src/app/core/integration/models/authority-options.model.ts b/src/app/core/integration/models/authority-options.model.ts new file mode 100644 index 0000000000..0b826f7f9c --- /dev/null +++ b/src/app/core/integration/models/authority-options.model.ts @@ -0,0 +1,16 @@ +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-value.model.ts b/src/app/core/integration/models/authority-value.model.ts new file mode 100644 index 0000000000..e2ef9ce9db --- /dev/null +++ b/src/app/core/integration/models/authority-value.model.ts @@ -0,0 +1,20 @@ +import { IntegrationModel } from './integration.model'; +import { autoserialize } from 'cerialize'; + +export class AuthorityValueModel extends IntegrationModel { + + @autoserialize + id: string; + + @autoserialize + display: string; + + @autoserialize + value: string; + + @autoserialize + otherInformation: any; + + @autoserialize + language: string; +} diff --git a/src/app/core/integration/models/integration-options.model.ts b/src/app/core/integration/models/integration-options.model.ts new file mode 100644 index 0000000000..5f158bd47c --- /dev/null +++ b/src/app/core/integration/models/integration-options.model.ts @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000000..d3383ab94a --- /dev/null +++ b/src/app/core/integration/models/integration.model.ts @@ -0,0 +1,12 @@ +import { autoserialize } from 'cerialize'; + +export abstract class IntegrationModel { + + @autoserialize + public type: string; + + @autoserialize + public _links: { + [name: string]: string + } +} diff --git a/src/app/core/shared/config/config-authority.model.ts b/src/app/core/shared/config/config-authority.model.ts new file mode 100644 index 0000000000..bbb8605bcc --- /dev/null +++ b/src/app/core/shared/config/config-authority.model.ts @@ -0,0 +1,23 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { ConfigObject } from './config.model'; +import { SubmissionSectionModel } from './config-submission-section.model'; + +@inheritSerialization(ConfigObject) +export class ConfigAuthorityModel extends ConfigObject { + + @autoserialize + id: string; + + @autoserialize + display: string; + + @autoserialize + value: string; + + @autoserialize + otherInformation: any; + + @autoserialize + language: string; + +} diff --git a/src/app/core/shared/config/config-object-factory.ts b/src/app/core/shared/config/config-object-factory.ts index 4f56a84812..4cb5016983 100644 --- a/src/app/core/shared/config/config-object-factory.ts +++ b/src/app/core/shared/config/config-object-factory.ts @@ -6,6 +6,7 @@ import { SubmissionFormsModel } from './config-submission-forms.model'; import { SubmissionDefinitionsModel } from './config-submission-definitions.model'; import { ConfigType } from './config-type'; import { ConfigObject } from './config.model'; +import { ConfigAuthorityModel } from './config-authority.model'; export class ConfigObjectFactory { public static getConstructor(type): GenericConstructor { @@ -22,6 +23,9 @@ export class ConfigObjectFactory { case ConfigType.SubmissionSections: { return SubmissionSectionModel } + case ConfigType.Authority: { + return ConfigAuthorityModel + } default: { return undefined; } diff --git a/src/app/core/shared/config/config-submission-forms.model.ts b/src/app/core/shared/config/config-submission-forms.model.ts index 0b094091a7..98d3bf9ce7 100644 --- a/src/app/core/shared/config/config-submission-forms.model.ts +++ b/src/app/core/shared/config/config-submission-forms.model.ts @@ -1,10 +1,14 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { autoserialize, inheritSerialization } from 'cerialize'; import { ConfigObject } from './config.model'; +import { FormFieldModel } from '../../../shared/form/builder/models/form-field.model'; + +export interface FormRowModel { + fields: FormFieldModel[]; +} @inheritSerialization(ConfigObject) export class SubmissionFormsModel extends ConfigObject { @autoserialize - fields: any[]; - + rows: FormRowModel[]; } diff --git a/src/app/core/shared/config/config-type.ts b/src/app/core/shared/config/config-type.ts index ab0a18e516..17ed099229 100644 --- a/src/app/core/shared/config/config-type.ts +++ b/src/app/core/shared/config/config-type.ts @@ -10,5 +10,6 @@ export enum ConfigType { SubmissionForm = 'submissionform', SubmissionForms = 'submissionforms', SubmissionSections = 'submissionsections', - SubmissionSection = 'submissionsection' + SubmissionSection = 'submissionsection', + Authority = 'authority' } diff --git a/src/app/shared/animations/shrink.ts b/src/app/shared/animations/shrink.ts new file mode 100644 index 0000000000..28f1d2a567 --- /dev/null +++ b/src/app/shared/animations/shrink.ts @@ -0,0 +1,13 @@ +import { animate, state, style, transition, trigger } from '@angular/animations'; + +export const shrinkInOut = trigger('shrinkInOut', [ + state('in', style({height: '100%', opacity: 1})), + transition('* => void', [ + style({height: '!', opacity: 1}), + animate(200, style({height: 0, opacity: 0})) + ]), + transition('void => *', [ + style({height: 0, opacity: 0}), + animate(200, style({height: '*', opacity: 1})) + ]) +]); diff --git a/src/app/shared/chips/chips.component.html b/src/app/shared/chips/chips.component.html new file mode 100644 index 0000000000..21ce99ecdb --- /dev/null +++ b/src/app/shared/chips/chips.component.html @@ -0,0 +1,34 @@ +
+ +
diff --git a/src/app/shared/chips/chips.component.scss b/src/app/shared/chips/chips.component.scss new file mode 100644 index 0000000000..9d7eae7edd --- /dev/null +++ b/src/app/shared/chips/chips.component.scss @@ -0,0 +1,9 @@ +@import "../../../styles/variables"; + +.chip-selected { + background-color: map-get($theme-colors, info) !important; +} + +.chip-label { + max-width: 10rem; +} diff --git a/src/app/shared/chips/chips.component.spec.ts b/src/app/shared/chips/chips.component.spec.ts new file mode 100644 index 0000000000..44092ce7d8 --- /dev/null +++ b/src/app/shared/chips/chips.component.spec.ts @@ -0,0 +1,227 @@ +// Load the implementations that should be tested +import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, fakeAsync, inject, TestBed, tick, } from '@angular/core/testing'; +import 'rxjs/add/observable/of'; + +import { Chips } from './models/chips.model'; +import { UploaderService } from '../uploader/uploader.service'; +import { ChipsComponent } from './chips.component'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { SortablejsModule } from 'angular-sortablejs'; +import { By } from '@angular/platform-browser'; +import { FormFieldMetadataValueObject } from '../form/builder/models/form-field-metadata-value.model'; +import { createTestComponent, hasClass } from '../testing/utils'; + +describe('ChipsComponent test suite', () => { + + let testComp: TestComponent; + let chipsComp: ChipsComponent; + let testFixture: ComponentFixture; + let chipsFixture: ComponentFixture; + let html; + let chips: Chips; + + // async beforeEach + beforeEach(async(() => { + + TestBed.configureTestingModule({ + imports: [ + NgbModule.forRoot(), + SortablejsModule.forRoot({animation: 150}), + ], + declarations: [ + ChipsComponent, + TestComponent, + ], // declare the test component + providers: [ + ChangeDetectorRef, + ChipsComponent, + UploaderService + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + + })); + + describe('', () => { + // synchronous beforeEach + beforeEach(() => { + html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + it('should create Chips Component', inject([ChipsComponent], (app: ChipsComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('when has items as string', () => { + beforeEach(() => { + chips = new Chips(['a', 'b', 'c']); + chipsFixture = TestBed.createComponent(ChipsComponent); + chipsComp = chipsFixture.componentInstance; // TruncatableComponent test instance + chipsComp.editable = true; + chipsComp.chips = chips; + chipsFixture.detectChanges(); + }); + + afterEach(() => { + chipsFixture.destroy(); + chipsComp = null; + }); + + it('should set edit mode when a chip item is selected', fakeAsync(() => { + + spyOn(chipsComp.selected, 'emit'); + + chipsComp.chipsSelected(new Event('click'), 1); + chipsFixture.detectChanges(); + tick(); + + const item = chipsComp.chips.getChipByIndex(1); + + expect(item.editMode).toBe(true); + expect(chipsComp.selected.emit).toHaveBeenCalledWith(1); + })); + + it('should not set edit mode when a chip item is selected and editable is false', fakeAsync(() => { + chipsComp.editable = false; + spyOn(chipsComp.selected, 'emit'); + + chipsComp.chipsSelected(new Event('click'), 1); + chipsFixture.detectChanges(); + tick(); + + const item = chipsComp.chips.getChipByIndex(1); + + expect(item.editMode).toBe(false); + expect(chipsComp.selected.emit).not.toHaveBeenCalledWith(1); + })); + + it('should emit when a chip item is removed and editable is true', fakeAsync(() => { + + spyOn(chipsComp.chips, 'remove'); + + const item = chipsComp.chips.getChipByIndex(1); + + chipsComp.removeChips(new Event('click'), 1); + chipsFixture.detectChanges(); + tick(); + + expect(chipsComp.chips.remove).toHaveBeenCalledWith(item); + })); + + it('should save chips item index when drag and drop start', fakeAsync(() => { + const de = chipsFixture.debugElement.query(By.css('li.nav-item')); + + de.triggerEventHandler('dragstart', null); + + expect(chipsComp.dragged).toBe(0); + })); + + it('should update chips item order when drag and drop end', fakeAsync(() => { + spyOn(chipsComp.chips, 'updateOrder'); + const de = chipsFixture.debugElement.query(By.css('li.nav-item')); + + de.triggerEventHandler('dragend', null); + + expect(chipsComp.dragged).toBe(-1); + expect(chipsComp.chips.updateOrder).toHaveBeenCalled(); + })); + }); + + describe('when has items as object', () => { + beforeEach(() => { + const item = { + mainField: new FormFieldMetadataValueObject('main test', null, 'test001'), + relatedField: new FormFieldMetadataValueObject('related test', null, 'test002'), + otherRelatedField: new FormFieldMetadataValueObject('other related test') + }; + const iconsConfig = [ + { + name: 'mainField', + config: { + withAuthority:{ + style: 'fa-user' + } + } + }, + { + name: 'relatedField', + config: { + withAuthority:{ + style: 'fa-user-alt' + }, + withoutAuthority:{ + style: 'fa-user-alt text-muted' + } + } + }, + { + name: 'otherRelatedField', + config: { + withAuthority:{ + style: 'fa-user-alt' + }, + withoutAuthority:{ + style: 'fa-user-alt text-muted' + } + } + }, + { + name: 'default', + config: {} + } + ]; + + chips = new Chips([item], 'display', 'mainField', iconsConfig); + chipsFixture = TestBed.createComponent(ChipsComponent); + chipsComp = chipsFixture.componentInstance; // TruncatableComponent test instance + chipsComp.editable = true; + chipsComp.chips = chips; + chipsFixture.detectChanges(); + }); + + it('should show icon for every field that has a configured icon', () => { + const de = chipsFixture.debugElement.query(By.css('li.nav-item')); + const icons = de.queryAll(By.css('i.fa')); + + expect(icons.length).toBe(4); + + }); + + it('should has text-muted on icon style when field value had not authority', () => { + const de = chipsFixture.debugElement.query(By.css('li.nav-item')); + const icons = de.queryAll(By.css('i.fa')); + + expect(hasClass(icons[2].nativeElement, 'text-muted')).toBeTruthy(); + }); + + it('should show tooltip on mouse over an icon', () => { + const de = chipsFixture.debugElement.query(By.css('li.nav-item')); + const icons = de.queryAll(By.css('i.fa')); + + icons[0].triggerEventHandler('mouseover', null); + + expect(chipsComp.tipText).toBe('main test') + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + public chips = new Chips(['a', 'b', 'c']); + public editable = true; +} diff --git a/src/app/shared/chips/chips.component.ts b/src/app/shared/chips/chips.component.ts new file mode 100644 index 0000000000..6032cc7659 --- /dev/null +++ b/src/app/shared/chips/chips.component.ts @@ -0,0 +1,95 @@ +import { ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, } from '@angular/core'; + +import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; +import { SortablejsOptions } from 'angular-sortablejs'; +import { isObject } from 'lodash'; + +import { Chips } from './models/chips.model'; +import { ChipsItem } from './models/chips-item.model'; +import { UploaderService } from '../uploader/uploader.service'; + +@Component({ + selector: 'ds-chips', + styleUrls: ['./chips.component.scss'], + templateUrl: './chips.component.html', +}) + +export class ChipsComponent implements OnChanges { + @Input() chips: Chips; + @Input() wrapperClass: string; + @Input() editable = true; + + @Output() selected: EventEmitter = new EventEmitter(); + @Output() remove: EventEmitter = new EventEmitter(); + @Output() change: EventEmitter = new EventEmitter(); + + options: SortablejsOptions; + dragged = -1; + tipText: string; + + constructor(private cdr: ChangeDetectorRef, private uploaderService: UploaderService) { + this.options = { + animation: 300, + chosenClass: 'm-0', + dragClass: 'm-0', + filter: '.chips-sort-ignore', + ghostClass: 'm-0' + }; + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.chips && !changes.chips.isFirstChange()) { + this.chips = changes.chips.currentValue; + } + } + + chipsSelected(event: Event, index: number) { + event.preventDefault(); + if (this.editable) { + this.chips.getChips().forEach((item: ChipsItem, i: number) => { + if (i === index) { + item.setEditMode(); + } else { + item.unsetEditMode(); + } + }); + this.selected.emit(index); + } + } + + removeChips(event: Event, index: number) { + event.preventDefault(); + event.stopPropagation(); + // Can't remove if this element is in editMode + if (!this.chips.getChipByIndex(index).editMode) { + this.chips.remove(this.chips.getChipByIndex(index)); + } + } + + onDragStart(index) { + this.uploaderService.overrideDragOverPage(); + this.dragged = index; + } + + onDragEnd(event) { + this.uploaderService.allowDragOverPage(); + this.dragged = -1; + this.chips.updateOrder(); + } + + showTooltip(tooltip: NgbTooltip, index, field?) { + tooltip.close(); + const item = this.chips.getChipByIndex(index); + if (!item.editMode && this.dragged === -1) { + if (field) { + this.tipText = (isObject(item.item[field])) ? item.item[field].display : item.item[field]; + } else { + this.tipText = item.display; + } + + this.cdr.detectChanges(); + tooltip.open(); + } + } + +} diff --git a/src/app/shared/chips/models/chips-item.model.spec.ts b/src/app/shared/chips/models/chips-item.model.spec.ts new file mode 100644 index 0000000000..72e4e37cab --- /dev/null +++ b/src/app/shared/chips/models/chips-item.model.spec.ts @@ -0,0 +1,78 @@ +import { ChipsItem, ChipsItemIcon } from './chips-item.model'; +import { FormFieldMetadataValueObject } from '../../form/builder/models/form-field-metadata-value.model'; + +describe('ChipsItem model test suite', () => { + let item: ChipsItem; + + beforeEach(() => { + item = new ChipsItem('a'); + }); + + it('should init ChipsItem object properly', () => { + expect(item.item).toBe('a'); + expect(item.display).toBe('a'); + expect(item.editMode).toBe(false); + expect(item.icons).toEqual([]); + }); + + it('should update item', () => { + item.updateItem('b'); + + expect(item.item).toBe('b'); + }); + + it('should set editMode', () => { + item.setEditMode(); + + expect(item.editMode).toBe(true); + }); + + it('should unset editMode', () => { + item.unsetEditMode(); + + expect(item.editMode).toBe(false); + }); + + it('should update icons', () => { + const icons: ChipsItemIcon[] = [{metadata: 'test', hasAuthority: false, style: 'fa fa-plus'}]; + item.updateIcons(icons); + + expect(item.icons).toEqual(icons); + }); + + it('should return true if has icons', () => { + const icons: ChipsItemIcon[] = [{metadata: 'test', hasAuthority: false, style: 'fa fa-plus'}]; + item.updateIcons(icons); + const hasIcons = item.hasIcons(); + + expect(hasIcons).toBe(true); + }); + + it('should return false if has not icons', () => { + const hasIcons = item.hasIcons(); + + expect(hasIcons).toBe(false); + }); + + it('should set display property with a different fieldToDisplay', () => { + item = new ChipsItem( + { + label: 'A', + value: 'a' + }, + 'label'); + + expect(item.display).toBe('A'); + }); + + it('should set display property with a different objToDisplay', () => { + item = new ChipsItem( + { + toDisplay: new FormFieldMetadataValueObject('a', null, 'a'), + otherProperty: 'other' + }, + 'value', 'toDisplay'); + + expect(item.display).toBe('a'); + }); +}); diff --git a/src/app/shared/chips/models/chips-item.model.ts b/src/app/shared/chips/models/chips-item.model.ts new file mode 100644 index 0000000000..b0798ce453 --- /dev/null +++ b/src/app/shared/chips/models/chips-item.model.ts @@ -0,0 +1,72 @@ +import { uniqueId, isObject } from 'lodash'; +import { isNotEmpty } from '../../empty.util'; + +export interface ChipsItemIcon { + metadata: string; + hasAuthority: boolean; + style: string; + tooltip?: any; +} + +export class ChipsItem { + public id: string; + public display: string; + public item: any; + public editMode?: boolean; + public icons?: ChipsItemIcon[]; + + private fieldToDisplay: string; + private objToDisplay: string; + + constructor(item: any, + fieldToDisplay: string = 'display', + objToDisplay?: string, + icons?: ChipsItemIcon[], + editMode?: boolean) { + + this.id = uniqueId(); + this.item = item; + this.fieldToDisplay = fieldToDisplay; + this.objToDisplay = objToDisplay; + this.setDisplayText(); + this.editMode = editMode || false; + this.icons = icons || []; + } + + hasIcons(): boolean { + return isNotEmpty(this.icons); + } + + setEditMode(): void { + this.editMode = true; + } + + updateIcons(icons: ChipsItemIcon[]): void { + this.icons = icons; + } + + updateItem(item: any): void { + this.item = item; + this.setDisplayText(); + } + + unsetEditMode(): void { + this.editMode = false; + } + + private setDisplayText(): void { + let value = this.item; + if (isObject(this.item)) { + // Check If displayField is in an internal object + const obj = this.objToDisplay ? this.item[this.objToDisplay] : this.item; + + if (isObject(obj) && obj) { + value = obj[this.fieldToDisplay] || obj.value; + } else { + value = obj; + } + } + + this.display = value; + } +} diff --git a/src/app/shared/chips/models/chips.model.spec.ts b/src/app/shared/chips/models/chips.model.spec.ts new file mode 100644 index 0000000000..f7564819fd --- /dev/null +++ b/src/app/shared/chips/models/chips.model.spec.ts @@ -0,0 +1,126 @@ +import { Chips } from './chips.model'; +import { ChipsItem } from './chips-item.model'; +import { FormFieldMetadataValueObject } from '../../form/builder/models/form-field-metadata-value.model'; + +describe('Chips model test suite', () => { + let items: any[]; + let item: ChipsItem; + let chips: Chips; + + beforeEach(() => { + items = ['a', 'b', 'c']; + chips = new Chips(items); + }); + + it('should init Chips object properly', () => { + expect(chips.getChipsItems()).toEqual(items); + expect(chips.displayField).toBe('display'); + expect(chips.displayObj).toBe(undefined); + expect(chips.iconsConfig).toEqual([]); + }); + + it('should add an element to items', () => { + items = ['a', 'b', 'c', 'd']; + chips.add('d'); + expect(chips.getChipsItems()).toEqual(items); + }); + + it('should remove an element from items', () => { + items = ['a', 'c']; + item = chips.getChipByIndex(1); + chips.remove(item); + expect(chips.getChipsItems()).toEqual(items); + }); + + it('should update an item', () => { + items = ['a', 'd', 'c']; + const id = chips.getChipByIndex(1).id; + chips.update(id, 'd'); + expect(chips.getChipsItems()).toEqual(items); + }); + + it('should update items order', () => { + items = ['a', 'c', 'b']; + const chipsItems = chips.getChips(); + const b = chipsItems[1]; + chipsItems[1] = chipsItems[2]; + chipsItems[2] = b; + chips.updateOrder(); + expect(chips.getChipsItems()).toEqual(items); + }); + + it('should set a different displayField', () => { + items = [ + { + label: 'A', + value: 'a' + }, + { + label: 'B', + value: 'b' + }, + { + label: 'C', + value: 'c' + }, + ]; + chips = new Chips(items, 'label'); + expect(chips.displayField).toBe('label'); + expect(chips.getChipsItems()).toEqual(items); + }); + + it('should set a different displayObj', () => { + items = [ + { + toDisplay: new FormFieldMetadataValueObject('a', null, 'a'), + otherProperty: 'a' + }, + { + toDisplay: new FormFieldMetadataValueObject('a', null, 'a'), + otherProperty: 'a' + }, + { + toDisplay: new FormFieldMetadataValueObject('a', null, 'a'), + otherProperty: 'a' + }, + ]; + chips = new Chips(items, 'value', 'toDisplay'); + expect(chips.displayField).toBe('value'); + expect(chips.displayObj).toBe('toDisplay'); + expect(chips.getChipsItems()).toEqual(items); + }); + + it('should set iconsConfig', () => { + items = [ + { + toDisplay: new FormFieldMetadataValueObject('a', null, 'a'), + otherProperty: 'a' + }, + { + toDisplay: new FormFieldMetadataValueObject('a', null, 'a'), + otherProperty: 'a' + }, + { + toDisplay: new FormFieldMetadataValueObject('a', null, 'a'), + otherProperty: 'a' + }, + ]; + const iconsConfig = [{ + name: 'toDisplay', + config: { + withAuthority:{ + style: 'fa-user' + }, + withoutAuthority:{ + style: 'fa-user text-muted' + } + } + }]; + chips = new Chips(items, 'value', 'toDisplay', iconsConfig); + + expect(chips.displayField).toBe('value'); + expect(chips.displayObj).toBe('toDisplay'); + expect(chips.iconsConfig).toEqual(iconsConfig); + expect(chips.getChipsItems()).toEqual(items); + }); +}); diff --git a/src/app/shared/chips/models/chips.model.ts b/src/app/shared/chips/models/chips.model.ts new file mode 100644 index 0000000000..e133a416f4 --- /dev/null +++ b/src/app/shared/chips/models/chips.model.ts @@ -0,0 +1,159 @@ +import { findIndex, isEqual, isObject } from 'lodash'; +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; +import { ChipsItem, ChipsItemIcon } from './chips-item.model'; +import { hasValue, isNotEmpty } from '../../empty.util'; + +export interface IconsConfig { + withAuthority?: { + style: string; + }; + + withoutAuthority?: { + style: string; + }; +} + +export interface MetadataIconsConfig { + name: string; + config: IconsConfig; +} + +export class Chips { + chipsItems: BehaviorSubject; + displayField: string; + displayObj: string; + iconsConfig: MetadataIconsConfig[]; + + private _items: ChipsItem[]; + + constructor(items: any[] = [], + displayField: string = 'display', + displayObj?: string, + iconsConfig?: MetadataIconsConfig[]) { + + this.displayField = displayField; + this.displayObj = displayObj; + this.iconsConfig = iconsConfig || []; + if (Array.isArray(items)) { + this.setInitialItems(items); + } + } + + public add(item: any): void { + const icons = this.getChipsIcons(item); + const chipsItem = new ChipsItem(item, this.displayField, this.displayObj, icons); + + const duplicatedIndex = findIndex(this._items, {display: chipsItem.display.trim()}); + if (duplicatedIndex === -1 || !isEqual(item, this.getChipByIndex(duplicatedIndex).item)) { + this._items.push(chipsItem); + this.chipsItems.next(this._items); + } + } + + public getChipById(id): ChipsItem { + const index = findIndex(this._items, {id: id}); + return this.getChipByIndex(index); + } + + public getChipByIndex(index): ChipsItem { + if (this._items.length > 0 && this._items[index]) { + return this._items[index]; + } else { + return null; + } + } + + public getChips(): ChipsItem[] { + return this._items; + } + + /** + * To use to get items before to store it + * @returns {any[]} + */ + public getChipsItems(): any[] { + const out = []; + this._items.forEach((item) => { + out.push(item.item); + }); + return out; + } + + public hasItems(): boolean { + return this._items.length > 0; + } + + public remove(chipsItem: ChipsItem): void { + const index = findIndex(this._items, {id: chipsItem.id}); + this._items.splice(index, 1); + this.chipsItems.next(this._items); + } + + public update(id: string, item: any): void { + const chipsItemTarget = this.getChipById(id); + const icons = this.getChipsIcons(item); + + chipsItemTarget.updateItem(item); + chipsItemTarget.updateIcons(icons); + chipsItemTarget.unsetEditMode(); + this.chipsItems.next(this._items); + } + + public updateOrder(): void { + this.chipsItems.next(this._items); + } + + private getChipsIcons(item) { + const icons = []; + const defaultConfigIndex: number = findIndex(this.iconsConfig, {name: 'default'}); + const defaultConfig: IconsConfig = (defaultConfigIndex !== -1) ? this.iconsConfig[defaultConfigIndex].config : undefined; + let config: IconsConfig; + let configIndex: number; + let value: any; + + Object.keys(item) + .forEach((metadata) => { + + value = item[metadata]; + configIndex = findIndex(this.iconsConfig, {name: metadata}); + + config = (configIndex !== -1) ? this.iconsConfig[configIndex].config : defaultConfig; + + if (hasValue(value) && isNotEmpty(config)) { + + let icon: ChipsItemIcon; + const hasAuthority: boolean = !!(isObject(value) && ((value.hasOwnProperty('authority') && value.authority) || (value.hasOwnProperty('id') && value.id))); + + // Set icons + if ((this.displayObj && this.displayObj === metadata && hasAuthority) + || (this.displayObj && this.displayObj !== metadata)) { + + icon = { + metadata, + hasAuthority: hasAuthority, + style: (hasAuthority) ? config.withAuthority.style : config.withoutAuthority.style + }; + } + if (icon) { + icons.push(icon); + } + } + }); + + return icons; + } + + /** + * Sets initial items, used in edit mode + */ + private setInitialItems(items: any[]): void { + this._items = []; + items.forEach((item) => { + const icons = this.getChipsIcons(item); + const chipsItem = new ChipsItem(item, this.displayField, this.displayObj, icons); + this._items.push(chipsItem); + }); + + this.chipsItems = new BehaviorSubject(this._items); + } +} diff --git a/src/app/shared/date.util.ts b/src/app/shared/date.util.ts new file mode 100644 index 0000000000..90f9ff9b39 --- /dev/null +++ b/src/app/shared/date.util.ts @@ -0,0 +1,19 @@ +import { NgbDateStruct } from '@ng-bootstrap/ng-bootstrap'; + +export function dateToGMTString(date: Date | NgbDateStruct) { + let year = ((date instanceof Date) ? date.getFullYear() : date.year).toString(); + let month = ((date instanceof Date) ? date.getMonth() + 1 : date.month).toString(); + let day = ((date instanceof Date) ? date.getDate() : date.day).toString(); + let hour = ((date instanceof Date) ? date.getHours() : 0).toString(); + let min = ((date instanceof Date) ? date.getMinutes() : 0).toString(); + let sec = ((date instanceof Date) ? date.getSeconds() : 0).toString(); + + year = (year.length === 1) ? '0' + year : year; + month = (month.length === 1) ? '0' + month : month; + day = (day.length === 1) ? '0' + day : day; + hour = (hour.length === 1) ? '0' + hour : hour; + min = (min.length === 1) ? '0' + min : min; + sec = (sec.length === 1) ? '0' + sec : sec; + return `${year}-${month}-${day}T${hour}:${min}:${sec}Z`; + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.html new file mode 100644 index 0000000000..db5bc92574 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.html @@ -0,0 +1,461 @@ +
+ + + + + +
+
+ + + + +
+ +
+ + + + +
+
+ + + + + + +
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+
+ + +
+ + +
+ + +
+ +
+ +
+ + + + + +
+ +
+ + + + +
+ + +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{ message | translate:model.validators }} +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ + + + + +
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.spec.ts new file mode 100644 index 0000000000..7fc756c470 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.spec.ts @@ -0,0 +1,281 @@ +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { CUSTOM_ELEMENTS_SCHEMA, DebugElement, SimpleChange } from '@angular/core'; +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TextMaskModule } from 'angular2-text-mask'; +import { + DynamicCheckboxGroupModel, + DynamicCheckboxModel, + DynamicColorPickerModel, + DynamicDatePickerModel, + DynamicEditorModel, + DynamicFileUploadModel, + DynamicFormArrayModel, + DynamicFormControlModel, + DynamicFormGroupModel, + DynamicFormsCoreModule, + DynamicFormService, + DynamicInputModel, + DynamicRadioGroupModel, + DynamicRatingModel, + DynamicSelectModel, + DynamicSliderModel, + DynamicSwitchModel, + DynamicTextAreaModel, + DynamicTimePickerModel +} from '@ng-dynamic-forms/core'; +import { DsDynamicFormControlComponent, NGBootstrapFormControlType } from './ds-dynamic-form-control.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { SharedModule } from '../../../shared.module'; +import { DynamicDsDatePickerModel } from './models/date-picker/date-picker.model'; +import { DynamicGroupModel } from './models/dynamic-group/dynamic-group.model'; +import { DynamicListCheckboxGroupModel } from './models/list/dynamic-list-checkbox-group.model'; +import { AuthorityOptions } from '../../../../core/integration/models/authority-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 { DynamicQualdropModel } from './models/ds-dynamic-qualdrop.model'; +import { DynamicLookupNameModel } from './models/lookup/dynamic-lookup-name.model'; + +describe('DsDynamicFormControlComponent test suite', () => { + + const authorityOptions: AuthorityOptions = { + closed: false, + metadata: 'list', + name: 'type_programme', + scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + }; + const formModel = [ + new DynamicCheckboxModel({id: 'checkbox'}), + new DynamicCheckboxGroupModel({id: 'checkboxGroup', group: []}), + new DynamicColorPickerModel({id: 'colorpicker'}), + new DynamicDatePickerModel({id: 'datepicker'}), + new DynamicEditorModel({id: 'editor'}), + new DynamicFileUploadModel({id: 'upload', url: ''}), + new DynamicFormArrayModel({id: 'formArray', groupFactory: () => []}), + new DynamicFormGroupModel({id: 'formGroup', group: []}), + new DynamicInputModel({id: 'input', maxLength: 51}), + new DynamicRadioGroupModel({id: 'radioGroup'}), + new DynamicRatingModel({id: 'rating'}), + new DynamicSelectModel({id: 'select', options: [{value: 'One'}, {value: 'Two'}], value: 'One'}), + new DynamicSliderModel({id: 'slider'}), + new DynamicSwitchModel({id: 'switch'}), + new DynamicTextAreaModel({id: 'textarea'}), + new DynamicTimePickerModel({id: 'timepicker'}), + new DynamicTypeaheadModel({id: 'typeahead'}), + new DynamicScrollableDropdownModel({id: 'scrollableDropdown', authorityOptions: authorityOptions}), + new DynamicTagModel({id: 'tag'}), + new DynamicListCheckboxGroupModel({id: 'checkboxList', authorityOptions: authorityOptions, repeatable: true}), + new DynamicListRadioGroupModel({id: 'radioList', authorityOptions: authorityOptions, repeatable: false}), + new DynamicGroupModel({ + id: 'relationGroup', + formConfiguration: [], + mandatoryField: '', + name: 'relationGroup', + relationFields: [], + scopeUUID: '', + submissionScope: '' + }), + new DynamicDsDatePickerModel({id: 'datepicker'}), + new DynamicLookupModel({id: 'lookup'}), + new DynamicLookupNameModel({id: 'lookupName'}), + new DynamicQualdropModel({id: 'combobox', readOnly: false}) + ]; + const testModel = formModel[8]; + let formGroup: FormGroup; + let fixture: ComponentFixture; + let component: DsDynamicFormControlComponent; + let debugElement: DebugElement; + let testElement: DebugElement; + + beforeEach(async(() => { + + TestBed.configureTestingModule({ + + imports: [ + FormsModule, + ReactiveFormsModule, + NgbModule.forRoot(), + DynamicFormsCoreModule.forRoot(), + SharedModule, + TranslateModule.forRoot(), + TextMaskModule, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents().then(() => { + + fixture = TestBed.createComponent(DsDynamicFormControlComponent); + + component = fixture.componentInstance; + debugElement = fixture.debugElement; + }); + })); + + beforeEach(inject([DynamicFormService], (service: DynamicFormService) => { + + formGroup = service.createFormGroup(formModel); + + component.group = formGroup; + component.model = testModel; + + component.ngOnChanges({ + + group: new SimpleChange(null, component.group, true), + model: new SimpleChange(null, component.model, true) + }); + + fixture.detectChanges(); + + testElement = debugElement.query(By.css(`input[id='${testModel.id}']`)); + })); + + it('should initialize correctly', () => { + + expect(component.context).toBeNull(); + expect(component.control instanceof FormControl).toBe(true); + expect(component.group instanceof FormGroup).toBe(true); + expect(component.model instanceof DynamicFormControlModel).toBe(true); + expect(component.hasErrorMessaging).toBe(false); + expect(component.asBootstrapFormGroup).toBe(true); + + expect(component.onControlValueChanges).toBeDefined(); + expect(component.onModelDisabledUpdates).toBeDefined(); + expect(component.onModelValueUpdates).toBeDefined(); + + expect(component.blur).toBeDefined(); + expect(component.change).toBeDefined(); + expect(component.focus).toBeDefined(); + + expect(component.onValueChange).toBeDefined(); + expect(component.onBlur).toBeDefined(); + expect(component.onFocus).toBeDefined(); + + expect(component.isValid).toBe(true); + expect(component.isInvalid).toBe(false); + expect(component.showErrorMessages).toBe(false); + + expect(component.type).toBe(NGBootstrapFormControlType.Input); + }); + + it('should have an input element', () => { + + expect(testElement instanceof DebugElement).toBe(true); + }); + + it('should listen to native blur events', () => { + + spyOn(component, 'onBlur'); + + testElement.triggerEventHandler('blur', null); + + expect(component.onBlur).toHaveBeenCalled(); + }); + + it('should listen to native focus events', () => { + + spyOn(component, 'onFocus'); + + testElement.triggerEventHandler('focus', null); + + expect(component.onFocus).toHaveBeenCalled(); + }); + + it('should listen to native change event', () => { + + spyOn(component, 'onValueChange'); + + testElement.triggerEventHandler('change', null); + + expect(component.onValueChange).toHaveBeenCalled(); + }); + + it('should update model value when control value changes', () => { + + spyOn(component, 'onControlValueChanges'); + + component.control.setValue('test'); + + expect(component.onControlValueChanges).toHaveBeenCalled(); + }); + + it('should update control value when model value changes', () => { + + spyOn(component, 'onModelValueUpdates'); + + (testModel as DynamicInputModel).valueUpdates.next('test'); + + expect(component.onModelValueUpdates).toHaveBeenCalled(); + }); + + it('should update control activation when model disabled property changes', () => { + + spyOn(component, 'onModelDisabledUpdates'); + + testModel.disabledUpdates.next(true); + + expect(component.onModelDisabledUpdates).toHaveBeenCalled(); + }); + + it('should determine correct form control type', () => { + + const testFn = DsDynamicFormControlComponent.getFormControlType; + + expect(testFn(formModel[0])).toEqual(NGBootstrapFormControlType.Checkbox); + + expect(testFn(formModel[1])).toEqual(NGBootstrapFormControlType.CheckboxGroup); + + expect(testFn(formModel[2])).toBeNull(); + + expect(testFn(formModel[3])).toEqual(NGBootstrapFormControlType.DatePicker); + + (formModel[3] as DynamicDatePickerModel).inline = true; + expect(testFn(formModel[3])).toEqual(NGBootstrapFormControlType.Calendar); + + expect(testFn(formModel[4])).toBeNull(); + + expect(testFn(formModel[5])).toBeNull(); + + expect(testFn(formModel[6])).toEqual(NGBootstrapFormControlType.Array); + + expect(testFn(formModel[7])).toEqual(NGBootstrapFormControlType.Group); + + expect(testFn(formModel[8])).toEqual(NGBootstrapFormControlType.Input); + + expect(testFn(formModel[9])).toEqual(NGBootstrapFormControlType.RadioGroup); + + expect(testFn(formModel[10])).toBeNull(); + + expect(testFn(formModel[11])).toEqual(NGBootstrapFormControlType.Select); + + expect(testFn(formModel[12])).toBeNull(); + + expect(testFn(formModel[13])).toBeNull(); + + expect(testFn(formModel[14])).toEqual(NGBootstrapFormControlType.TextArea); + + expect(testFn(formModel[15])).toEqual(NGBootstrapFormControlType.TimePicker); + + expect(testFn(formModel[16])).toEqual(NGBootstrapFormControlType.TypeAhead); + + expect(testFn(formModel[17])).toEqual(NGBootstrapFormControlType.ScrollableDropdown); + + expect(testFn(formModel[18])).toEqual(NGBootstrapFormControlType.Tag); + + expect(testFn(formModel[19])).toEqual(NGBootstrapFormControlType.List); + + expect(testFn(formModel[20])).toEqual(NGBootstrapFormControlType.List); + + expect(testFn(formModel[21])).toEqual(NGBootstrapFormControlType.Relation); + + expect(testFn(formModel[22])).toEqual(NGBootstrapFormControlType.Date); + + expect(testFn(formModel[23])).toEqual(NGBootstrapFormControlType.Lookup); + + expect(testFn(formModel[24])).toEqual(NGBootstrapFormControlType.LookupName); + + expect(testFn(formModel[25])).toEqual(NGBootstrapFormControlType.Group); + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.ts new file mode 100644 index 0000000000..3a39d22bef --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.ts @@ -0,0 +1,178 @@ +import { + ChangeDetectorRef, + Component, + ContentChildren, + EventEmitter, + Input, + OnChanges, + Output, + QueryList, + SimpleChanges +} from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { + DynamicDatePickerModel, + DynamicFormControlComponent, + DynamicFormControlEvent, + DynamicFormControlModel, + DynamicFormLayout, + DynamicFormLayoutService, + DynamicFormValidationService, + DynamicTemplateDirective, + DYNAMIC_FORM_CONTROL_TYPE_ARRAY, + DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX, + DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX_GROUP, + DYNAMIC_FORM_CONTROL_TYPE_DATEPICKER, + DYNAMIC_FORM_CONTROL_TYPE_GROUP, + DYNAMIC_FORM_CONTROL_TYPE_INPUT, + DYNAMIC_FORM_CONTROL_TYPE_RADIO_GROUP, + DYNAMIC_FORM_CONTROL_TYPE_SELECT, + DYNAMIC_FORM_CONTROL_TYPE_TEXTAREA, + DYNAMIC_FORM_CONTROL_TYPE_TIMEPICKER, +} from '@ng-dynamic-forms/core'; +import { DYNAMIC_FORM_CONTROL_TYPE_TYPEAHEAD } from './models/typeahead/dynamic-typeahead.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_RELATION_GROUP } from './models/dynamic-group/dynamic-group.model'; +import { DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER } from './models/date-picker/date-picker.model'; +import { DYNAMIC_FORM_CONTROL_TYPE_LOOKUP } from './models/lookup/dynamic-lookup.model'; +import { DynamicListCheckboxGroupModel } from './models/list/dynamic-list-checkbox-group.model'; +import { DynamicListRadioGroupModel } from './models/list/dynamic-list-radio-group.model'; +import { isNotEmpty } from '../../../empty.util'; +import { DYNAMIC_FORM_CONTROL_TYPE_LOOKUP_NAME } from './models/lookup/dynamic-lookup-name.model'; + +export const enum NGBootstrapFormControlType { + + Array = 1, // 'ARRAY', + Calendar = 2, // 'CALENDAR', + Checkbox = 3, // 'CHECKBOX', + CheckboxGroup = 4, // 'CHECKBOX_GROUP', + DatePicker = 5, // 'DATEPICKER', + Group = 6, // 'GROUP', + Input = 7, // 'INPUT', + RadioGroup = 8, // 'RADIO_GROUP', + Select = 9, // 'SELECT', + TextArea = 10, // 'TEXTAREA', + TimePicker = 11, // 'TIMEPICKER' + TypeAhead = 12, // 'TYPEAHEAD' + ScrollableDropdown = 13, // 'SCROLLABLE_DROPDOWN' + Tag = 14, // 'TAG' + List = 15, // 'TYPELIST' + Relation = 16, // 'RELATION' + Date = 17, // 'DATE' + Lookup = 18, // LOOKUP + LookupName = 19, // LOOKUP_NAME +} + +@Component({ + selector: 'ds-dynamic-form-control', + styleUrls: ['../../form.component.scss', './ds-dynamic-form.component.scss'], + templateUrl: './ds-dynamic-form-control.component.html' +}) +export class DsDynamicFormControlComponent extends DynamicFormControlComponent implements OnChanges { + + @ContentChildren(DynamicTemplateDirective) contentTemplateList: QueryList; + // tslint:disable-next-line:no-input-rename + @Input('templates') inputTemplateList: QueryList; + + @Input() formId: string; + @Input() asBootstrapFormGroup = true; + @Input() bindId = true; + @Input() context: any | null = null; + @Input() group: FormGroup; + @Input() hasErrorMessaging = false; + @Input() layout: DynamicFormLayout; + @Input() model: any; + + /* tslint:disable:no-output-rename */ + @Output('dfBlur') blur: EventEmitter = new EventEmitter(); + @Output('dfChange') change: EventEmitter = new EventEmitter(); + @Output('dfFocus') focus: EventEmitter = new EventEmitter(); + /* tslint:enable:no-output-rename */ + + type: NGBootstrapFormControlType | null; + + static getFormControlType(model: DynamicFormControlModel): NGBootstrapFormControlType | null { + + switch (model.type) { + + case DYNAMIC_FORM_CONTROL_TYPE_ARRAY: + return NGBootstrapFormControlType.Array; + + case DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX: + return NGBootstrapFormControlType.Checkbox; + + case DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX_GROUP: + return (model instanceof DynamicListCheckboxGroupModel) ? NGBootstrapFormControlType.List : NGBootstrapFormControlType.CheckboxGroup; + + case DYNAMIC_FORM_CONTROL_TYPE_DATEPICKER: + const datepickerModel = model as DynamicDatePickerModel; + + return datepickerModel.inline ? NGBootstrapFormControlType.Calendar : NGBootstrapFormControlType.DatePicker; + + case DYNAMIC_FORM_CONTROL_TYPE_GROUP: + return NGBootstrapFormControlType.Group; + + case DYNAMIC_FORM_CONTROL_TYPE_INPUT: + return NGBootstrapFormControlType.Input; + + case DYNAMIC_FORM_CONTROL_TYPE_RADIO_GROUP: + return (model instanceof DynamicListRadioGroupModel) ? NGBootstrapFormControlType.List : NGBootstrapFormControlType.RadioGroup; + + case DYNAMIC_FORM_CONTROL_TYPE_SELECT: + return NGBootstrapFormControlType.Select; + + case DYNAMIC_FORM_CONTROL_TYPE_TEXTAREA: + return NGBootstrapFormControlType.TextArea; + + case DYNAMIC_FORM_CONTROL_TYPE_TIMEPICKER: + return NGBootstrapFormControlType.TimePicker; + + case DYNAMIC_FORM_CONTROL_TYPE_TYPEAHEAD: + return NGBootstrapFormControlType.TypeAhead; + + case DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN: + return NGBootstrapFormControlType.ScrollableDropdown; + + case DYNAMIC_FORM_CONTROL_TYPE_TAG: + return NGBootstrapFormControlType.Tag; + + case DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP: + return NGBootstrapFormControlType.Relation; + + case DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER: + return NGBootstrapFormControlType.Date; + + case DYNAMIC_FORM_CONTROL_TYPE_LOOKUP: + return NGBootstrapFormControlType.Lookup; + + case DYNAMIC_FORM_CONTROL_TYPE_LOOKUP_NAME: + return NGBootstrapFormControlType.LookupName; + + default: + return null; + } + } + + constructor(protected changeDetectorRef: ChangeDetectorRef, protected layoutService: DynamicFormLayoutService, + protected validationService: DynamicFormValidationService) { + + super(changeDetectorRef, layoutService, validationService); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes) { + super.ngOnChanges(changes); + } + + if (changes.model) { + this.type = DsDynamicFormControlComponent.getFormControlType(this.model); + } + } + + onChangeLanguage(event) { + if (isNotEmpty((this.model as any).value)) { + this.onValueChange(event); + } + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.html new file mode 100644 index 0000000000..ea151726f4 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.html @@ -0,0 +1,12 @@ + diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.scss new file mode 100644 index 0000000000..52facc2f2c --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.scss @@ -0,0 +1,5 @@ +:host { + display: flex; + flex-direction: column; + justify-content: center; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.ts new file mode 100644 index 0000000000..7789d910a8 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.ts @@ -0,0 +1,47 @@ +import { + Component, + ContentChildren, + EventEmitter, + Input, + Output, + QueryList, + ViewChildren +} from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { + DynamicFormComponent, + DynamicFormControlEvent, + DynamicFormControlModel, + DynamicFormLayout, + DynamicFormLayoutService, + DynamicFormService, + DynamicTemplateDirective, +} from '@ng-dynamic-forms/core'; +import { DsDynamicFormControlComponent } from './ds-dynamic-form-control.component'; +import { FormBuilderService } from '../form-builder.service'; + +@Component({ + selector: 'ds-dynamic-form', + templateUrl: './ds-dynamic-form.component.html' +}) +export class DsDynamicFormComponent extends DynamicFormComponent { + + @Input() formId: string; + @Input() formGroup: FormGroup; + @Input() formModel: DynamicFormControlModel[]; + @Input() formLayout: DynamicFormLayout = null; + + /* tslint:disable:no-output-rename */ + @Output('dfBlur') blur: EventEmitter = new EventEmitter(); + @Output('dfChange') change: EventEmitter = new EventEmitter(); + @Output('dfFocus') focus: EventEmitter = new EventEmitter(); + /* tslint:enable:no-output-rename */ + + @ContentChildren(DynamicTemplateDirective) templates: QueryList; + + @ViewChildren(DsDynamicFormControlComponent) components: QueryList; + + constructor(protected formService: FormBuilderService, protected layoutService: DynamicFormLayoutService) { + super(formService, layoutService); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html new file mode 100644 index 0000000000..a5d6d63418 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html @@ -0,0 +1,50 @@ +
+ + + + + + +
+ +
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss new file mode 100644 index 0000000000..9eab449eeb --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss @@ -0,0 +1,3 @@ +.col-lg-1 { + width: auto; +} 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 new file mode 100644 index 0000000000..02f1415e99 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts @@ -0,0 +1,255 @@ +// Load the implementations that should be tested +import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed, } from '@angular/core/testing'; +import { FormControl, FormGroup } from '@angular/forms'; + +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { DynamicFormValidationService } from '@ng-dynamic-forms/core'; + +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'; + +export const DATE_TEST_GROUP = new FormGroup({ + date: new FormControl() +}); + +export const DATE_TEST_MODEL_CONFIG = { + disabled: false, + errorMessages: {required: 'You must enter at least the year.'}, + id: 'date', + label: 'Date', + name: 'date', + placeholder: 'Date', + readOnly: false, + required: true, + toggleIcon: 'fa fa-calendar' +}; + +describe('DsDatePickerComponent test suite', () => { + + let testComp: TestComponent; + let dateComp: DsDatePickerComponent; + let testFixture: ComponentFixture; + let dateFixture: ComponentFixture; + let html; + + // async beforeEach + beforeEach(async(() => { + + TestBed.configureTestingModule({ + imports: [ + NgbModule.forRoot() + ], + declarations: [ + DsDatePickerComponent, + TestComponent, + ], // declare the test component + providers: [ + ChangeDetectorRef, + DsDatePickerComponent, + DynamicFormValidationService, + FormBuilderService, + FormComponent, + FormService + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + + })); + + describe('', () => { + // synchronous beforeEach + beforeEach(() => { + html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + it('should create DsDatePickerComponent', inject([DsDatePickerComponent], (app: DsDatePickerComponent) => { + + expect(app).toBeDefined(); + })); + + }); + + describe('', () => { + describe('when init model value is empty', () => { + beforeEach(() => { + + dateFixture = TestBed.createComponent(DsDatePickerComponent); + dateComp = dateFixture.componentInstance; // FormComponent test instance + dateComp.group = DATE_TEST_GROUP; + dateComp.model = new DynamicDsDatePickerModel(DATE_TEST_MODEL_CONFIG); + dateFixture.detectChanges(); + }); + + it('should init component properly', () => { + expect(dateComp.initialYear).toBeDefined(); + expect(dateComp.initialMonth).toBeDefined(); + expect(dateComp.initialDay).toBeDefined(); + expect(dateComp.maxYear).toBeDefined(); + expect(dateComp.disabledMonth).toBeTruthy(); + expect(dateComp.disabledDay).toBeTruthy(); + }); + + it('should set year and enable month field when year field is entered', () => { + const event = { + field: 'year', + value: '1983' + }; + dateComp.onChange(event); + + expect(dateComp.year).toEqual('1983'); + expect(dateComp.disabledMonth).toBeFalsy(); + expect(dateComp.disabledDay).toBeTruthy(); + }); + + it('should set month and enable day field when month field is entered', () => { + const event = { + field: 'month', + value: '11' + }; + + dateComp.year = '1983'; + dateComp.disabledMonth = false; + dateFixture.detectChanges(); + + dateComp.onChange(event); + + expect(dateComp.year).toEqual('1983'); + expect(dateComp.month).toEqual('11'); + expect(dateComp.disabledMonth).toBeFalsy(); + expect(dateComp.disabledDay).toBeFalsy(); + }); + + it('should set day when day field is entered', () => { + const event = { + field: 'day', + value: '18' + }; + + dateComp.year = '1983'; + dateComp.month = '11'; + dateComp.disabledMonth = false; + dateComp.disabledDay = false; + dateFixture.detectChanges(); + + dateComp.onChange(event); + + expect(dateComp.year).toEqual('1983'); + expect(dateComp.month).toEqual('11'); + expect(dateComp.day).toEqual('18'); + expect(dateComp.disabledMonth).toBeFalsy(); + expect(dateComp.disabledDay).toBeFalsy(); + }); + + it('should emit blur Event onBlur', () => { + spyOn(dateComp.blur, 'emit'); + dateComp.onBlur(new Event('blur')); + expect(dateComp.blur.emit).toHaveBeenCalled(); + }); + + it('should emit focus Event onFocus', () => { + spyOn(dateComp.focus, 'emit'); + dateComp.onFocus(new Event('focus')); + expect(dateComp.focus.emit).toHaveBeenCalled(); + }); + }); + + describe('when init model value is not empty', () => { + beforeEach(() => { + + dateFixture = TestBed.createComponent(DsDatePickerComponent); + dateComp = dateFixture.componentInstance; // FormComponent test instance + dateComp.group = DATE_TEST_GROUP; + dateComp.model = new DynamicDsDatePickerModel(DATE_TEST_MODEL_CONFIG); + dateComp.model.value = '1983-11-18'; + dateFixture.detectChanges(); + }); + + it('should init component properly', () => { + expect(dateComp.initialYear).toBeDefined(); + expect(dateComp.initialMonth).toBeDefined(); + expect(dateComp.initialDay).toBeDefined(); + expect(dateComp.maxYear).toBeDefined(); + expect(dateComp.year).toBe(1983); + expect(dateComp.month).toBe(11); + expect(dateComp.day).toBe(18); + expect(dateComp.disabledMonth).toBeFalsy(); + expect(dateComp.disabledDay).toBeFalsy(); + }); + + it('should disable month and day fields when year field is canceled', () => { + const event = { + field: 'year', + value: null + }; + dateComp.onChange(event); + + expect(dateComp.year).not.toBeDefined(); + expect(dateComp.month).not.toBeDefined(); + expect(dateComp.day).not.toBeDefined(); + expect(dateComp.disabledMonth).toBeTruthy(); + expect(dateComp.disabledDay).toBeTruthy(); + }); + + it('should disable day field when month field is canceled', () => { + const event = { + field: 'month', + value: null + }; + dateComp.onChange(event); + + expect(dateComp.year).toBe(1983); + expect(dateComp.month).not.toBeDefined(); + expect(dateComp.day).not.toBeDefined(); + expect(dateComp.disabledMonth).toBeFalsy(); + expect(dateComp.disabledDay).toBeTruthy(); + }); + + it('should not disable day field when day field is canceled', () => { + const event = { + field: 'day', + value: null + }; + dateComp.onChange(event); + + expect(dateComp.year).toBe(1983); + expect(dateComp.month).toBe(11); + expect(dateComp.day).not.toBeDefined(); + expect(dateComp.disabledMonth).toBeFalsy(); + expect(dateComp.disabledDay).toBeFalsy(); + }); + }); + }); + +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + group = DATE_TEST_GROUP; + + model = new DynamicDsDatePickerModel(DATE_TEST_MODEL_CONFIG); + + showErrorMessages = false; + +} 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 new file mode 100644 index 0000000000..741d86fab9 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts @@ -0,0 +1,172 @@ +import { Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { DynamicDsDatePickerModel } from './date-picker.model'; +import { hasNoValue, hasValue, isNotEmpty } from '../../../../../empty.util'; + +export const DS_DATE_PICKER_SEPARATOR = '-'; + +@Component({ + selector: 'ds-date-picker', + styleUrls: ['./date-picker.component.scss'], + templateUrl: './date-picker.component.html', +}) + +export class DsDatePickerComponent implements OnInit { + @Input() bindId = true; + @Input() group: FormGroup; + @Input() model: DynamicDsDatePickerModel; + @Input() showErrorMessages = false; + // @Input() + // minDate; + // @Input() + // maxDate; + + @Output() selected = new EventEmitter(); + @Output() remove = new EventEmitter(); + @Output() blur = new EventEmitter(); + @Output() change = new EventEmitter(); + @Output() focus = new EventEmitter(); + + initialYear: number; + initialMonth: number; + initialDay: number; + + year: any; + month: any; + day: any; + + minYear: 0; + maxYear: number; + minMonth = 1; + maxMonth = 12; + minDay = 1; + maxDay = 31; + + yearPlaceholder = 'year'; + monthPlaceholder = 'month'; + dayPlaceholder = 'day'; + + disabledMonth = true; + disabledDay = true; + + ngOnInit() { + const now = new Date(); + this.initialYear = now.getFullYear(); + this.initialMonth = now.getMonth() + 1; + this.initialDay = now.getDate(); + + if (this.model.value && 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); + this.year = this.initialYear; + this.disabledMonth = false; + } + if (values.length > 1) { + this.initialMonth = parseInt(values[1], 10); + this.month = this.initialMonth; + this.disabledDay = false; + } + if (values.length > 2) { + this.initialDay = parseInt(values[2], 10); + this.day = this.initialDay; + } + } + + this.maxYear = this.initialYear + 100; + + } + + onBlur(event) { + this.blur.emit(); + } + + onChange(event) { + // update year-month-day + switch (event.field) { + case 'year': { + if (event.value !== null) { + this.year = event.value; + } else { + this.year = undefined; + this.month = undefined; + this.day = undefined; + this.disabledMonth = true; + this.disabledDay = true; + } + break; + } + case 'month': { + if (event.value !== null) { + this.month = event.value; + } else { + this.month = undefined; + this.day = undefined; + this.disabledDay = true; + } + break; + } + case 'day': { + if (event.value !== null) { + this.day = event.value; + } else { + this.day = undefined; + } + break; + } + } + + // set max for days by month/year + if (!this.disabledDay) { + const month = this.month ? this.month - 1 : 0; + const date = new Date(this.year, month, 1); + this.maxDay = this.getLastDay(date); + if (this.day > this.maxDay) { + this.day = this.maxDay; + } + } + + // Manage disable + if (hasValue(this.year) && event.field === 'year') { + this.disabledMonth = false; + } else if (hasValue(this.month) && event.field === 'month') { + this.disabledDay = false; + } + + // update value + let value = null; + if (hasValue(this.year)) { + let yyyy = this.year.toString(); + while (yyyy.length < 4) { + yyyy = '0' + yyyy; + } + value = yyyy; + } + if (hasValue(this.month)) { + const mm = this.month.toString().length === 1 + ? '0' + this.month.toString() + : this.month.toString(); + value += DS_DATE_PICKER_SEPARATOR + mm; + } + if (hasValue(this.day)) { + const dd = this.day.toString().length === 1 + ? '0' + this.day.toString() + : this.day.toString(); + value += DS_DATE_PICKER_SEPARATOR + dd; + } + + this.model.valueUpdates.next(value); + this.change.emit(value); + } + + onFocus(event) { + this.focus.emit(event); + } + + getLastDay(date: Date) { + // Last Day of the same month (+1 month, -1 day) + date.setMonth(date.getMonth() + 1, 0); + return date.getDate(); + } + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts new file mode 100644 index 0000000000..a75a1d2f1a --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts @@ -0,0 +1,21 @@ +import { DynamicDateControlModel, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; +import { DynamicDateControlModelConfig } from '@ng-dynamic-forms/core/src/model/dynamic-date-control.model'; +import { Subject } from 'rxjs/Subject'; + +export const DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER = 'DATE'; + +/** + * Dynamic Date Picker Model class + */ +export class DynamicDsDatePickerModel extends DynamicDateControlModel { + @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER; + valueUpdates: Subject; + malformedDate: boolean; + hasLanguages = false; + + constructor(config: DynamicDateControlModelConfig, layout?: DynamicFormControlLayout) { + super(config, layout); + this.malformedDate = false; + } + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts new file mode 100644 index 0000000000..81f570b14c --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts @@ -0,0 +1,52 @@ +import { DynamicFormControlLayout, DynamicFormGroupModel, DynamicFormGroupModelConfig, serializable } from '@ng-dynamic-forms/core'; +import { isNotEmpty } from '../../../../empty.util'; +import { DsDynamicInputModel } from './ds-dynamic-input.model'; +import { AuthorityValueModel } from '../../../../../core/integration/models/authority-value.model'; +import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; + +export const CONCAT_GROUP_SUFFIX = '_CONCAT_GROUP'; +export const CONCAT_FIRST_INPUT_SUFFIX = '_CONCAT_FIRST_INPUT'; +export const CONCAT_SECOND_INPUT_SUFFIX = '_CONCAT_SECOND_INPUT'; + +export interface DynamicConcatModelConfig extends DynamicFormGroupModelConfig { + separator: string; +} + +export class DynamicConcatModel extends DynamicFormGroupModel { + + @serializable() separator: string; + @serializable() hasLanguages = false; + isCustomGroup = true; + + constructor(config: DynamicConcatModelConfig, layout?: DynamicFormControlLayout) { + + super(config, layout); + + this.separator = config.separator + ' '; + } + + get value() { + const firstValue = (this.get(0) as DsDynamicInputModel).value; + const secondValue = (this.get(1) as DsDynamicInputModel).value; + if (isNotEmpty(firstValue) && isNotEmpty(secondValue)) { + return new FormFieldMetadataValueObject(firstValue + this.separator + secondValue); + } else { + return null; + } + } + + set value(value: string | FormFieldMetadataValueObject) { + let values; + if (typeof value === 'string') { + values = value ? value.split(this.separator) : [null, null]; + } else { + values = value ? value.value.split(this.separator) : [null, null]; + } + + if (values.length > 1) { + (this.get(0) as DsDynamicInputModel).valueUpdates.next(values[0]); + (this.get(1) as DsDynamicInputModel).valueUpdates.next(values[1]); + } + } + +} 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 new file mode 100644 index 0000000000..f739c17cf3 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts @@ -0,0 +1,86 @@ +import { + DynamicFormControlLayout, + DynamicInputModel, + DynamicInputModelConfig, + serializable +} from '@ng-dynamic-forms/core'; +import { Subject } from 'rxjs/Subject'; + +import { LanguageCode } from '../../models/form-field-language-value.model'; +import { AuthorityOptions } from '../../../../../core/integration/models/authority-options.model'; +import { hasValue } from '../../../../empty.util'; +import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; + +export interface DsDynamicInputModelConfig extends DynamicInputModelConfig { + authorityOptions?: AuthorityOptions; + languageCodes?: LanguageCode[]; + language?: string; + value?: any; +} + +export class DsDynamicInputModel extends DynamicInputModel { + + @serializable() authorityOptions: AuthorityOptions; + @serializable() private _languageCodes: LanguageCode[]; + @serializable() private _language: string; + @serializable() languageUpdates: Subject; + + constructor(config: DsDynamicInputModelConfig, layout?: DynamicFormControlLayout) { + super(config, layout); + + this.readOnly = config.readOnly; + this.value = config.value; + this.language = config.language; + if (!this.language) { + // TypeAhead + if (config.value instanceof FormFieldMetadataValueObject) { + this.language = config.value.language; + } else if (Array.isArray(config.value)) { + // Tag of Authority + if (config.value[0].language) { + this.language = config.value[0].language; + } + } + } + this.languageCodes = config.languageCodes; + + this.languageUpdates = new Subject(); + this.languageUpdates.subscribe((lang: string) => { + this.language = lang; + }); + + this.authorityOptions = config.authorityOptions; + } + + get hasAuthority(): boolean { + return this.authorityOptions && hasValue(this.authorityOptions.name); + } + + get hasLanguages(): boolean { + if (this.languageCodes && this.languageCodes.length > 1) { + return true; + } else { + return false; + } + } + + get language(): string { + return this._language; + } + + set language(language: string) { + this._language = language; + } + + get languageCodes(): LanguageCode[] { + return this._languageCodes; + } + + set languageCodes(languageCodes: LanguageCode[]) { + this._languageCodes = languageCodes; + if (!this.language || this.language === null || this.language === '') { + this.language = this.languageCodes ? this.languageCodes[0].code : null; + } + } + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts new file mode 100644 index 0000000000..bae79cc348 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts @@ -0,0 +1,65 @@ +import { DynamicFormControlLayout, DynamicFormGroupModel, DynamicInputModelConfig, serializable } from '@ng-dynamic-forms/core'; +import { DsDynamicInputModel, DsDynamicInputModelConfig } from './ds-dynamic-input.model'; +import { Subject } from 'rxjs/Subject'; +import { DynamicFormGroupModelConfig } from '@ng-dynamic-forms/core/src/model/form-group/dynamic-form-group.model'; +import { LanguageCode } from '../../models/form-field-language-value.model'; + +export const QUALDROP_GROUP_SUFFIX = '_QUALDROP_GROUP'; +export const QUALDROP_METADATA_SUFFIX = '_QUALDROP_METADATA'; +export const QUALDROP_VALUE_SUFFIX = '_QUALDROP_VALUE'; + +export interface DsDynamicQualdropModelConfig extends DynamicFormGroupModelConfig { + languageCodes?: LanguageCode[]; + language?: string; + readOnly: boolean; +} + +export class DynamicQualdropModel extends DynamicFormGroupModel { + @serializable() private _language: string; + @serializable() private _languageCodes: LanguageCode[]; + @serializable() languageUpdates: Subject; + @serializable() hasLanguages = false; + @serializable() readOnly: boolean; + isCustomGroup = true; + + constructor(config: DsDynamicQualdropModelConfig, layout?: DynamicFormControlLayout) { + super(config, layout); + + this.readOnly = config.readOnly; + this.language = config.language; + this.languageCodes = config.languageCodes; + + this.languageUpdates = new Subject(); + this.languageUpdates.subscribe((lang: string) => { + this.language = lang; + }); + } + + get value() { + return (this.get(1) as DsDynamicInputModel).value; + } + + get qualdropId(): string { + return (this.get(0) as DsDynamicInputModel).value.toString(); + } + + get language(): string { + return this._language; + } + + set language(language: string) { + this._language = language; + } + + get languageCodes(): LanguageCode[] { + return this._languageCodes; + } + + set languageCodes(languageCodes: LanguageCode[]) { + this._languageCodes = languageCodes; + if (!this.language || this.language === null || this.language === '') { + this.language = this.languageCodes ? this.languageCodes[0].code : null; + } + } + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts new file mode 100644 index 0000000000..b38ea142f0 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts @@ -0,0 +1,21 @@ +import { + DYNAMIC_FORM_CONTROL_TYPE_ARRAY, + DynamicFormArrayModel, DynamicFormArrayModelConfig, DynamicFormControlLayout, + serializable +} from '@ng-dynamic-forms/core'; +import { DYNAMIC_FORM_CONTROL_TYPE_TAG } from './tag/dynamic-tag.model'; + +export interface DynamicRowArrayModelConfig extends DynamicFormArrayModelConfig { + notRepeteable: boolean; +} + +export class DynamicRowArrayModel extends DynamicFormArrayModel { + @serializable() notRepeteable = false; + isRowArray = true; + + constructor(config: DynamicRowArrayModelConfig, layout?: DynamicFormControlLayout) { + super(config, layout); + this.notRepeteable = config.notRepeteable; + } + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-group-model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-group-model.ts new file mode 100644 index 0000000000..b6d76b4c55 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-group-model.ts @@ -0,0 +1,5 @@ +import { DynamicFormGroupModel } from '@ng-dynamic-forms/core'; + +export class DynamicRowGroupModel extends DynamicFormGroupModel { + isRowGroup = true; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-textarea.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-textarea.model.ts new file mode 100644 index 0000000000..00d385edef --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-textarea.model.ts @@ -0,0 +1,24 @@ +import { DYNAMIC_FORM_CONTROL_TYPE_TEXTAREA, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; +import { DsDynamicInputModel, DsDynamicInputModelConfig } from './ds-dynamic-input.model'; + +export interface DsDynamicTextAreaModelConfig extends DsDynamicInputModelConfig { + cols?: number; + rows?: number; + wrap?: string; +} + +export class DsDynamicTextAreaModel extends DsDynamicInputModel { + @serializable() cols: number; + @serializable() rows: number; + @serializable() wrap: string; + @serializable() type = DYNAMIC_FORM_CONTROL_TYPE_TEXTAREA; + + constructor(config: DsDynamicTextAreaModelConfig, layout?: DynamicFormControlLayout) { + super(config, layout); + + this.cols = config.cols; + this.rows = config.rows; + this.wrap = config.wrap; + } + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.html new file mode 100644 index 0000000000..d82ab9133d --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.html @@ -0,0 +1,72 @@ + + + + + + + +
+
+ + + +
+ + + + + +
+
+
+ +
+
+ +
+ +
+
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.scss new file mode 100644 index 0000000000..d380554157 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.scss @@ -0,0 +1,3 @@ +.close { + top: -2.5rem; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.spec.ts new file mode 100644 index 0000000000..d1e6f67287 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.spec.ts @@ -0,0 +1,315 @@ +// Load the implementations that should be tested +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 { DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/observable/of'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; + +import { DsDynamicGroupComponent } from './dynamic-group.components'; +import { DynamicGroupModel, DynamicGroupModelConfig } from './dynamic-group.model'; +import { FormRowModel, SubmissionFormsModel } from '../../../../../../core/shared/config/config-submission-forms.model'; +import { FormFieldModel } from '../../../models/form-field.model'; +import { FormBuilderService } from '../../../form-builder.service'; +import { FormService } from '../../../../form.service'; +import { GLOBAL_CONFIG } from '../../../../../../../config'; +import { FormComponent } from '../../../../form.component'; +import { AppState } from '../../../../../../app.reducer'; +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'; + +export const FORM_GROUP_TEST_MODEL_CONFIG = { + disabled: false, + errorMessages: {required: 'You must specify at least one author.'}, + formConfiguration: [{ + fields: [{ + hints: 'Enter the name of the author.', + input: {type: 'onebox'}, + label: 'Author', + languageCodes: [], + mandatory: 'true', + mandatoryMessage: 'Required field!', + repeatable: false, + selectableMetadata: [{ + authority: 'RPAuthority', + closed: false, + metadata: 'dc.contributor.author' + }], + } as FormFieldModel] + } as FormRowModel, { + fields: [{ + hints: 'Enter the affiliation of the author.', + input: {type: 'onebox'}, + label: 'Affiliation', + languageCodes: [], + mandatory: 'false', + repeatable: false, + selectableMetadata: [{ + authority: 'OUAuthority', + closed: false, + metadata: 'local.contributor.affiliation' + }] + } as FormFieldModel] + } as FormRowModel], + id: 'dc_contributor_author', + label: 'Authors', + mandatoryField: 'dc.contributor.author', + name: 'dc.contributor.author', + placeholder: 'Authors', + readOnly: false, + relationFields: ['local.contributor.affiliation'], + required: true, + scopeUUID: '43fe1f8c-09a6-4fcf-9c78-5d4fed8f2c8f', + submissionScope: undefined, + validators: {required: null} +} as DynamicGroupModelConfig; + +export const FORM_GROUP_TEST_GROUP = new FormGroup({ + dc_contributor_author: new FormControl(), +}); + +describe('DsDynamicGroupComponent test suite', () => { + const config = { + form: { + validatorMap: { + required: 'required', + regex: 'pattern' + } + } + } as any; + let testComp: TestComponent; + let groupComp: DsDynamicGroupComponent; + let testFixture: ComponentFixture; + let groupFixture: ComponentFixture; + let modelValue: any; + let html; + let control1: FormControl; + let model1: DsDynamicInputModel; + let control2: FormControl; + let model2: DsDynamicInputModel; + + const store: Store = jasmine.createSpyObj('store', { + dispatch: {}, + select: Observable.of(true) + }); + + // async beforeEach + beforeEach(async(() => { + + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + FormsModule, + ReactiveFormsModule, + NgbModule.forRoot(), + TranslateModule.forRoot() + ], + declarations: [ + FormComponent, + DsDynamicGroupComponent, + TestComponent, + ], // declare the test component + providers: [ + ChangeDetectorRef, + DsDynamicGroupComponent, + DynamicFormValidationService, + FormBuilderService, + FormComponent, + FormService, + {provide: GLOBAL_CONFIG, useValue: config}, + {provide: Store, useValue: store}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + + })); + + describe('', () => { + // synchronous beforeEach + beforeEach(() => { + html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + it('should create DsDynamicGroupComponent', inject([DsDynamicGroupComponent], (app: DsDynamicGroupComponent) => { + + expect(app).toBeDefined(); + })); + }); + + describe('when init model value is empty', () => { + beforeEach(inject([FormBuilderService], (service: FormBuilderService) => { + + groupFixture = TestBed.createComponent(DsDynamicGroupComponent); + groupComp = groupFixture.componentInstance; // FormComponent test instance + groupComp.formId = 'testForm'; + groupComp.group = FORM_GROUP_TEST_GROUP; + groupComp.model = new DynamicGroupModel(FORM_GROUP_TEST_MODEL_CONFIG); + groupComp.showErrorMessages = false; + groupFixture.detectChanges(); + + control1 = service.getFormControlById('dc_contributor_author', (groupComp as any).formRef.formGroup, groupComp.formModel) as FormControl; + model1 = service.findById('dc_contributor_author', groupComp.formModel) as DsDynamicInputModel; + control2 = service.getFormControlById('local_contributor_affiliation', (groupComp as any).formRef.formGroup, groupComp.formModel) as FormControl; + model2 = service.findById('local_contributor_affiliation', groupComp.formModel) as DsDynamicInputModel; + + // spyOn(store, 'dispatch'); + })); + + afterEach(() => { + groupFixture.destroy(); + groupComp = null; + }); + + it('should init component properly', inject([FormBuilderService], (service: FormBuilderService) => { + const formConfig = {rows: groupComp.model.formConfiguration} as SubmissionFormsModel; + const formModel = service.modelFromConfiguration(formConfig, groupComp.model.scopeUUID, {}, groupComp.model.submissionScope, groupComp.model.readOnly); + const chips = new Chips([], 'value', 'dc.contributor.author'); + + expect(groupComp.formCollapsed).toEqual(Observable.of(false)); + expect(groupComp.formModel.length).toEqual(formModel.length); + expect(groupComp.chips.getChipsItems()).toEqual(chips.getChipsItems()); + })); + + it('should save a new chips item', () => { + control1.setValue('test author'); + (model1 as any).value = new FormFieldMetadataValueObject('test author'); + control2.setValue('test affiliation'); + (model2 as any).value = new FormFieldMetadataValueObject('test affiliation'); + modelValue = [{ + 'dc.contributor.author': new FormFieldMetadataValueObject('test author'), + 'local.contributor.affiliation': new FormFieldMetadataValueObject('test affiliation') + }]; + groupFixture.detectChanges(); + + const buttons = groupFixture.debugElement.nativeElement.querySelectorAll('button'); + const btnEl = buttons[0]; + btnEl.click(); + + expect(groupComp.chips.getChipsItems()).toEqual(modelValue); + expect(groupComp.formCollapsed).toEqual(Observable.of(true)); + }); + + it('should clear form inputs', () => { + control1.setValue('test author'); + (model1 as any).value = new FormFieldMetadataValueObject('test author'); + control2.setValue('test affiliation'); + (model2 as any).value = new FormFieldMetadataValueObject('test affiliation'); + + groupFixture.detectChanges(); + + const buttons = groupFixture.debugElement.nativeElement.querySelectorAll('button'); + const btnEl = buttons[2]; + btnEl.click(); + + expect(control1.value).toBeNull(); + expect(control2.value).toBeNull(); + expect(groupComp.formCollapsed).toEqual(Observable.of(false)); + }); + }); + + describe('when init model value is not empty', () => { + beforeEach(() => { + + groupFixture = TestBed.createComponent(DsDynamicGroupComponent); + groupComp = groupFixture.componentInstance; // FormComponent test instance + groupComp.formId = 'testForm'; + groupComp.group = FORM_GROUP_TEST_GROUP; + groupComp.model = new DynamicGroupModel(FORM_GROUP_TEST_MODEL_CONFIG); + modelValue = [{ + 'dc.contributor.author': new FormFieldMetadataValueObject('test author'), + 'local.contributor.affiliation': new FormFieldMetadataValueObject('test affiliation') + }]; + groupComp.model.value = modelValue; + groupComp.showErrorMessages = false; + groupFixture.detectChanges(); + + }); + + afterEach(() => { + groupFixture.destroy(); + groupComp = null; + }); + + it('should init component properly', inject([FormBuilderService], (service: FormBuilderService) => { + const formConfig = {rows: groupComp.model.formConfiguration} as SubmissionFormsModel; + const formModel = service.modelFromConfiguration(formConfig, groupComp.model.scopeUUID, {}, groupComp.model.submissionScope, groupComp.model.readOnly); + const chips = new Chips(modelValue, 'value', 'dc.contributor.author'); + + expect(groupComp.formCollapsed).toEqual(Observable.of(true)); + expect(groupComp.formModel.length).toEqual(formModel.length); + expect(groupComp.chips.getChipsItems()).toEqual(chips.getChipsItems()); + })); + + it('should modify existing chips item', inject([FormBuilderService], (service: FormBuilderService) => { + groupComp.onChipSelected(0); + groupFixture.detectChanges(); + + control1 = service.getFormControlById('dc_contributor_author', (groupComp as any).formRef.formGroup, groupComp.formModel) as FormControl; + model1 = service.findById('dc_contributor_author', groupComp.formModel) as DsDynamicInputModel; + + control1.setValue('test author modify'); + (model1 as any).value = new FormFieldMetadataValueObject('test author modify'); + + modelValue = [{ + 'dc.contributor.author': new FormFieldMetadataValueObject('test author modify'), + 'local.contributor.affiliation': new FormFieldMetadataValueObject('test affiliation') + }]; + groupFixture.detectChanges(); + + const buttons = groupFixture.debugElement.nativeElement.querySelectorAll('button'); + const btnEl = buttons[0]; + btnEl.click(); + + groupFixture.detectChanges(); + + expect(groupComp.chips.getChipsItems()).toEqual(modelValue); + expect(groupComp.formCollapsed).toEqual(Observable.of(true)); + })); + + it('should delete existing chips item', () => { + groupComp.onChipSelected(0); + groupFixture.detectChanges(); + + const buttons = groupFixture.debugElement.nativeElement.querySelectorAll('button'); + const btnEl = buttons[1]; + btnEl.click(); + + expect(groupComp.chips.getChipsItems()).toEqual([]); + expect(groupComp.formCollapsed).toEqual(Observable.of(false)); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + group = FORM_GROUP_TEST_GROUP; + + groupModelConfig = FORM_GROUP_TEST_MODEL_CONFIG; + + model = new DynamicGroupModel(this.groupModelConfig); + + showErrorMessages = false; + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.components.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.components.ts new file mode 100644 index 0000000000..a55e7aff9d --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.components.ts @@ -0,0 +1,240 @@ +import { + ChangeDetectorRef, + Component, + EventEmitter, + Inject, + Input, + OnDestroy, + OnInit, + Output, + ViewChild +} from '@angular/core'; + +import { Observable } from 'rxjs/Observable'; +import { DynamicFormControlModel, DynamicFormGroupModel, DynamicInputModel } from '@ng-dynamic-forms/core'; +import { isEqual } from 'lodash'; + +import { DynamicGroupModel, PLACEHOLDER_PARENT_METADATA } from './dynamic-group.model'; +import { FormBuilderService } from '../../../form-builder.service'; +import { SubmissionFormsModel } from '../../../../../../core/shared/config/config-submission-forms.model'; +import { FormService } from '../../../../form.service'; +import { FormComponent } from '../../../../form.component'; +import { Chips } from '../../../../../chips/models/chips.model'; +import { hasValue, isEmpty, isNotEmpty } from '../../../../../empty.util'; +import { shrinkInOut } from '../../../../../animations/shrink'; +import { ChipsItem } from '../../../../../chips/models/chips-item.model'; +import { GlobalConfig } from '../../../../../../../config/global-config.interface'; +import { GLOBAL_CONFIG } from '../../../../../../../config'; +import { FormGroup } from '@angular/forms'; +import { Subscription } from 'rxjs/Subscription'; +import { hasOnlyEmptyProperties } from '../../../../../object.util'; +import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; +import { AuthorityValueModel } from '../../../../../../core/integration/models/authority-value.model'; + +@Component({ + selector: 'ds-dynamic-group', + styleUrls: ['./dynamic-group.component.scss'], + templateUrl: './dynamic-group.component.html', + animations: [shrinkInOut] +}) +export class DsDynamicGroupComponent implements OnDestroy, OnInit { + + @Input() formId: string; + @Input() group: FormGroup; + @Input() model: DynamicGroupModel; + @Input() showErrorMessages = false; + + @Output() blur: EventEmitter = new EventEmitter(); + @Output() change: EventEmitter = new EventEmitter(); + @Output() focus: EventEmitter = new EventEmitter(); + + public chips: Chips; + public formCollapsed = Observable.of(false); + public formModel: DynamicFormControlModel[]; + public editMode = false; + + private selectedChipItem: ChipsItem; + private subs: Subscription[] = []; + + @ViewChild('formRef') private formRef: FormComponent; + + constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + private formBuilderService: FormBuilderService, + private formService: FormService, + private cdr: ChangeDetectorRef) { + } + + ngOnInit() { + const config = {rows: this.model.formConfiguration} as SubmissionFormsModel; + if (!this.model.isEmpty()) { + this.formCollapsed = Observable.of(true); + } + this.model.valueUpdates.subscribe((value: any[]) => { + if ((isNotEmpty(value) && !(value.length === 1 && hasOnlyEmptyProperties(value[0])))) { + this.collapseForm(); + } else { + this.expandForm(); + } + // this.formCollapsed = (isNotEmpty(value) && !(value.length === 1 && hasOnlyEmptyProperties(value[0]))) ? Observable.of(true) : Observable.of(false); + }); + + this.formId = this.formService.getUniqueId(this.model.id); + this.formModel = this.formBuilderService.modelFromConfiguration( + config, + this.model.scopeUUID, + {}, + this.model.submissionScope, + this.model.readOnly); + const initChipsValue = this.model.isEmpty() ? [] : this.model.value; + this.chips = new Chips( + initChipsValue, + 'value', + this.model.mandatoryField); + this.subs.push( + this.chips.chipsItems + .subscribe((subItems: any[]) => { + const items = this.chips.getChipsItems(); + // Does not emit change if model value is equal to the current value + if (!isEqual(items, this.model.value)) { + // if ((isNotEmpty(items) && !this.model.isEmpty()) || (isEmpty(items) && !this.model.isEmpty())) { + if (!(isEmpty(items) && this.model.isEmpty())) { + this.model.valueUpdates.next(items); + this.change.emit(); + } + } + }), + ) + } + + isMandatoryFieldEmpty() { + // formModel[0].group[0].value == null + let res = true; + this.formModel.forEach((row) => { + const modelRow = row as DynamicFormGroupModel; + modelRow.group.forEach((model: DynamicInputModel) => { + if (model.name === this.model.mandatoryField) { + res = model.value == null; + return; + } + }); + }); + return res; + } + + onBlur(event) { + this.blur.emit(); + } + + onChipSelected(event) { + this.expandForm(); + this.selectedChipItem = this.chips.getChipByIndex(event); + this.formModel.forEach((row) => { + const modelRow = row as DynamicFormGroupModel; + modelRow.group.forEach((model: DynamicInputModel) => { + const value = (this.selectedChipItem.item[model.name] === PLACEHOLDER_PARENT_METADATA + || this.selectedChipItem.item[model.name].value === PLACEHOLDER_PARENT_METADATA) + ? null + : this.selectedChipItem.item[model.name]; + // if (value instanceof FormFieldMetadataValueObject || value instanceof AuthorityValueModel) { + // model.valueUpdates.next(value.display); + // } else { + // model.valueUpdates.next(value); + // } + model.valueUpdates.next(value); + }); + }); + + this.editMode = true; + } + + onFocus(event) { + this.focus.emit(event); + } + + collapseForm() { + this.formCollapsed = Observable.of(true); + this.clear(); + } + + expandForm() { + this.formCollapsed = Observable.of(false); + } + + clear() { + if (this.editMode) { + this.selectedChipItem.editMode = false; + this.selectedChipItem = null; + this.editMode = false; + } + this.resetForm(); + if (!this.model.isEmpty()) { + this.formCollapsed = Observable.of(true); + } + } + + save() { + if (this.editMode) { + this.modifyChip(); + } else { + this.addToChips(); + } + } + + delete() { + this.chips.remove(this.selectedChipItem); + this.clear(); + } + + private addToChips() { + if (!this.formRef.formGroup.valid) { + this.formService.validateAllFormFields(this.formRef.formGroup); + return; + } + + // Item to add + if (!this.isMandatoryFieldEmpty()) { + const item = this.buildChipItem(); + this.chips.add(item); + + this.resetForm(); + } + } + + private modifyChip() { + if (!this.formRef.formGroup.valid) { + this.formService.validateAllFormFields(this.formRef.formGroup); + return; + } + + if (!this.isMandatoryFieldEmpty()) { + const item = this.buildChipItem(); + this.chips.update(this.selectedChipItem.id, item); + this.resetForm(); + this.cdr.detectChanges(); + } + } + + private buildChipItem() { + const item = Object.create({}); + this.formModel.forEach((row) => { + const modelRow = row as DynamicFormGroupModel; + modelRow.group.forEach((control: DynamicInputModel) => { + item[control.name] = control.value || PLACEHOLDER_PARENT_METADATA; + }); + }); + return item; + } + + private resetForm() { + if (this.formRef) { + this.formService.resetForm(this.formRef.formGroup, this.formModel, this.formId); + } + } + + 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/dynamic-group/dynamic-group.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.model.ts new file mode 100644 index 0000000000..2048c41df8 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.model.ts @@ -0,0 +1,73 @@ +import { DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; +import { FormRowModel } from '../../../../../../core/shared/config/config-submission-forms.model'; +import { DsDynamicInputModel, DsDynamicInputModelConfig } from '../ds-dynamic-input.model'; +import { AuthorityValueModel } from '../../../../../../core/integration/models/authority-value.model'; +import { isEmpty, isNull } from '../../../../../empty.util'; + +export const DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP = 'RELATION'; +export const PLACEHOLDER_PARENT_METADATA = '#PLACEHOLDER_PARENT_METADATA_VALUE#'; + +/** + * Dynamic Group Model configuration interface + */ +export interface DynamicGroupModelConfig extends DsDynamicInputModelConfig { + formConfiguration: FormRowModel[], + mandatoryField: string, + relationFields: string[], + scopeUUID: string, + submissionScope: string; +} + +/** + * Dynamic Group Model class + */ +export class DynamicGroupModel extends DsDynamicInputModel { + @serializable() formConfiguration: FormRowModel[]; + @serializable() mandatoryField: string; + @serializable() relationFields: string[]; + @serializable() scopeUUID: string; + @serializable() submissionScope: string; + @serializable() _value: any[]; + @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP; + + constructor(config: DynamicGroupModelConfig, layout?: DynamicFormControlLayout) { + super(config, layout); + + this.formConfiguration = config.formConfiguration; + this.mandatoryField = config.mandatoryField; + this.relationFields = config.relationFields; + this.scopeUUID = config.scopeUUID; + this.submissionScope = config.submissionScope; + const value = config.value || []; + this.valueUpdates.next(value); + } + + get value() { + return this._value + } + + set value(value) { + this._value = (isEmpty(value)) ? null : value; + } + + isEmpty() { + const value = this.getGroupValue(); + return (value.length === 1 && isNull(value[0][this.mandatoryField])); + } + + getGroupValue(): any[] { + if (isEmpty(this._value)) { + // If items is empty, last element has been removed + // so emit an empty value that allows to dispatch + // a remove JSON PATCH operation + const emptyItem = Object.create({}); + emptyItem[this.mandatoryField] = null; + this.relationFields + .forEach((field) => { + emptyItem[field] = null; + }); + return [emptyItem]; + } + return this._value + } +} 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 new file mode 100644 index 0000000000..5fdc530ebd --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model.ts @@ -0,0 +1,60 @@ +import { Subject } from 'rxjs/Subject'; + +import { + DynamicCheckboxGroupModel, DynamicFormControlLayout, + DynamicFormGroupModelConfig, + serializable +} from '@ng-dynamic-forms/core'; +import { AuthorityValueModel } from '../../../../../../core/integration/models/authority-value.model'; +import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; +import { hasValue } from '../../../../../empty.util'; + +export interface DynamicListCheckboxGroupModelConfig extends DynamicFormGroupModelConfig { + authorityOptions: AuthorityOptions; + groupLength?: number; + repeatable: boolean; + value?: any; +} + +export class DynamicListCheckboxGroupModel extends DynamicCheckboxGroupModel { + + @serializable() authorityOptions: AuthorityOptions; + @serializable() repeatable: boolean; + @serializable() groupLength: number; + @serializable() _value: AuthorityValueModel[]; + isListGroup = true; + valueUpdates: Subject; + + constructor(config: DynamicListCheckboxGroupModelConfig, layout?: DynamicFormControlLayout) { + super(config, layout); + + this.authorityOptions = config.authorityOptions; + this.groupLength = config.groupLength || 5; + this._value = []; + this.repeatable = config.repeatable; + + this.valueUpdates = new Subject(); + this.valueUpdates.subscribe((value: AuthorityValueModel | AuthorityValueModel[]) => this.value = value); + this.valueUpdates.next(config.value); + } + + get hasAuthority(): boolean { + return this.authorityOptions && hasValue(this.authorityOptions.name); + } + + get value() { + return this._value; + } + + set value(value: AuthorityValueModel | AuthorityValueModel[]) { + 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 AuthorityValueModel[]).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 new file mode 100644 index 0000000000..287c10f3fe --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model.ts @@ -0,0 +1,36 @@ +import { + DynamicFormControlLayout, + DynamicRadioGroupModel, + DynamicRadioGroupModelConfig, + serializable +} from '@ng-dynamic-forms/core'; +import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; +import { hasValue } from '../../../../../empty.util'; + +export interface DynamicListModelConfig extends DynamicRadioGroupModelConfig { + authorityOptions: AuthorityOptions; + groupLength?: number; + repeatable: boolean; + value?: any; +} + +export class DynamicListRadioGroupModel extends DynamicRadioGroupModel { + + @serializable() authorityOptions: AuthorityOptions; + @serializable() repeatable: boolean; + @serializable() groupLength: number; + isListGroup = true; + + constructor(config: DynamicListModelConfig, layout?: DynamicFormControlLayout) { + super(config, layout); + + this.authorityOptions = config.authorityOptions; + this.groupLength = config.groupLength || 5; + this.repeatable = config.repeatable; + this.valueUpdates.next(config.value); + } + + get hasAuthority(): boolean { + return this.authorityOptions && hasValue(this.authorityOptions.name); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.html new file mode 100644 index 0000000000..ad4608053c --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.html @@ -0,0 +1,66 @@ +
+
+ +
+ +
+ + + +
+
+
+ +
+ +
+ +
+ +
+ +
+
+
+ +
+
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.scss new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.scss @@ -0,0 +1 @@ + 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 new file mode 100644 index 0000000000..6a765eba4a --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts @@ -0,0 +1,299 @@ +// Load the implementations that should be tested +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 { 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 { DynamicFormControlLayout, 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 { DynamicListRadioGroupModel } from './dynamic-list-radio-group.model'; +import { By } from '@angular/platform-browser'; +import { AuthorityValueModel } from '../../../../../../core/integration/models/authority-value.model'; +import { createTestComponent } from '../../../../../testing/utils'; + +export const LAYOUT_TEST = { + element: { + group: '' + } +} as DynamicFormControlLayout; + +export const LIST_TEST_GROUP = new FormGroup({ + listCheckbox: new FormGroup({}), + listRadio: new FormGroup({}) +}); + +export const LIST_CHECKBOX_TEST_MODEL_CONFIG = { + authorityOptions: { + closed: false, + metadata: 'listCheckbox', + name: 'type_programme', + scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + } as AuthorityOptions, + disabled: false, + id: 'listCheckbox', + label: 'Programme', + name: 'listCheckbox', + placeholder: 'Programme', + readOnly: false, + required: false, + repeatable: true +}; + +export const LIST_RADIO_TEST_MODEL_CONFIG = { + authorityOptions: { + closed: false, + metadata: 'listRadio', + name: 'type_programme', + scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + } as AuthorityOptions, + disabled: false, + id: 'listRadio', + label: 'Programme', + name: 'listRadio', + placeholder: 'Programme', + readOnly: false, + required: false, + repeatable: false +}; + +describe('DsDynamicListComponent test suite', () => { + + let testComp: TestComponent; + let listComp: DsDynamicListComponent; + let testFixture: ComponentFixture; + let listFixture: ComponentFixture; + let html; + let modelValue; + + const authorityServiceStub = new AuthorityServiceStub(); + + // async beforeEach + beforeEach(async(() => { + + TestBed.configureTestingModule({ + imports: [ + DynamicFormsCoreModule, + DynamicFormsNGBootstrapUIModule, + FormsModule, + ReactiveFormsModule, + NgbModule.forRoot() + ], + declarations: [ + DsDynamicListComponent, + TestComponent, + ], // declare the test component + providers: [ + AuthorityService, + ChangeDetectorRef, + DsDynamicListComponent, + DynamicFormValidationService, + FormBuilderService, + {provide: AuthorityService, useValue: authorityServiceStub}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + + })); + + describe('', () => { + // synchronous beforeEach + beforeEach(() => { + html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + it('should create DsDynamicListComponent', inject([DsDynamicListComponent], (app: DsDynamicListComponent) => { + + expect(app).toBeDefined(); + })); + }); + + describe('when model is a DynamicListCheckboxGroupModel', () => { + describe('and init model value is empty', () => { + beforeEach(() => { + + listFixture = TestBed.createComponent(DsDynamicListComponent); + listComp = listFixture.componentInstance; // FormComponent test instance + listComp.group = LIST_TEST_GROUP; + listComp.model = new DynamicListCheckboxGroupModel(LIST_CHECKBOX_TEST_MODEL_CONFIG, LAYOUT_TEST); + listFixture.detectChanges(); + }); + + afterEach(() => { + listFixture.destroy(); + listComp = null; + }); + + 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); + }) + }); + + 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 AuthorityValueModel(), {id: 1, display: 'one', value: 1})]; + + item.nativeElement.click(); + + expect(listComp.model.value).toEqual(modelValue) + }); + + it('should emit blur Event onBlur', () => { + spyOn(listComp.blur, 'emit'); + listComp.onBlur(new Event('blur')); + expect(listComp.blur.emit).toHaveBeenCalled(); + }); + + it('should emit focus Event onFocus', () => { + spyOn(listComp.focus, 'emit'); + listComp.onFocus(new Event('focus')); + expect(listComp.focus.emit).toHaveBeenCalled(); + }); + }); + + describe('and init model value is not empty', () => { + beforeEach(() => { + + listFixture = TestBed.createComponent(DsDynamicListComponent); + 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 AuthorityValueModel(), {id: 1, display: 'one', value: 1})]; + listComp.model.value = modelValue; + listFixture.detectChanges(); + }); + + afterEach(() => { + listFixture.destroy(); + listComp = null; + }); + + 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(); + }) + }); + + it('should set model value properly when a checkbox option is deselected', () => { + 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 = []; + + item.nativeElement.click(); + + expect(listComp.model.value).toEqual(modelValue) + }); + }); + }); + + describe('when model is a DynamicListRadioGroupModel', () => { + describe('and init model value is empty', () => { + beforeEach(() => { + + listFixture = TestBed.createComponent(DsDynamicListComponent); + listComp = listFixture.componentInstance; // FormComponent test instance + listComp.group = LIST_TEST_GROUP; + listComp.model = new DynamicListRadioGroupModel(LIST_RADIO_TEST_MODEL_CONFIG, LAYOUT_TEST); + listFixture.detectChanges(); + }); + + afterEach(() => { + listFixture.destroy(); + listComp = null; + }); + + 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); + }) + }); + + 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 AuthorityValueModel(), {id: 1, display: 'one', value: 1}); + + item.nativeElement.click(); + + expect(listComp.model.value).toEqual(modelValue) + }); + }); + + describe('and init model value is not empty', () => { + beforeEach(() => { + + listFixture = TestBed.createComponent(DsDynamicListComponent); + 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 AuthorityValueModel(), {id: 1, display: 'one', value: 1}); + listComp.model.value = modelValue; + listFixture.detectChanges(); + }); + + afterEach(() => { + listFixture.destroy(); + listComp = null; + }); + + 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(); + }) + }); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + group: FormGroup = LIST_TEST_GROUP; + + model = new DynamicListCheckboxGroupModel(LIST_CHECKBOX_TEST_MODEL_CONFIG, LAYOUT_TEST); + + showErrorMessages = false; + +} 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 new file mode 100644 index 0000000000..ec0d3e343a --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts @@ -0,0 +1,135 @@ +import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +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 } from '@ng-dynamic-forms/core'; +import { AuthorityValueModel } from '../../../../../../core/integration/models/authority-value.model'; +import { DynamicListRadioGroupModel } from './dynamic-list-radio-group.model'; +import { IntegrationData } from '../../../../../../core/integration/integration-data'; + +export interface ListItem { + id: string, + label: string, + value: boolean, + index: number +} + +@Component({ + selector: 'ds-dynamic-list', + styleUrls: ['./dynamic-list.component.scss'], + templateUrl: './dynamic-list.component.html' +}) + +export class DsDynamicListComponent implements OnInit { + @Input() bindId = true; + @Input() group: FormGroup; + @Input() model: DynamicListCheckboxGroupModel | DynamicListRadioGroupModel; + @Input() showErrorMessages = false; + + @Output() blur: EventEmitter = new EventEmitter(); + @Output() change: EventEmitter = new EventEmitter(); + @Output() focus: EventEmitter = new EventEmitter(); + + public items: ListItem[][] = []; + protected optionsList: AuthorityValueModel[]; + protected searchOptions: IntegrationSearchOptions; + + constructor(private authorityService: AuthorityService, + private cdr: ChangeDetectorRef, + private formBuilderService: FormBuilderService) { + } + + 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(); + } + } + + onBlur(event: Event) { + this.blur.emit(event); + } + + onFocus(event: Event) { + this.focus.emit(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: AuthorityValueModel = this.optionsList[target.tabIndex]; + if (target.checked) { + this.model.valueUpdates.next(authorityValue); + } else { + const newValue = []; + this.model.value + .filter((item) => item.value !== authorityValue.value) + .forEach((item) => newValue.push(item)); + this.model.valueUpdates.next(newValue); + } + } else { + (this.model as DynamicListRadioGroupModel).valueUpdates.next(this.optionsList[target.value]); + } + this.change.emit(event); + } + + protected setOptionsFromAuthority() { + if (this.model.authorityOptions.name && this.model.authorityOptions.name.length > 0) { + const listGroup = this.group.controls[this.model.id] as FormGroup; + this.authorityService.getEntriesByName(this.searchOptions).subscribe((authorities: IntegrationData) => { + let groupCounter = 0; + let itemsPerGroup = 0; + let tempList: ListItem[] = []; + this.optionsList = authorities.payload as AuthorityValueModel[]; + // Make a list of available options (checkbox/radio) and split in groups of 'model.groupLength' + (authorities.payload as AuthorityValueModel[]).forEach((option, key) => { + const value = option.id || option.value; + const checked: boolean = isNotEmpty(findKey( + this.model.value, + {value: option.value})); + + const item: ListItem = { + id: value, + label: option.display, + value: checked, + index: key + }; + if (this.model.repeatable) { + this.formBuilderService.addFormGroupControl(listGroup, (this.model as DynamicListCheckboxGroupModel), new DynamicCheckboxModel(item)); + } else { + (this.model as DynamicListRadioGroupModel).options.push({label: item.label, value: option}); + } + tempList.push(item); + itemsPerGroup++; + this.items[groupCounter] = tempList; + if (itemsPerGroup === this.model.groupLength) { + groupCounter++; + itemsPerGroup = 0; + tempList = []; + } + }); + this.cdr.detectChanges(); + }); + + } + } + + 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-name.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup-name.model.ts new file mode 100644 index 0000000000..64590ec9e0 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup-name.model.ts @@ -0,0 +1,26 @@ +import { DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; +import { DynamicLookupModel, DynamicLookupModelConfig } from './dynamic-lookup.model'; + +export const DYNAMIC_FORM_CONTROL_TYPE_LOOKUP_NAME = 'LOOKUP_NAME'; + +export interface DynamicLookupNameModelConfig extends DynamicLookupModelConfig { + separator?: string; + firstPlaceholder?: string; + secondPlaceholder?: string; +} + +export class DynamicLookupNameModel extends DynamicLookupModel { + + @serializable() separator: string; + @serializable() secondPlaceholder: string; + @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_LOOKUP_NAME; + + constructor(config: DynamicLookupNameModelConfig, layout?: DynamicFormControlLayout) { + + super(config, layout); + + this.separator = config.separator || ','; + this.placeholder = config.firstPlaceholder || 'form.last-name'; + this.secondPlaceholder = config.secondPlaceholder || 'form.first-name'; + } +} 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 new file mode 100644 index 0000000000..bd18ef3a1f --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.html @@ -0,0 +1,127 @@ +
+ + +
+
+
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+ +
+
+
+
+ + +
+
+ + + +
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.scss new file mode 100644 index 0000000000..6dec8c1f45 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.scss @@ -0,0 +1,65 @@ +@import "../../../../../../../styles/variables"; + +.dropdown-toggle::after { + display:none +} + +/* enable absolute positioning */ +.spinner-addon { + position: relative; +} + +/* style fa-spin */ +.spinner-addon .fa-spin { + color: map-get($theme-colors, primary); + position: absolute; + margin-top: 3px; + padding: 0; + pointer-events: none; +} + +/* align fa-spin */ +.left-addon .fa-spin { + left: 0px; +} + +.right-addon .fa-spin { + right: 0px; +} + +/* add padding */ +.left-addon input { + padding-left: $spacer * 2; +} + +.right-addon input { + padding-right: $spacer * 2; +} + +:host /deep/ .dropdown-menu { + width: 100% !important; + max-height: $dropdown-menu-max-height; + overflow-y: auto !important; + overflow-x: hidden; +} + +:host /deep/ .dropdown-item.active, +:host /deep/ .dropdown-item:active, +:host /deep/ .dropdown-item:focus, +:host /deep/ .dropdown-item:hover { + color: $dropdown-link-hover-color !important; + background-color: $dropdown-link-hover-bg !important; +} +// +//.dropdown-menu { +// margin-top: -($spacer * 0.625); +//} + +div { + overflow: visible; + //padding-right: 0 !important; +} +// +//button { +// margin-right: $spacer * 0.625; +//} 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 new file mode 100644 index 0000000000..ce45453bee --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts @@ -0,0 +1,343 @@ +// Load the implementations that should be tested +import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { async, ComponentFixture, fakeAsync, inject, TestBed, tick, } from '@angular/core/testing'; + +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; + +import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; +import { 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 { DsDynamicLookupComponent } from './dynamic-lookup.component'; +import { DynamicLookupModel } from './dynamic-lookup.model'; +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; +import { TranslateModule } from '@ngx-translate/core'; +import { FormBuilderService } from '../../../form-builder.service'; +import { FormService } from '../../../../form.service'; +import { FormComponent } from '../../../../form.component'; +import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; +import { By } from '@angular/platform-browser'; +import { AuthorityValueModel } from '../../../../../../core/integration/models/authority-value.model'; +import { DynamicLookupNameModel } from './dynamic-lookup-name.model'; +import { createTestComponent } from '../../../../../testing/utils'; + +export const LOOKUP_TEST_MODEL_CONFIG = { + authorityOptions: { + closed: false, + metadata: 'lookup', + name: 'RPAuthority', + scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + } as AuthorityOptions, + disabled: false, + errorMessages: {required: 'Required field.'}, + id: 'lookup', + label: 'Author', + maxOptions: 10, + name: 'lookup', + placeholder: 'Author', + readOnly: false, + required: true, + repeatable: true, + separator: ',', + validators: {required: null}, + value: undefined +}; + +export const LOOKUP_NAME_TEST_MODEL_CONFIG = { + authorityOptions: { + closed: false, + metadata: 'lookup-name', + name: 'RPAuthority', + scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + } as AuthorityOptions, + disabled: false, + errorMessages: {required: 'Required field.'}, + id: 'lookupName', + label: 'Author', + maxOptions: 10, + name: 'lookupName', + placeholder: 'Author', + readOnly: false, + required: true, + repeatable: true, + separator: ',', + validators: {required: null}, + value: undefined +}; + +export const LOOKUP_TEST_GROUP = new FormGroup({ + lookup: new FormControl(), + lookupName: new FormControl() +}); + +describe('Dynamic Lookup component', () => { + + let testComp: TestComponent; + let lookupComp: DsDynamicLookupComponent; + let testFixture: ComponentFixture; + let lookupFixture: ComponentFixture; + let html; + + const authorityServiceStub = new AuthorityServiceStub(); + + // async beforeEach + beforeEach(async(() => { + + TestBed.configureTestingModule({ + imports: [ + DynamicFormsCoreModule, + DynamicFormsNGBootstrapUIModule, + FormsModule, + InfiniteScrollModule, + ReactiveFormsModule, + NgbModule.forRoot(), + TranslateModule.forRoot() + ], + declarations: [ + DsDynamicLookupComponent, + TestComponent, + ], // declare the test component + providers: [ + ChangeDetectorRef, + DsDynamicLookupComponent, + DynamicFormValidationService, + FormBuilderService, + FormComponent, + FormService, + {provide: AuthorityService, useValue: authorityServiceStub}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + + })); + + describe('', () => { + // synchronous beforeEach + beforeEach(() => { + html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + it('should create DsDynamicLookupComponent', inject([DsDynamicLookupComponent], (app: DsDynamicLookupComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('when model is DynamicLookupModel', () => { + + describe('', () => { + beforeEach(() => { + + lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); + lookupComp = lookupFixture.componentInstance; // FormComponent test instance + lookupComp.group = LOOKUP_TEST_GROUP; + lookupComp.model = new DynamicLookupModel(LOOKUP_TEST_MODEL_CONFIG); + lookupFixture.detectChanges(); + }); + + it('should render only an input element', () => { + const de = lookupFixture.debugElement.queryAll(By.css('input.form-control')); + expect(de.length).toBe(1); + }); + + }); + + describe('and init model value is empty', () => { + beforeEach(() => { + + lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); + lookupComp = lookupFixture.componentInstance; // FormComponent test instance + lookupComp.group = LOOKUP_TEST_GROUP; + lookupComp.model = new DynamicLookupModel(LOOKUP_TEST_MODEL_CONFIG); + lookupFixture.detectChanges(); + }); + + it('should init component properly', () => { + expect(lookupComp.firstInputValue).toBe(''); + }); + + 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); + + lookupComp.firstInputValue = 'test'; + lookupFixture.detectChanges(); + + btnEl.click(); + tick(); + lookupFixture.detectChanges(); + results$.subscribe((results) => { + expect(lookupComp.optionsList).toEqual(results.payload); + }) + + })); + + 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 AuthorityValueModel(), {id: 1, display: 'one', value: 1}); + spyOn(lookupComp.change, 'emit'); + + lookupComp.firstInputValue = 'test'; + lookupFixture.detectChanges(); + btnEl.click(); + tick(); + lookupFixture.detectChanges(); + de = lookupFixture.debugElement.queryAll(By.css('button.dropdown-item')); + const entryEl = de[0].nativeElement; + entryEl.click(); + + expect(lookupComp.firstInputValue).toEqual('one'); + expect(lookupComp.model.value).toEqual(selectedValue); + expect(lookupComp.change.emit).toHaveBeenCalled(); + })); + + it('should set model.value on input type when AuthorityOptions.closed is false', fakeAsync(() => { + lookupComp.firstInputValue = 'test'; + lookupFixture.detectChanges(); + + lookupComp.onInput(new Event('input')); + expect(lookupComp.model.value).toEqual(new FormFieldMetadataValueObject('test')) + + })); + + it('should not set model.value on input type when AuthorityOptions.closed is true', () => { + lookupComp.model.authorityOptions.closed = true; + lookupComp.firstInputValue = 'test'; + lookupFixture.detectChanges(); + + lookupComp.onInput(new Event('input')); + expect(lookupComp.model.value).not.toBeDefined(); + + }); + }); + + describe('and init model value is not empty', () => { + beforeEach(() => { + + lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); + 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'); + lookupFixture.detectChanges(); + + // spyOn(store, 'dispatch'); + }); + + it('should init component properly', () => { + expect(lookupComp.firstInputValue).toBe('test') + }); + }); + }); + + describe('when model is DynamicLookupNameModel', () => { + + describe('', () => { + 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); + lookupFixture.detectChanges(); + + // spyOn(store, 'dispatch'); + }); + + it('should render two input element', () => { + const de = lookupFixture.debugElement.queryAll(By.css('input.form-control')); + expect(de.length).toBe(2); + }); + + }); + + describe('and init model value is empty', () => { + + 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); + lookupFixture.detectChanges(); + }); + + it('should select a results entry properly', fakeAsync(() => { + const payload = [ + Object.assign(new AuthorityValueModel(), {id: 1, display: 'Name, Lastname', value: 1}), + Object.assign(new AuthorityValueModel(), {id: 2, display: 'NameTwo, LastnameTwo', value: 2}), + ]; + let de = lookupFixture.debugElement.queryAll(By.css('button')); + const btnEl = de[0].nativeElement; + const selectedValue = Object.assign(new AuthorityValueModel(), {id: 1, display: 'Name, Lastname', value: 1}); + + spyOn(lookupComp.change, 'emit'); + authorityServiceStub.setNewPayload(payload); + lookupComp.firstInputValue = 'test'; + lookupFixture.detectChanges(); + btnEl.click(); + tick(); + lookupFixture.detectChanges(); + de = lookupFixture.debugElement.queryAll(By.css('button.dropdown-item')); + const entryEl = de[0].nativeElement; + entryEl.click(); + + expect(lookupComp.firstInputValue).toEqual('Name'); + expect(lookupComp.secondInputValue).toEqual('Lastname'); + expect(lookupComp.model.value).toEqual(selectedValue); + expect(lookupComp.change.emit).toHaveBeenCalled(); + })); + + }); + + describe('and init model value is not empty', () => { + 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'); + lookupFixture.detectChanges(); + + }); + + it('should init component properly', () => { + expect(lookupComp.firstInputValue).toBe('Name'); + expect(lookupComp.secondInputValue).toBe('Lastname'); + }); + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + group: FormGroup = LOOKUP_TEST_GROUP; + + inputLookupModelConfig = LOOKUP_TEST_MODEL_CONFIG; + + model = new DynamicLookupModel(this.inputLookupModelConfig); + + showErrorMessages = false; + +} 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 new file mode 100644 index 0000000000..4e88e9c78e --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts @@ -0,0 +1,217 @@ +import { ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +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 { PageInfo } from '../../../../../../core/shared/page-info.model'; +import { Subscription } from 'rxjs/Subscription'; +import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; +import { AuthorityValueModel } from '../../../../../../core/integration/models/authority-value.model'; +import { DynamicLookupNameModel } from './dynamic-lookup-name.model'; + +@Component({ + selector: 'ds-dynamic-lookup', + styleUrls: ['./dynamic-lookup.component.scss'], + templateUrl: './dynamic-lookup.component.html' +}) +export class DsDynamicLookupComponent implements OnDestroy, OnInit { + @Input() bindId = true; + @Input() group: FormGroup; + @Input() model: DynamicLookupModel | DynamicLookupNameModel; + @Input() showErrorMessages = false; + + @Output() blur: EventEmitter = new EventEmitter(); + @Output() change: EventEmitter = new EventEmitter(); + @Output() focus: EventEmitter = new EventEmitter(); + + public firstInputValue = ''; + public secondInputValue = ''; + public loading = false; + public pageInfo: PageInfo; + public optionsList: any; + + protected searchOptions: IntegrationSearchOptions; + protected sub: Subscription; + + constructor(private authorityService: AuthorityService, + private cdr: ChangeDetectorRef) { + } + + 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); + + this.model.valueUpdates + .subscribe((value) => { + if (isEmpty(value)) { + this.resetFields(); + } else { + this.setInputsValue(this.model.value); + } + }); + } + + public formatItemForInput(item: any, field: number): string { + if (isUndefined(item) || isNull(item)) { + return ''; + } + return (typeof item === 'string') ? item : this.inputFormatter(item, field); + } + + // inputFormatter = (x: { display: string }) => x.display; + inputFormatter = (x: { display: string }, y: number) => { + // this.splitValues(); + return y === 1 ? this.firstInputValue : this.secondInputValue; + }; + + onInput(event) { + if (!this.model.authorityOptions.closed) { + if (isNotEmpty(this.getCurrentValue())) { + const currentValue = new FormFieldMetadataValueObject(this.getCurrentValue()); + this.onSelect(currentValue); + } else { + this.remove(); + } + } + } + + onScroll() { + if (!this.loading && this.pageInfo.currentPage <= this.pageInfo.totalPages) { + this.searchOptions.currentPage++; + this.search(); + } + } + + protected setInputsValue(value) { + if (hasValue(value)) { + let displayValue = value; + if (value instanceof FormFieldMetadataValueObject || value instanceof AuthorityValueModel) { + 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 getCurrentValue(): string { + let result = ''; + if (!this.isLookupName()) { + result = this.firstInputValue; + } else { + if (isNotEmpty(this.firstInputValue)) { + result = this.firstInputValue; + } + if (isNotEmpty(this.secondInputValue)) { + result = isEmpty(result) + ? this.secondInputValue + : this.firstInputValue + (this.model as DynamicLookupNameModel).separator + ' ' + this.secondInputValue; + } + } + return result; + } + + search() { + this.optionsList = null; + this.pageInfo = null; + + // Query + this.searchOptions.query = this.getCurrentValue(); + + this.loading = true; + this.authorityService.getEntriesByName(this.searchOptions) + .distinctUntilChanged() + .subscribe((object: IntegrationData) => { + this.optionsList = object.payload; + this.pageInfo = object.pageInfo; + this.loading = false; + this.cdr.detectChanges(); + }); + } + + clearFields() { + // Clear inputs whether there is no results and authority is closed + if (this.model.authorityOptions.closed) { + this.resetFields(); + } + } + + protected resetFields() { + this.firstInputValue = ''; + if (this.isLookupName()) { + this.secondInputValue = ''; + } + } + + onSelect(event) { + this.group.markAsDirty(); + this.model.valueUpdates.next(event); + this.setInputsValue(event); + this.change.emit(event); + this.optionsList = null; + this.pageInfo = null; + } + + isInputDisabled() { + return this.model.authorityOptions.closed && hasValue(this.model.value); + } + + isLookupName() { + return (this.model instanceof DynamicLookupNameModel); + } + + isSearchDisabled() { + // if (this.firstInputValue === '' + // && (this.isLookupName ? this.secondInputValue === '' : true)) { + // return true; + // } + // return false; + return isEmpty(this.firstInputValue); + } + + remove() { + this.group.markAsPristine(); + this.model.valueUpdates.next(null); + this.change.emit(null); + } + + openChange(isOpened: boolean) { + if (!isOpened) { + if (this.model.authorityOptions.closed) { + this.setInputsValue(''); + } + } + } + + onBlurEvent(event: Event) { + this.blur.emit(event); + } + + onFocusEvent(event) { + this.focus.emit(event); + } + + ngOnDestroy() { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.model.ts new file mode 100644 index 0000000000..e14dedb2ac --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.model.ts @@ -0,0 +1,26 @@ +import { AUTOCOMPLETE_OFF, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; +import { DsDynamicInputModel, DsDynamicInputModelConfig } from '../ds-dynamic-input.model'; + +export const DYNAMIC_FORM_CONTROL_TYPE_LOOKUP = 'LOOKUP'; + +export interface DynamicLookupModelConfig extends DsDynamicInputModelConfig { + maxOptions?: number; + value?: any; +} + +export class DynamicLookupModel extends DsDynamicInputModel { + + @serializable() maxOptions: number; + @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_LOOKUP; + @serializable() value: any; + + constructor(config: DynamicLookupModelConfig, layout?: DynamicFormControlLayout) { + + super(config, layout); + + this.autoComplete = AUTOCOMPLETE_OFF; + this.maxOptions = config.maxOptions || 10; + + this.valueUpdates.next(config.value); + } +} 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 new file mode 100644 index 0000000000..6a5e588610 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html @@ -0,0 +1,43 @@ +
+ + + + +
+ + diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.scss new file mode 100644 index 0000000000..34c5a84220 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.scss @@ -0,0 +1,26 @@ +@import '../../../../form.component'; + +.scrollable-menu { + height: auto; + max-height: $dropdown-menu-max-height; + overflow-x: hidden; +} + +.collection-item { + border-bottom: $dropdown-border-width solid $dropdown-border-color; +} + +.scrollable-dropdown-loading { + background-color: map-get($theme-colors, primary); + color: white; + height: $spacer * 2 !important; + line-height: $spacer * 2; + position: sticky; + bottom: 0; +} + +.scrollable-dropdown-menu { + left: 0 !important; + margin-bottom: $spacer; + z-index: 1000; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts new file mode 100644 index 0000000000..49cdb5d890 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts @@ -0,0 +1,235 @@ +// Load the implementations that should be tested +import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { async, ComponentFixture, fakeAsync, inject, TestBed, tick, } from '@angular/core/testing'; + +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; + +import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; +import { DynamicFormsCoreModule } 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 { InfiniteScrollModule } from 'ngx-infinite-scroll'; +import { TranslateModule } from '@ngx-translate/core'; +import { DsDynamicScrollableDropdownComponent } from './dynamic-scrollable-dropdown.component'; +import { DynamicScrollableDropdownModel } from './dynamic-scrollable-dropdown.model'; +import { DsDynamicTypeaheadComponent } from '../typeahead/dynamic-typeahead.component'; +import { DynamicTypeaheadModel } from '../typeahead/dynamic-typeahead.model'; +import { TYPEAHEAD_TEST_GROUP, TYPEAHEAD_TEST_MODEL_CONFIG } from '../typeahead/dynamic-typeahead.component.spec'; +import { By } from '@angular/platform-browser'; +import { AuthorityValueModel } from '../../../../../../core/integration/models/authority-value.model'; +import { hasClass, createTestComponent } from '../../../../../testing/utils'; + +export const SD_TEST_GROUP = new FormGroup({ + dropdown: new FormControl(), +}); + +export const SD_TEST_MODEL_CONFIG = { + authorityOptions: { + closed: false, + metadata: 'dropdown', + name: 'common_iso_languages', + scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + } as AuthorityOptions, + disabled: false, + errorMessages: {required: 'Required field.'}, + id: 'dropdown', + label: 'Language', + maxOptions: 10, + name: 'dropdown', + placeholder: 'Language', + readOnly: false, + required: false, + repeatable: false, + value: undefined +}; + +describe('Dynamic Dynamic Scrollable Dropdown component', () => { + + let testComp: TestComponent; + let scrollableDropdownComp: DsDynamicScrollableDropdownComponent; + let testFixture: ComponentFixture; + let scrollableDropdownFixture: ComponentFixture; + let html; + let modelValue; + + const authorityServiceStub = new AuthorityServiceStub(); + + // async beforeEach + beforeEach(async(() => { + + TestBed.configureTestingModule({ + imports: [ + DynamicFormsCoreModule, + DynamicFormsNGBootstrapUIModule, + FormsModule, + InfiniteScrollModule, + ReactiveFormsModule, + NgbModule.forRoot(), + TranslateModule.forRoot() + ], + declarations: [ + DsDynamicScrollableDropdownComponent, + TestComponent, + ], // declare the test component + providers: [ + ChangeDetectorRef, + DsDynamicScrollableDropdownComponent, + {provide: AuthorityService, useValue: authorityServiceStub}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + + })); + + describe('', () => { + // synchronous beforeEach + beforeEach(() => { + html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + it('should create DsDynamicScrollableDropdownComponent', inject([DsDynamicScrollableDropdownComponent], (app: DsDynamicScrollableDropdownComponent) => { + + expect(app).toBeDefined(); + })); + }); + + describe('', () => { + describe('when init model value is empty', () => { + beforeEach(() => { + + scrollableDropdownFixture = TestBed.createComponent(DsDynamicScrollableDropdownComponent); + scrollableDropdownComp = scrollableDropdownFixture.componentInstance; // FormComponent test instance + scrollableDropdownComp.group = SD_TEST_GROUP; + scrollableDropdownComp.model = new DynamicScrollableDropdownModel(SD_TEST_MODEL_CONFIG); + scrollableDropdownFixture.detectChanges(); + }); + + afterEach(() => { + scrollableDropdownFixture.destroy(); + scrollableDropdownComp = null; + }); + + it('should init component properly', () => { + const results$ = authorityServiceStub.getEntriesByName({} as any); + expect(scrollableDropdownComp.optionsList).toBeDefined(); + results$.subscribe((results) => { + expect(scrollableDropdownComp.optionsList).toEqual(results.payload); + }) + }); + + it('should display dropdown menu entries', () => { + const de = scrollableDropdownFixture.debugElement.query(By.css('button.ds-form-input-btn')); + const btnEl = de.nativeElement; + + const deMenu = scrollableDropdownFixture.debugElement.query(By.css('div.scrollable-dropdown-menu')); + const menuEl = deMenu.nativeElement; + + btnEl.click(); + scrollableDropdownFixture.detectChanges(); + + expect(hasClass(menuEl, 'show')).toBeTruthy(); + }); + + it('should fetch the next set of results when the user scroll to the end of the list', fakeAsync(() => { + scrollableDropdownComp.pageInfo.currentPage = 1; + scrollableDropdownComp.pageInfo.totalPages = 2; + + scrollableDropdownFixture.detectChanges(); + + scrollableDropdownComp.onScroll(); + tick(); + + expect(scrollableDropdownComp.optionsList.length).toBe(4); + })); + + it('should select a results entry properly', fakeAsync(() => { + const selectedValue = Object.assign(new AuthorityValueModel(), {id: 1, display: 'one', value: 1}); + + let de: any = scrollableDropdownFixture.debugElement.query(By.css('button.ds-form-input-btn')); + let btnEl = de.nativeElement; + + de = scrollableDropdownFixture.debugElement.query(By.css('div.scrollable-dropdown-menu')); + const menuEl = de.nativeElement; + + btnEl.click(); + scrollableDropdownFixture.detectChanges(); + + de = scrollableDropdownFixture.debugElement.queryAll(By.css('button.dropdown-item')); + btnEl = de[0].nativeElement; + + btnEl.click(); + + scrollableDropdownFixture.detectChanges(); + + expect((scrollableDropdownComp.model as any).value).toEqual(selectedValue); + })); + + it('should emit blur Event onBlur', () => { + spyOn(scrollableDropdownComp.blur, 'emit'); + scrollableDropdownComp.onBlur(new Event('blur')); + expect(scrollableDropdownComp.blur.emit).toHaveBeenCalled(); + }); + + it('should emit focus Event onFocus', () => { + spyOn(scrollableDropdownComp.focus, 'emit'); + scrollableDropdownComp.onFocus(new Event('focus')); + expect(scrollableDropdownComp.focus.emit).toHaveBeenCalled(); + }); + + }); + + describe('when init model value is not empty', () => { + beforeEach(() => { + + scrollableDropdownFixture = TestBed.createComponent(DsDynamicScrollableDropdownComponent); + scrollableDropdownComp = scrollableDropdownFixture.componentInstance; // FormComponent test instance + scrollableDropdownComp.group = SD_TEST_GROUP; + modelValue = Object.assign(new AuthorityValueModel(), {id: 1, display: 'one', value: 1}); + scrollableDropdownComp.model = new DynamicScrollableDropdownModel(SD_TEST_MODEL_CONFIG); + scrollableDropdownComp.model.value = modelValue; + scrollableDropdownFixture.detectChanges(); + }); + + afterEach(() => { + scrollableDropdownFixture.destroy(); + scrollableDropdownComp = null; + }); + + it('should init component properly', () => { + const results$ = authorityServiceStub.getEntriesByName({} as any); + expect(scrollableDropdownComp.optionsList).toBeDefined(); + results$.subscribe((results) => { + expect(scrollableDropdownComp.optionsList).toEqual(results.payload); + expect(scrollableDropdownComp.model.value).toEqual(modelValue); + }) + }); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + group: FormGroup = SD_TEST_GROUP; + + model = new DynamicScrollableDropdownModel(SD_TEST_MODEL_CONFIG); + + showErrorMessages = false; + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts new file mode 100644 index 0000000000..1c8bf15f1a --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts @@ -0,0 +1,92 @@ +import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { DynamicScrollableDropdownModel } from './dynamic-scrollable-dropdown.model'; +import { PageInfo } from '../../../../../../core/shared/page-info.model'; +import { isNull, isUndefined } from '../../../../../empty.util'; +import { AuthorityService } from '../../../../../../core/integration/authority.service'; +import { IntegrationSearchOptions } from '../../../../../../core/integration/models/integration-options.model'; +import { IntegrationData } from '../../../../../../core/integration/integration-data'; +import { AuthorityValueModel } from '../../../../../../core/integration/models/authority-value.model'; +import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'ds-dynamic-scrollable-dropdown', + styleUrls: ['./dynamic-scrollable-dropdown.component.scss'], + templateUrl: './dynamic-scrollable-dropdown.component.html' +}) +export class DsDynamicScrollableDropdownComponent implements OnInit { + @Input() bindId = true; + @Input() group: FormGroup; + @Input() model: DynamicScrollableDropdownModel; + @Input() showErrorMessages = false; + + @Output() blur: EventEmitter = new EventEmitter(); + @Output() change: EventEmitter = new EventEmitter(); + @Output() focus: EventEmitter = new EventEmitter(); + + public loading = false; + public pageInfo: PageInfo; + public optionsList: any; + + protected searchOptions: IntegrationSearchOptions; + + constructor(private authorityService: AuthorityService, private cdr: ChangeDetectorRef) {} + + ngOnInit() { + this.searchOptions = new IntegrationSearchOptions( + this.model.authorityOptions.scope, + this.model.authorityOptions.name, + this.model.authorityOptions.metadata, + '', + this.model.maxOptions, + 1); + this.authorityService.getEntriesByName(this.searchOptions) + .subscribe((object: IntegrationData) => { + this.optionsList = object.payload; + this.pageInfo = object.pageInfo; + this.cdr.detectChanges(); + }) + } + + public formatItemForInput(item: any): string { + if (isUndefined(item) || isNull(item)) { return '' } + return (typeof item === 'string') ? item : this.inputFormatter(item); + } + + inputFormatter = (x: AuthorityValueModel) => x.display || x.value; + + openDropdown(sdRef: NgbDropdown) { + if (!this.model.readOnly) { + sdRef.open(); + } + } + + onScroll() { + if (!this.loading && this.pageInfo.currentPage <= this.pageInfo.totalPages) { + this.loading = true; + this.searchOptions.currentPage++; + this.authorityService.getEntriesByName(this.searchOptions) + .do(() => this.loading = false) + .subscribe((object: IntegrationData) => { + this.optionsList = this.optionsList.concat(object.payload); + this.pageInfo = object.pageInfo; + this.cdr.detectChanges(); + }) + } + } + + onBlur(event: Event) { + this.blur.emit(event); + } + + onFocus(event) { + this.focus.emit(event); + } + + onSelect(event) { + this.group.markAsDirty(); + this.model.valueUpdates.next(event); + this.change.emit(event); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model.ts new file mode 100644 index 0000000000..8ab497ec6e --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model.ts @@ -0,0 +1,27 @@ +import { AUTOCOMPLETE_OFF, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; +import { DsDynamicInputModel, DsDynamicInputModelConfig } from '../ds-dynamic-input.model'; +import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; + +export const DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN = 'SCROLLABLE_DROPDOWN'; + +export interface DynamicScrollableDropdownModelConfig extends DsDynamicInputModelConfig { + authorityOptions: AuthorityOptions; + maxOptions?: number; + value?: any; +} + +export class DynamicScrollableDropdownModel extends DsDynamicInputModel { + + @serializable() maxOptions: number; + @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN; + + constructor(config: DynamicScrollableDropdownModelConfig, layout?: DynamicFormControlLayout) { + + super(config, layout); + + this.autoComplete = AUTOCOMPLETE_OFF; + this.authorityOptions = config.authorityOptions; + this.maxOptions = config.maxOptions || 10; + } + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.html new file mode 100644 index 0000000000..c01716b674 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.html @@ -0,0 +1,46 @@ + + {{ r.display }} + + + + + + + + + + + + diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.scss new file mode 100644 index 0000000000..9527558bbf --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.scss @@ -0,0 +1,33 @@ +@import "../../../../../../../styles/variables"; + +/* style fa-spin */ +.fa-spin { + pointer-events: none; + right: 0; +} +.chips-left { + left: 0; + padding-right: 100%; +} + +:host /deep/ .dropdown-menu { + width: 100% !important; + max-height: $dropdown-menu-max-height; + overflow-y: scroll; + overflow-x: hidden; + left: 0 !important; + margin-top: $spacer !important; +} + +:host /deep/ .dropdown-item.active, +:host /deep/ .dropdown-item:active, +:host /deep/ .dropdown-item:focus, +:host /deep/ .dropdown-item:hover { + color: $dropdown-link-hover-color !important; + background-color: $dropdown-link-hover-bg !important; +} + +.tag-input { + outline: none; + width: auto !important; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts new file mode 100644 index 0000000000..24959f4be4 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts @@ -0,0 +1,293 @@ +// Load the implementations that should be tested +import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { async, ComponentFixture, fakeAsync, flush, inject, TestBed, } from '@angular/core/testing'; + +import { DynamicFormsCoreModule } from '@ng-dynamic-forms/core'; +import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; +import { NgbModule, NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; +import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/observable/of' + +import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; +import { AuthorityService } from '../../../../../../core/integration/authority.service'; +import { AuthorityServiceStub } from '../../../../../testing/authority-service-stub'; +import { DsDynamicTagComponent } from './dynamic-tag.component'; +import { DynamicTagModel } from './dynamic-tag.model'; +import { GlobalConfig } from '../../../../../../../config/global-config.interface'; +import { GLOBAL_CONFIG } from '../../../../../../../config'; +import { Chips } from '../../../../../chips/models/chips.model'; +import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; +import { AuthorityValueModel } from '../../../../../../core/integration/models/authority-value.model'; +import { createTestComponent } from '../../../../../testing/utils'; + +function createKeyUpEvent(key: number) { + /* tslint:disable:no-empty */ + const event = { + keyCode: key, preventDefault: () => { + }, stopPropagation: () => { + } + }; + /* tslint:enable:no-empty */ + spyOn(event, 'preventDefault'); + spyOn(event, 'stopPropagation'); + return event; +} + +export const TAG_TEST_GROUP = new FormGroup({ + tag: new FormControl(), +}); + +export const TAG_TEST_MODEL_CONFIG = { + authorityOptions: { + closed: false, + metadata: 'tag', + name: 'common_iso_languages', + scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + } as AuthorityOptions, + disabled: false, + id: 'tag', + label: 'Keywords', + minChars: 3, + name: 'tag', + placeholder: 'Keywords', + readOnly: false, + required: false, + repeatable: false +}; + +describe('DsDynamicTagComponent test suite', () => { + + let testComp: TestComponent; + let tagComp: DsDynamicTagComponent; + let testFixture: ComponentFixture; + let tagFixture: ComponentFixture; + let html; + let chips: Chips; + let modelValue: any; + + // async beforeEach + beforeEach(async(() => { + const authorityServiceStub = new AuthorityServiceStub(); + + TestBed.configureTestingModule({ + imports: [ + DynamicFormsCoreModule, + DynamicFormsNGBootstrapUIModule, + FormsModule, + NgbModule.forRoot(), + ReactiveFormsModule, + ], + declarations: [ + DsDynamicTagComponent, + TestComponent, + ], // declare the test component + providers: [ + ChangeDetectorRef, + DsDynamicTagComponent, + {provide: AuthorityService, useValue: authorityServiceStub}, + {provide: GLOBAL_CONFIG, useValue: {} as GlobalConfig}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + + })); + + describe('', () => { + // synchronous beforeEach + beforeEach(() => { + html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + it('should create DsDynamicTagComponent', inject([DsDynamicTagComponent], (app: DsDynamicTagComponent) => { + + expect(app).toBeDefined(); + })); + }); + + describe('when authorityOptions are setted', () => { + describe('and init model value is empty', () => { + beforeEach(() => { + + tagFixture = TestBed.createComponent(DsDynamicTagComponent); + tagComp = tagFixture.componentInstance; // FormComponent test instance + tagComp.group = TAG_TEST_GROUP; + tagComp.model = new DynamicTagModel(TAG_TEST_MODEL_CONFIG); + tagFixture.detectChanges(); + }); + + afterEach(() => { + tagFixture.destroy(); + tagComp = null; + }); + + it('should init component properly', () => { + chips = new Chips([], 'display'); + expect(tagComp.chips.getChipsItems()).toEqual(chips.getChipsItems()); + expect(tagComp.searchOptions).toBeDefined(); + }); + + it('should search when 3+ characters typed', fakeAsync(() => { + spyOn((tagComp as any).authorityService, 'getEntriesByName').and.callThrough(); + + tagComp.search(Observable.of('test')).subscribe(() => { + expect((tagComp as any).authorityService.getEntriesByName).toHaveBeenCalled(); + }); + })); + + it('should select a results entry properly', fakeAsync(() => { + modelValue = [ + Object.assign(new AuthorityValueModel(), {id: 1, display: 'Name, Lastname', value: 1}) + ]; + const event: NgbTypeaheadSelectItemEvent = { + item: Object.assign(new AuthorityValueModel(), {id: 1, display: 'Name, Lastname', value: 1}), + preventDefault: () => { + return; + } + }; + spyOn(tagComp.change, 'emit'); + + tagComp.onSelectItem(event); + + tagFixture.detectChanges(); + flush(); + + expect(tagComp.chips.getChipsItems()).toEqual(modelValue); + expect(tagComp.model.value).toEqual(modelValue); + expect(tagComp.currentValue).toBeNull(); + expect(tagComp.change.emit).toHaveBeenCalled(); + })); + + it('should emit blur Event onBlur', () => { + spyOn(tagComp.blur, 'emit'); + tagComp.onBlur(new Event('blur')); + expect(tagComp.blur.emit).toHaveBeenCalled(); + }); + + it('should emit focus Event onFocus', () => { + spyOn(tagComp.focus, 'emit'); + tagComp.onFocus(new Event('focus')); + expect(tagComp.focus.emit).toHaveBeenCalled(); + }); + + it('should emit change Event onBlur when currentValue is not empty', fakeAsync(() => { + tagComp.currentValue = 'test value'; + tagFixture.detectChanges(); + spyOn(tagComp.blur, 'emit'); + spyOn(tagComp.change, 'emit'); + tagComp.onBlur(new Event('blur')); + + tagFixture.detectChanges(); + flush(); + + expect(tagComp.change.emit).toHaveBeenCalled(); + expect(tagComp.blur.emit).toHaveBeenCalled(); + })); + }); + + describe('and init model value is not empty', () => { + beforeEach(() => { + + tagFixture = TestBed.createComponent(DsDynamicTagComponent); + tagComp = tagFixture.componentInstance; // FormComponent test instance + tagComp.group = TAG_TEST_GROUP; + tagComp.model = new DynamicTagModel(TAG_TEST_MODEL_CONFIG); + modelValue = [ + new FormFieldMetadataValueObject('a', null, 'test001'), + new FormFieldMetadataValueObject('b', null, 'test002'), + new FormFieldMetadataValueObject('c', null, 'test003'), + ]; + tagComp.model.value = modelValue; + tagFixture.detectChanges(); + }); + + afterEach(() => { + tagFixture.destroy(); + tagComp = null; + }); + + it('should init component properly', () => { + chips = new Chips(modelValue, 'display'); + expect(tagComp.chips.getChipsItems()).toEqual(chips.getChipsItems()); + expect(tagComp.searchOptions).toBeDefined(); + }); + }); + + }); + + describe('when authorityOptions are not setted', () => { + describe('and init model value is empty', () => { + beforeEach(() => { + + tagFixture = TestBed.createComponent(DsDynamicTagComponent); + tagComp = tagFixture.componentInstance; // FormComponent test instance + tagComp.group = TAG_TEST_GROUP; + const config = TAG_TEST_MODEL_CONFIG; + config.authorityOptions = null; + tagComp.model = new DynamicTagModel(config); + tagFixture.detectChanges(); + }); + + afterEach(() => { + tagFixture.destroy(); + tagComp = null; + }); + + it('should init component properly', () => { + chips = new Chips([], 'display'); + expect(tagComp.chips.getChipsItems()).toEqual(chips.getChipsItems()); + expect(tagComp.searchOptions).not.toBeDefined(); + }); + + it('should add an item on ENTER or key press is \',\' or \';\'', fakeAsync(() => { + let event = createKeyUpEvent(13); + tagComp.currentValue = 'test value'; + + tagFixture.detectChanges(); + tagComp.onKeyUp(event); + + flush(); + + expect(tagComp.model.value).toEqual(['test value']); + expect(tagComp.currentValue).toBeNull(); + + event = createKeyUpEvent(188); + tagComp.currentValue = 'test value'; + + tagFixture.detectChanges(); + tagComp.onKeyUp(event); + + flush(); + + expect(tagComp.model.value).toEqual(['test value']); + expect(tagComp.currentValue).toBeNull(); + })); + + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + group: FormGroup = TAG_TEST_GROUP; + + model = new DynamicTagModel(TAG_TEST_MODEL_CONFIG); + + showErrorMessages = false; + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts new file mode 100644 index 0000000000..ac23e665d0 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts @@ -0,0 +1,182 @@ +import { ChangeDetectorRef, Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { Observable } from 'rxjs/Observable'; +import { NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; + +import { AuthorityService } from '../../../../../../core/integration/authority.service'; +import { DynamicTagModel } from './dynamic-tag.model'; +import { IntegrationSearchOptions } from '../../../../../../core/integration/models/integration-options.model'; +import { Chips } from '../../../../../chips/models/chips.model'; +import { hasValue, isNotEmpty } from '../../../../../empty.util'; +import { isEqual } from 'lodash'; +import { GlobalConfig } from '../../../../../../../config/global-config.interface'; +import { GLOBAL_CONFIG } from '../../../../../../../config'; + +@Component({ + selector: 'ds-dynamic-tag', + styleUrls: ['./dynamic-tag.component.scss'], + templateUrl: './dynamic-tag.component.html' +}) +export class DsDynamicTagComponent implements OnInit { + @Input() bindId = true; + @Input() group: FormGroup; + @Input() model: DynamicTagModel; + @Input() showErrorMessages = false; + + @Output() blur: EventEmitter = new EventEmitter(); + @Output() change: EventEmitter = new EventEmitter(); + @Output() focus: EventEmitter = new EventEmitter(); + + chips: Chips; + hasAuthority: boolean; + + searching = false; + searchOptions: IntegrationSearchOptions; + searchFailed = false; + hideSearchingWhenUnsubscribed = new Observable(() => () => this.changeSearchingStatus(false)); + currentValue: any; + + formatter = (x: { display: string }) => x.display; + + search = (text$: Observable) => + text$ + .debounceTime(300) + .distinctUntilChanged() + .do(() => this.changeSearchingStatus(true)) + .switchMap((term) => { + if (term === '' || term.length < this.model.minChars) { + return Observable.of({list: []}); + } else { + this.searchOptions.query = term; + return this.authorityService.getEntriesByName(this.searchOptions) + .map((authorities) => { + // @TODO Pagination for authority is not working, to refactor when it will be fixed + return { + list: authorities.payload, + pageInfo: authorities.pageInfo + }; + }) + .do(() => this.searchFailed = false) + .catch(() => { + this.searchFailed = true; + return Observable.of({list: []}); + }); + } + }) + .map((results) => results.list) + .do(() => this.changeSearchingStatus(false)) + .merge(this.hideSearchingWhenUnsubscribed); + + constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + private authorityService: AuthorityService, + private cdr: ChangeDetectorRef) { + } + + ngOnInit() { + this.hasAuthority = this.model.authorityOptions && hasValue(this.model.authorityOptions.name); + if (this.hasAuthority) { + this.searchOptions = new IntegrationSearchOptions( + this.model.authorityOptions.scope, + this.model.authorityOptions.name, + this.model.authorityOptions.metadata); + } + + this.chips = new Chips(this.model.value, 'display'); + + this.chips.chipsItems + .subscribe((subItems: any[]) => { + const items = this.chips.getChipsItems(); + // Does not emit change if model value is equal to the current value + if (!isEqual(items, this.model.value)) { + this.model.valueUpdates.next(items); + this.change.emit(event); + } + }) + } + + changeSearchingStatus(status: boolean) { + this.searching = status; + this.cdr.detectChanges(); + } + + onInput(event) { + if (event.data) { + this.group.markAsDirty(); + } + this.cdr.detectChanges(); + } + + onBlur(event: Event) { + if (isNotEmpty(this.currentValue)) { + this.addTagsToChips(); + } + this.blur.emit(event); + } + + onFocus(event) { + this.focus.emit(event); + } + + onSelectItem(event: NgbTypeaheadSelectItemEvent) { + this.chips.add(event.item); + // this.group.controls[this.model.id].setValue(this.model.value); + this.updateModel(event); + + setTimeout(() => { + // Reset the input text after x ms, mandatory or the formatter overwrite it + this.currentValue = null; + this.cdr.detectChanges(); + }, 50); + } + + updateModel(event) { + this.model.valueUpdates.next(this.chips.getChipsItems()); + this.change.emit(event); + } + + onKeyUp(event) { + if (event.keyCode === 13 || event.keyCode === 188) { + event.preventDefault(); + // Key: Enter or ',' or ';' + this.addTagsToChips(); + event.stopPropagation(); + } + } + + preventEventsPropagation(event) { + event.stopPropagation(); + if (event.keyCode === 13) { + event.preventDefault(); + } + } + + private addTagsToChips() { + if (!this.hasAuthority || !this.model.authorityOptions.closed) { + let res: string[] = []; + res = this.currentValue.split(','); + + const res1 = []; + res.forEach((item) => { + item.split(';').forEach((i) => { + res1.push(i); + }); + }); + + res1.forEach((c) => { + c = c.trim(); + if (c.length > 0) { + this.chips.add(c); + } + }); + + // this.currentValue = ''; + setTimeout(() => { + // Reset the input text after x ms, mandatory or the formatter overwrite it + this.currentValue = null; + this.cdr.detectChanges(); + }, 50); + this.updateModel(event); + } + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.model.ts new file mode 100644 index 0000000000..d17cccd338 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.model.ts @@ -0,0 +1,28 @@ +import { AUTOCOMPLETE_OFF, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; +import { DsDynamicInputModel, DsDynamicInputModelConfig } from '../ds-dynamic-input.model'; +import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; + +export const DYNAMIC_FORM_CONTROL_TYPE_TAG = 'TAG'; + +export interface DynamicTagModelConfig extends DsDynamicInputModelConfig { + minChars?: number; + value?: any; +} + +export class DynamicTagModel extends DsDynamicInputModel { + + @serializable() minChars: number; + @serializable() value: any[]; + @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_TAG; + + constructor(config: DynamicTagModelConfig, layout?: DynamicFormControlLayout) { + + super(config, layout); + + this.autoComplete = AUTOCOMPLETE_OFF; + this.minChars = config.minChars || 3; + const value = config.value || []; + this.valueUpdates.next(value) + } + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.html new file mode 100644 index 0000000000..2d5eca2f16 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.html @@ -0,0 +1,25 @@ + + {{ r.display}} + +
+ + + +
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/typeahead/dynamic-typeahead.component.scss new file mode 100644 index 0000000000..84691364f2 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.scss @@ -0,0 +1,25 @@ +@import "../../../../../../../styles/variables"; + +/* style fa-spin */ +.fa-spin { + pointer-events: none; +} + +/* align fa-spin */ +.left-addon .fa-spin { left: 0;} +.right-addon .fa-spin { right: 0;} + +:host /deep/ .dropdown-menu { + width: 100% !important; + max-height: $dropdown-menu-max-height; + overflow-y: auto !important; + overflow-x: hidden; +} + +:host /deep/ .dropdown-item.active, +:host /deep/ .dropdown-item:active, +:host /deep/ .dropdown-item:focus, +:host /deep/ .dropdown-item:hover { + color: $dropdown-link-hover-color !important; + background-color: $dropdown-link-hover-bg !important; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts new file mode 100644 index 0000000000..2ed145b03a --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts @@ -0,0 +1,224 @@ +// Load the implementations that should be tested +import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { async, ComponentFixture, fakeAsync, inject, TestBed, } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/observable/of'; + +import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; +import { DynamicFormsCoreModule } 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 { GlobalConfig } from '../../../../../../../config/global-config.interface'; +import { GLOBAL_CONFIG } from '../../../../../../../config'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { DsDynamicTypeaheadComponent } from './dynamic-typeahead.component'; +import { DynamicTypeaheadModel } from './dynamic-typeahead.model'; +import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; +import { createTestComponent } from '../../../../../testing/utils'; + +export const TYPEAHEAD_TEST_GROUP = new FormGroup({ + typeahead: new FormControl(), +}); + +export const TYPEAHEAD_TEST_MODEL_CONFIG = { + authorityOptions: { + closed: false, + metadata: 'typeahead', + name: 'EVENTAuthority', + scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + } as AuthorityOptions, + disabled: false, + id: 'typeahead', + label: 'Conference', + minChars: 3, + name: 'typeahead', + placeholder: 'Conference', + readOnly: false, + required: false, + repeatable: false, + value: undefined +}; + +describe('DsDynamicTypeaheadComponent test suite', () => { + + let testComp: TestComponent; + let typeaheadComp: DsDynamicTypeaheadComponent; + let testFixture: ComponentFixture; + let typeaheadFixture: ComponentFixture; + let html; + + // async beforeEach + beforeEach(async(() => { + const authorityServiceStub = new AuthorityServiceStub(); + + TestBed.configureTestingModule({ + imports: [ + DynamicFormsCoreModule, + DynamicFormsNGBootstrapUIModule, + FormsModule, + NgbModule.forRoot(), + ReactiveFormsModule, + ], + declarations: [ + DsDynamicTypeaheadComponent, + TestComponent, + ], // declare the test component + providers: [ + ChangeDetectorRef, + DsDynamicTypeaheadComponent, + {provide: AuthorityService, useValue: authorityServiceStub}, + {provide: GLOBAL_CONFIG, useValue: {} as GlobalConfig}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + + })); + + describe('', () => { + // synchronous beforeEach + beforeEach(() => { + html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + it('should create DsDynamicTypeaheadComponent', inject([DsDynamicTypeaheadComponent], (app: DsDynamicTypeaheadComponent) => { + + expect(app).toBeDefined(); + })); + }); + + describe('', () => { + describe('when init model value is empty', () => { + beforeEach(() => { + + typeaheadFixture = TestBed.createComponent(DsDynamicTypeaheadComponent); + typeaheadComp = typeaheadFixture.componentInstance; // FormComponent test instance + typeaheadComp.group = TYPEAHEAD_TEST_GROUP; + typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); + typeaheadFixture.detectChanges(); + }); + + afterEach(() => { + typeaheadFixture.destroy(); + typeaheadComp = null; + }); + + it('should init component properly', () => { + expect(typeaheadComp.currentValue).not.toBeDefined(); + }); + + it('should search when 3+ characters typed', fakeAsync(() => { + spyOn((typeaheadComp as any).authorityService, 'getEntriesByName').and.callThrough(); + + typeaheadComp.search(Observable.of('test')).subscribe(() => { + expect((typeaheadComp as any).authorityService.getEntriesByName).toHaveBeenCalled(); + }); + + })); + + it('should set model.value on input type when AuthorityOptions.closed is false', () => { + const inputDe = typeaheadFixture.debugElement.query(By.css('input.form-control')); + const inputElement = inputDe.nativeElement; + + inputElement.value = 'test value'; + inputElement.dispatchEvent(new Event('input')); + + expect((typeaheadComp.model as any).value).toEqual(new FormFieldMetadataValueObject('test value')) + + }); + + it('should not set model.value on input type when AuthorityOptions.closed is true', () => { + typeaheadComp.model.authorityOptions.closed = true; + typeaheadFixture.detectChanges(); + const inputDe = typeaheadFixture.debugElement.query(By.css('input.form-control')); + const inputElement = inputDe.nativeElement; + + inputElement.value = 'test value'; + inputElement.dispatchEvent(new Event('input')); + + expect(typeaheadComp.model.value).not.toBeDefined(); + + }); + + it('should emit blur Event onBlur', () => { + spyOn(typeaheadComp.blur, 'emit'); + typeaheadComp.onBlur(new Event('blur')); + expect(typeaheadComp.blur.emit).toHaveBeenCalled(); + }); + + it('should emit change Event onBlur when AuthorityOptions.closed is false', () => { + typeaheadComp.inputValue = 'test value'; + typeaheadFixture.detectChanges(); + spyOn(typeaheadComp.blur, 'emit'); + spyOn(typeaheadComp.change, 'emit'); + typeaheadComp.onBlur(new Event('blur')); + // expect(typeaheadComp.change.emit).toHaveBeenCalled(); + expect(typeaheadComp.blur.emit).toHaveBeenCalled(); + }); + + it('should emit focus Event onFocus', () => { + spyOn(typeaheadComp.focus, 'emit'); + typeaheadComp.onFocus(new Event('focus')); + expect(typeaheadComp.focus.emit).toHaveBeenCalled(); + }); + + }); + + describe('and init model value is not empty', () => { + beforeEach(() => { + typeaheadFixture = TestBed.createComponent(DsDynamicTypeaheadComponent); + typeaheadComp = typeaheadFixture.componentInstance; // FormComponent test instance + typeaheadComp.group = TYPEAHEAD_TEST_GROUP; + typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); + (typeaheadComp.model as any).value = new FormFieldMetadataValueObject('test', null, 'test001'); + typeaheadFixture.detectChanges(); + }); + + afterEach(() => { + typeaheadFixture.destroy(); + typeaheadComp = null; + }); + + it('should init component properly', () => { + expect(typeaheadComp.currentValue).toEqual(new FormFieldMetadataValueObject('test', null, 'test001')); + }); + + it('should emit change Event onChange and currentValue is empty', () => { + typeaheadComp.currentValue = null; + spyOn(typeaheadComp.change, 'emit'); + typeaheadComp.onChange(new Event('change')); + expect(typeaheadComp.change.emit).toHaveBeenCalled(); + expect(typeaheadComp.model.value).toBeNull(); + }); + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + group: FormGroup = TYPEAHEAD_TEST_GROUP; + + model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); + + showErrorMessages = false; + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts new file mode 100644 index 0000000000..dade5d037a --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts @@ -0,0 +1,123 @@ +import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { Observable } from 'rxjs/Observable'; +import { NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; + +import { AuthorityService } from '../../../../../../core/integration/authority.service'; +import { DynamicTypeaheadModel } from './dynamic-typeahead.model'; +import { IntegrationSearchOptions } from '../../../../../../core/integration/models/integration-options.model'; +import { isEmpty, isNotEmpty } from '../../../../../empty.util'; +import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; + +@Component({ + selector: 'ds-dynamic-typeahead', + styleUrls: ['./dynamic-typeahead.component.scss'], + templateUrl: './dynamic-typeahead.component.html' +}) +export class DsDynamicTypeaheadComponent implements OnInit { + @Input() bindId = true; + @Input() group: FormGroup; + @Input() model: DynamicTypeaheadModel; + @Input() showErrorMessages = false; + + @Output() blur: EventEmitter = new EventEmitter(); + @Output() change: EventEmitter = new EventEmitter(); + @Output() focus: EventEmitter = new EventEmitter(); + + searching = false; + searchOptions: IntegrationSearchOptions; + searchFailed = false; + hideSearchingWhenUnsubscribed = new Observable(() => () => this.changeSearchingStatus(false)); + currentValue: any; + inputValue: any; + + formatter = (x: { display: string }) => { + return (typeof x === 'object') ? x.display : x + }; + + search = (text$: Observable) => + text$ + .debounceTime(300) + .distinctUntilChanged() + .do(() => this.changeSearchingStatus(true)) + .switchMap((term) => { + if (term === '' || term.length < this.model.minChars) { + return Observable.of({list: []}); + } else { + this.searchOptions.query = term; + return this.authorityService.getEntriesByName(this.searchOptions) + .map((authorities) => { + // @TODO Pagination for authority is not working, to refactor when it will be fixed + return { + list: authorities.payload, + pageInfo: authorities.pageInfo + }; + }) + .do(() => this.searchFailed = false) + .catch(() => { + this.searchFailed = true; + return Observable.of({list: []}); + }); + } + }) + .map((results) => results.list) + .do(() => this.changeSearchingStatus(false)) + .merge(this.hideSearchingWhenUnsubscribed); + + constructor(private authorityService: AuthorityService, private cdr: ChangeDetectorRef) { + } + + ngOnInit() { + this.currentValue = this.model.value; + this.searchOptions = new IntegrationSearchOptions( + this.model.authorityOptions.scope, + this.model.authorityOptions.name, + this.model.authorityOptions.metadata); + this.group.get(this.model.id).valueChanges + .filter((value) => this.currentValue !== value) + .subscribe((value) => { + this.currentValue = value; + }); + } + + changeSearchingStatus(status: boolean) { + this.searching = status; + this.cdr.detectChanges(); + } + + onInput(event) { + if (!this.model.authorityOptions.closed && isNotEmpty(event.target.value)) { + const valueObj = new FormFieldMetadataValueObject(event.target.value); + this.inputValue = valueObj; + this.model.valueUpdates.next(this.inputValue); + } + } + + onBlur(event: Event) { + if (!this.model.authorityOptions.closed && isNotEmpty(this.inputValue)) { + this.change.emit(this.inputValue); + this.inputValue = null; + } + this.blur.emit(event); + } + + onChange(event: Event) { + event.stopPropagation(); + if (isEmpty(this.currentValue)) { + this.model.valueUpdates.next(null); + this.change.emit(null); + } + } + + onFocus(event) { + this.focus.emit(event); + } + + onSelectItem(event: NgbTypeaheadSelectItemEvent) { + this.inputValue = null; + this.currentValue = event.item; + this.model.valueUpdates.next(event.item); + this.change.emit(event.item); + } +} 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/typeahead/dynamic-typeahead.model.ts new file mode 100644 index 0000000000..866055ed04 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.model.ts @@ -0,0 +1,24 @@ +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 interface DsDynamicTypeaheadModelConfig extends DsDynamicInputModelConfig { + minChars?: number; + value?: any; +} + +export class DynamicTypeaheadModel extends DsDynamicInputModel { + + @serializable() minChars: number; + @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_TYPEAHEAD; + + constructor(config: DsDynamicTypeaheadModelConfig, layout?: DynamicFormControlLayout) { + + super(config, layout); + + this.autoComplete = AUTOCOMPLETE_OFF; + this.minChars = config.minChars || 3; + } + +} diff --git a/src/app/shared/form/builder/form-builder.service.spec.ts b/src/app/shared/form/builder/form-builder.service.spec.ts new file mode 100644 index 0000000000..12f51166b5 --- /dev/null +++ b/src/app/shared/form/builder/form-builder.service.spec.ts @@ -0,0 +1,830 @@ +import { inject, TestBed } from '@angular/core/testing'; +import { + FormArray, + FormControl, + FormGroup, + NG_ASYNC_VALIDATORS, + NG_VALIDATORS, + ReactiveFormsModule +} from '@angular/forms'; +import { + DynamicCheckboxGroupModel, + DynamicCheckboxModel, + DynamicColorPickerModel, + DynamicDatePickerModel, + DynamicEditorModel, + DynamicFileUploadModel, DynamicFormArrayGroupModel, + DynamicFormArrayModel, + DynamicFormControlModel, + DynamicFormControlValue, + DynamicFormGroupModel, + DynamicFormService, + DynamicFormValidationService, + DynamicFormValueControlModel, + DynamicInputModel, + DynamicRadioGroupModel, + DynamicRatingModel, + DynamicSelectModel, + DynamicSliderModel, + DynamicSwitchModel, + DynamicTextAreaModel, + DynamicTimePickerModel +} from '@ng-dynamic-forms/core'; +import { DynamicTagModel } from './ds-dynamic-form-ui/models/tag/dynamic-tag.model'; +import { DynamicListCheckboxGroupModel } from './ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model'; +import { DynamicQualdropModel } from './ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; +import { DynamicScrollableDropdownModel } from './ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; +import { DynamicGroupModel } from './ds-dynamic-form-ui/models/dynamic-group/dynamic-group.model'; +import { DynamicLookupModel } from './ds-dynamic-form-ui/models/lookup/dynamic-lookup.model'; +import { DynamicDsDatePickerModel } from './ds-dynamic-form-ui/models/date-picker/date-picker.model'; +import { DynamicTypeaheadModel } from './ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.model'; +import { DynamicListRadioGroupModel } from './ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model'; +import { AuthorityOptions } from '../../../core/integration/models/authority-options.model'; +import { FormFieldModel } from './models/form-field.model'; +import { FormRowModel, SubmissionFormsModel } from '../../../core/shared/config/config-submission-forms.model'; +import { FormBuilderService } from './form-builder.service'; +import { DynamicRowGroupModel } from './ds-dynamic-form-ui/models/ds-dynamic-row-group-model'; +import { DsDynamicInputModel } from './ds-dynamic-form-ui/models/ds-dynamic-input.model'; +import { FormFieldMetadataValueObject } from './models/form-field-metadata-value.model'; +import { DynamicConcatModel } from './ds-dynamic-form-ui/models/ds-dynamic-concat.model'; +import { DynamicLookupNameModel } from './ds-dynamic-form-ui/models/lookup/dynamic-lookup-name.model'; +import { DynamicRowArrayModel } from './ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; + +describe('FormBuilderService test suite', () => { + + let testModel: DynamicFormControlModel[]; + let testFormConfiguration: SubmissionFormsModel; + let service: FormBuilderService; + + function testValidator() { + return {testValidator: {valid: true}}; + } + + function testAsyncValidator() { + return new Promise((resolve) => setTimeout(() => resolve(true), 0)); + } + + beforeEach(() => { + + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule], + providers: [ + FormBuilderService, + DynamicFormService, + DynamicFormValidationService, + {provide: NG_VALIDATORS, useValue: testValidator, multi: true}, + {provide: NG_ASYNC_VALIDATORS, useValue: testAsyncValidator, multi: true} + ] + }); + + const authorityOptions: AuthorityOptions = { + closed: false, + metadata: 'list', + name: 'type_programme', + scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + }; + + testModel = [ + + new DynamicSelectModel( + { + id: 'testSelect', + options: [ + { + label: 'Option 1', + value: 'option-1' + }, + { + label: 'Option 2', + value: 'option-2' + } + ], + value: 'option-3' + } + ), + + new DynamicInputModel( + { + id: 'testInput', + mask: ['(', /[1-9]/, /\d/, /\d/, ')', ' ', /\d/, /\d/, /\d/, '-', /\d/, /\d/, /\d/, /\d/], + } + ), + + new DynamicCheckboxGroupModel( + { + id: 'testCheckboxGroup', + group: [ + new DynamicCheckboxModel( + { + id: 'testCheckboxGroup1', + value: true + } + ), + new DynamicCheckboxModel( + { + id: 'testCheckboxGroup2', + value: true + } + ) + ] + } + ), + + new DynamicRadioGroupModel( + { + id: 'testRadioGroup', + options: [ + { + label: 'Option 1', + value: 'option-1' + }, + { + label: 'Option 2', + value: 'option-2' + } + ], + value: 'option-3' + } + ), + + new DynamicTextAreaModel({id: 'testTextArea'}), + + new DynamicCheckboxModel({id: 'testCheckbox'}), + + new DynamicFormArrayModel( + { + id: 'testFormArray', + initialCount: 5, + groupFactory: () => { + return [ + new DynamicInputModel({id: 'testFormArrayGroupInput'}), + new DynamicFormArrayModel({ + id: 'testNestedFormArray', groupFactory: () => [ + new DynamicInputModel({id: 'testNestedFormArrayGroupInput'}) + ] + }) + ]; + } + } + ), + + new DynamicFormGroupModel( + { + id: 'testFormGroup', + group: [ + new DynamicInputModel({id: 'nestedTestInput'}), + new DynamicTextAreaModel({id: 'nestedTestTextArea'}) + ] + } + ), + + new DynamicSliderModel({id: 'testSlider'}), + + new DynamicSwitchModel({id: 'testSwitch'}), + + new DynamicDatePickerModel({id: 'testDatepicker', value: new Date()}), + + new DynamicFileUploadModel({id: 'testFileUpload'}), + + new DynamicEditorModel({id: 'testEditor'}), + + new DynamicTimePickerModel({id: 'testTimePicker'}), + + new DynamicRatingModel({id: 'testRating'}), + + new DynamicColorPickerModel({id: 'testColorPicker'}), + + new DynamicTypeaheadModel({id: 'testTypeahead'}), + + new DynamicScrollableDropdownModel({id: 'testScrollableDropdown', authorityOptions: authorityOptions}), + + new DynamicTagModel({id: 'testTag'}), + + new DynamicListCheckboxGroupModel({id: 'testCheckboxList', authorityOptions: authorityOptions, repeatable: true}), + + new DynamicListRadioGroupModel({id: 'testRadioList', authorityOptions: authorityOptions, repeatable: false}), + + new DynamicGroupModel({ + id: 'testRelationGroup', + formConfiguration: [{ + fields: [{ + hints: 'Enter the name of the author.', + input: {type: 'onebox'}, + label: 'Authors', + languageCodes: [], + mandatory: 'true', + mandatoryMessage: 'Required field!', + repeatable: false, + selectableMetadata: [{ + authority: 'RPAuthority', + closed: false, + metadata: 'dc.contributor.author' + }], + } as FormFieldModel] + } as FormRowModel, { + fields: [{ + hints: 'Enter the affiliation of the author.', + input: {type: 'onebox'}, + label: 'Affiliation', + languageCodes: [], + mandatory: 'false', + repeatable: false, + selectableMetadata: [{ + authority: 'OUAuthority', + closed: false, + metadata: 'local.contributor.affiliation' + }] + } as FormFieldModel] + } as FormRowModel], + mandatoryField: '', + name: 'testRelationGroup', + relationFields: [], + scopeUUID: '', + submissionScope: '' + }), + + new DynamicDsDatePickerModel({id: 'testDate'}), + + new DynamicLookupModel({id: 'testLookup'}), + + new DynamicLookupNameModel({id: 'testLookupName'}), + + new DynamicQualdropModel({id: 'testCombobox', readOnly: false}), + + new DynamicRowArrayModel( + { + id: 'testFormRowArray', + initialCount: 5, + notRepeteable: false, + groupFactory: () => { + return [ + new DynamicInputModel({id: 'testFormRowArrayGroupInput'}) + ]; + }, + } + ), + ]; + + testFormConfiguration = { + name: 'testFormConfiguration', + rows: [ + { + fields: [ + { + input: {type: 'lookup'}, + label: 'Journal', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name of the journal where the item has been\n\t\t\t\t\tpublished, if any.', + selectableMetadata: [ + { + metadata: 'journal', + authority: 'JOURNALAuthority', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel, + { + input: {type: 'onebox'}, + label: 'Issue', + mandatory: 'false', + repeatable: false, + hints: ' Enter issue number.', + selectableMetadata: [ + { + metadata: 'issue' + } + ], + languageCodes: [] + } as FormFieldModel, + { + input: {type: 'name'}, + label: 'Name', + mandatory: 'false', + repeatable: false, + hints: 'Enter full name.', + selectableMetadata: [ + { + metadata: 'name' + } + ], + languageCodes: [] + } as FormFieldModel + ] + } as FormRowModel, + { + fields: [ + { + hints: 'If the item has any identification numbers or codes associated with↵ it, please enter the types and the actual numbers or codes.', + input: {type: 'onebox'}, + label: 'Identifiers', + languageCodes: [], + mandatory: 'false', + repeatable: false, + selectableMetadata: [ + {metadata: 'dc.identifier.issn', label: 'ISSN'}, + {metadata: 'dc.identifier.other', label: 'Other'}, + {metadata: 'dc.identifier.ismn', label: 'ISMN'}, + {metadata: 'dc.identifier.govdoc', label: 'Gov\'t Doc #'}, + {metadata: 'dc.identifier.uri', label: 'URI'}, + {metadata: 'dc.identifier.isbn', label: 'ISBN'}, + {metadata: 'dc.identifier.doi', label: 'DOI'}, + {metadata: 'dc.identifier.pmid', label: 'PubMed ID'}, + {metadata: 'dc.identifier.arxiv', label: 'arXiv'} + ] + }, { + input: {type: 'onebox'}, + label: 'Publisher', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name of the publisher of the previously issued instance of this item.', + selectableMetadata: [ + { + metadata: 'publisher' + } + ], + languageCodes: [] + } + ] + } as FormRowModel, + { + fields: [ + { + input: {type: 'onebox'}, + label: 'Conference', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name of the events, if any.', + selectableMetadata: [ + { + metadata: 'conference', + authority: 'EVENTAuthority', + closed: false + } + ], + languageCodes: [] + } + ] + } as FormRowModel + ], + self: 'testFormConfiguration.url', + type: 'submissionform', + _links: { + self: 'testFormConfiguration.url' + } + } + }); + + beforeEach(inject([FormBuilderService], (formService: FormBuilderService) => service = formService)); + + it('should find a dynamic form control model by id', () => { + + expect(service.findById('testCheckbox', testModel) instanceof DynamicFormControlModel).toBe(true); + expect(service.findById('testCheckboxGroup', testModel) instanceof DynamicFormControlModel).toBe(true); + expect(service.findById('testDatepicker', testModel) instanceof DynamicFormControlModel).toBe(true); + expect(service.findById('testFormArray', testModel) instanceof DynamicFormControlModel).toBe(true); + expect(service.findById('testInput', testModel) instanceof DynamicFormControlModel).toBe(true); + expect(service.findById('testRadioGroup', testModel) instanceof DynamicFormControlModel).toBe(true); + expect(service.findById('testSelect', testModel) instanceof DynamicFormControlModel).toBe(true); + expect(service.findById('testSlider', testModel) instanceof DynamicFormControlModel).toBe(true); + expect(service.findById('testSwitch', testModel) instanceof DynamicFormControlModel).toBe(true); + expect(service.findById('testTextArea', testModel) instanceof DynamicFormControlModel).toBe(true); + expect(service.findById('testFileUpload', testModel) instanceof DynamicFormControlModel).toBe(true); + expect(service.findById('testEditor', testModel) instanceof DynamicEditorModel).toBe(true); + expect(service.findById('testTimePicker', testModel) instanceof DynamicTimePickerModel).toBe(true); + expect(service.findById('testRating', testModel) instanceof DynamicRatingModel).toBe(true); + expect(service.findById('testColorPicker', testModel) instanceof DynamicColorPickerModel).toBe(true); + }); + + it('should find a nested dynamic form control model by id', () => { + + expect(service.findById('testCheckboxGroup1', testModel) instanceof DynamicFormControlModel).toBe(true); + expect(service.findById('testCheckboxGroup2', testModel) instanceof DynamicFormControlModel).toBe(true); + expect(service.findById('nestedTestInput', testModel) instanceof DynamicFormControlModel).toBe(true); + expect(service.findById('testFormRowArrayGroupInput', testModel) instanceof DynamicFormControlModel).toBe(true); + expect(service.findById('testFormRowArrayGroupInput', testModel, 2) instanceof DynamicFormControlModel).toBe(true); + }); + + it('should create an array of form models', () => { + const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + + expect(formModel[0] instanceof DynamicRowGroupModel).toBe(true); + expect((formModel[0] as DynamicRowGroupModel).group.length).toBe(3); + expect((formModel[0] as DynamicRowGroupModel).get(0) instanceof DynamicLookupModel).toBe(true); + expect((formModel[0] as DynamicRowGroupModel).get(1) instanceof DsDynamicInputModel).toBe(true); + expect((formModel[0] as DynamicRowGroupModel).get(2) instanceof DynamicConcatModel).toBe(true); + + expect(formModel[1] instanceof DynamicRowGroupModel).toBe(true); + expect((formModel[1] as DynamicRowGroupModel).group.length).toBe(2); + expect((formModel[1] as DynamicRowGroupModel).get(0) instanceof DynamicQualdropModel).toBe(true); + expect(((formModel[1] as DynamicRowGroupModel).get(0) as DynamicQualdropModel).get(0) instanceof DynamicSelectModel).toBe(true); + expect(((formModel[1] as DynamicRowGroupModel).get(0) as DynamicQualdropModel).get(1) instanceof DsDynamicInputModel).toBe(true); + expect((formModel[1] as DynamicRowGroupModel).get(1) instanceof DsDynamicInputModel).toBe(true); + + expect(formModel[2] instanceof DynamicRowGroupModel).toBe(true); + expect((formModel[2] as DynamicRowGroupModel).group.length).toBe(1); + expect((formModel[2] as DynamicRowGroupModel).get(0) instanceof DynamicTypeaheadModel).toBe(true); + }); + + it('should return form\'s fields value from form model', () => { + const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + let value = {} as any; + + expect(service.getValueFromModel(formModel)).toEqual(value); + + ((formModel[0] as DynamicRowGroupModel).get(1) as DsDynamicInputModel).valueUpdates.next('test'); + + value = { + issue: [new FormFieldMetadataValueObject('test')] + }; + expect(service.getValueFromModel(formModel)).toEqual(value); + + ((formModel[2] as DynamicRowGroupModel).get(0) as DynamicTypeaheadModel).valueUpdates.next('test one'); + value = { + issue: [new FormFieldMetadataValueObject('test')], + conference: [new FormFieldMetadataValueObject('test one')] + }; + expect(service.getValueFromModel(formModel)).toEqual(value); + }); + + it('should clear all form\'s fields value', () => { + const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + const value = {} as any; + + ((formModel[0] as DynamicRowGroupModel).get(1) as DsDynamicInputModel).valueUpdates.next('test'); + ((formModel[2] as DynamicRowGroupModel).get(0) as DynamicTypeaheadModel).valueUpdates.next('test one'); + + service.clearAllModelsValue(formModel); + expect(((formModel[0] as DynamicRowGroupModel).get(1) as DynamicTypeaheadModel).value).toEqual(undefined) + expect(((formModel[2] as DynamicRowGroupModel).get(0) as DynamicTypeaheadModel).value).toEqual(undefined) + }); + + it('should return true when model has a custom group model as parent', () => { + const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + let model = service.findById('dc_identifier_QUALDROP_VALUE', formModel); + let modelParent = service.findById('dc_identifier_QUALDROP_GROUP', formModel); + model.parent = modelParent; + + expect(service.isModelInCustomGroup(model)).toBe(true); + + model = service.findById('name_CONCAT_FIRST_INPUT', formModel); + modelParent = service.findById('name_CONCAT_GROUP', formModel); + model.parent = modelParent; + + expect(service.isModelInCustomGroup(model)).toBe(true); + }); + + it('should return true when model value is an array', () => { + let model = service.findById('testCheckboxList', testModel) as DynamicFormArrayModel; + + expect(service.hasArrayGroupValue(model)).toBe(true); + + model = service.findById('testRadioList', testModel) as DynamicFormArrayModel; + + expect(service.hasArrayGroupValue(model)).toBe(true); + + model = service.findById('testTag', testModel) as DynamicFormArrayModel; + + expect(service.hasArrayGroupValue(model)).toBe(true); + }); + + it('should return true when model value is a map', () => { + const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + const model = service.findById('dc_identifier_QUALDROP_VALUE', formModel); + const modelParent = service.findById('dc_identifier_QUALDROP_GROUP', formModel); + model.parent = modelParent; + + expect(service.hasMappedGroupValue(model)).toBe(true); + }); + + it('should return true when model is a Qualdrop Group', () => { + const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + let model = service.findById('dc_identifier_QUALDROP_GROUP', formModel); + + expect(service.isQualdropGroup(model)).toBe(true); + + model = service.findById('name_CONCAT_GROUP', formModel); + + expect(service.isQualdropGroup(model)).toBe(false); + }); + + it('should return true when model is a Custom or List Group', () => { + const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + let model = service.findById('dc_identifier_QUALDROP_GROUP', formModel); + + expect(service.isCustomOrListGroup(model)).toBe(true); + + model = service.findById('name_CONCAT_GROUP', formModel); + + expect(service.isCustomOrListGroup(model)).toBe(true); + + model = service.findById('testCheckboxList', testModel); + + expect(service.isCustomOrListGroup(model)).toBe(true); + + model = service.findById('testRadioList', testModel); + + expect(service.isCustomOrListGroup(model)).toBe(true); + }); + + it('should return true when model is a Custom Group', () => { + const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + let model = service.findById('dc_identifier_QUALDROP_GROUP', formModel); + + expect(service.isCustomGroup(model)).toBe(true); + + model = service.findById('name_CONCAT_GROUP', formModel); + + expect(service.isCustomGroup(model)).toBe(true); + + model = service.findById('testCheckboxList', testModel); + + expect(service.isCustomGroup(model)).toBe(false); + + model = service.findById('testRadioList', testModel); + + expect(service.isCustomGroup(model)).toBe(false); + }); + + it('should return true when model is a List Group', () => { + let model = service.findById('testCheckboxList', testModel); + + expect(service.isListGroup(model)).toBe(true); + + model = service.findById('testRadioList', testModel); + + expect(service.isListGroup(model)).toBe(true); + }); + + it('should return true when model is a Relation Group', () => { + const model = service.findById('testRelationGroup', testModel); + + expect(service.isRelationGroup(model)).toBe(true); + }); + + it('should return true when model is a Array Row Group', () => { + let model = service.findById('testFormRowArray', testModel, null); + + expect(service.isRowArrayGroup(model)).toBe(true); + + model = service.findById('testFormArray', testModel); + + expect(service.isRowArrayGroup(model)).toBe(false); + }); + + it('should return true when model is a Array Group', () => { + let model = service.findById('testFormRowArray', testModel) as DynamicFormArrayModel; + + expect(service.isArrayGroup(model)).toBe(true); + + model = service.findById('testFormArray', testModel) as DynamicFormArrayModel; + + expect(service.isArrayGroup(model)).toBe(true); + }); + + it('should return properly form control by field id', () => { + const group = service.createFormGroup(testModel); + const control = group.controls.testLookup; + + expect(service.getFormControlById('testLookup', group, testModel)).toEqual(control); + }); + + it('should return field id from model', () => { + const model = service.findById('testRadioList', testModel); + + expect(service.getId(model)).toEqual('testRadioList'); + }); + + it('should create a form group', () => { + + const formGroup = service.createFormGroup(testModel); + + expect(formGroup instanceof FormGroup).toBe(true); + + expect(formGroup.get('testCheckbox') instanceof FormControl).toBe(true); + expect(formGroup.get('testCheckboxGroup') instanceof FormGroup).toBe(true); + expect(formGroup.get('testDatepicker') instanceof FormControl).toBe(true); + expect(formGroup.get('testFormArray') instanceof FormArray).toBe(true); + expect(formGroup.get('testInput') instanceof FormControl).toBe(true); + expect(formGroup.get('testRadioGroup') instanceof FormControl).toBe(true); + expect(formGroup.get('testSelect') instanceof FormControl).toBe(true); + expect(formGroup.get('testTextArea') instanceof FormControl).toBe(true); + expect(formGroup.get('testFileUpload') instanceof FormControl).toBe(true); + expect(formGroup.get('testEditor') instanceof FormControl).toBe(true); + expect(formGroup.get('testTimePicker') instanceof FormControl).toBe(true); + expect(formGroup.get('testRating') instanceof FormControl).toBe(true); + expect(formGroup.get('testColorPicker') instanceof FormControl).toBe(true); + }); + + it('should throw when unknown DynamicFormControlModel id is specified in JSON', () => { + + expect(() => service.fromJSON([{id: 'test'}])) + .toThrow(new Error(`unknown form control model type defined on JSON object with id "test"`)); + }); + + it('should resolve array group path', () => { + + service.createFormGroup(testModel); + + const model = service.findById('testFormArray', testModel) as DynamicFormArrayModel; + const nestedModel = (model.get(0).get(1) as DynamicFormArrayModel).get(0); + + expect(service.getPath(model)).toEqual(['testFormArray']); + expect(service.getPath(nestedModel)).toEqual(['testFormArray', '0', 'testNestedFormArray', '0']); + }); + + it('should add a form control to an existing form group', () => { + + const formGroup = service.createFormGroup(testModel); + const nestedFormGroup = formGroup.controls.testFormGroup as FormGroup; + const nestedFormGroupModel = testModel[7] as DynamicFormGroupModel; + const newModel1 = new DynamicInputModel({id: 'newInput1'}); + const newModel2 = new DynamicInputModel({id: 'newInput2'}); + + service.addFormGroupControl(formGroup, testModel, newModel1); + service.addFormGroupControl(nestedFormGroup, nestedFormGroupModel, newModel2); + + expect(formGroup.controls[newModel1.id]).toBeTruthy(); + expect(testModel[testModel.length - 1] === newModel1).toBe(true); + + expect((formGroup.controls.testFormGroup as FormGroup).controls[newModel2.id]).toBeTruthy(); + expect(nestedFormGroupModel.get(nestedFormGroupModel.group.length - 1) === newModel2).toBe(true); + }); + + it('should insert a form control to an existing form group', () => { + + const formGroup = service.createFormGroup(testModel); + const nestedFormGroup = formGroup.controls.testFormGroup as FormGroup; + const nestedFormGroupModel = testModel[7] as DynamicFormGroupModel; + const newModel1 = new DynamicInputModel({id: 'newInput1'}); + const newModel2 = new DynamicInputModel({id: 'newInput2'}); + + service.insertFormGroupControl(4, formGroup, testModel, newModel1); + service.insertFormGroupControl(0, nestedFormGroup, nestedFormGroupModel, newModel2); + + expect(formGroup.controls[newModel1.id]).toBeTruthy(); + expect(testModel[4] === newModel1).toBe(true); + expect(service.getPath(testModel[4])).toEqual(['newInput1']); + + expect((formGroup.controls.testFormGroup as FormGroup).controls[newModel2.id]).toBeTruthy(); + expect(nestedFormGroupModel.get(0) === newModel2).toBe(true); + expect(service.getPath(nestedFormGroupModel.get(0))).toEqual(['testFormGroup', 'newInput2']); + }); + + it('should move an existing form control to a different group position', () => { + + const formGroup = service.createFormGroup(testModel); + const nestedFormGroupModel = testModel[7] as DynamicFormGroupModel; + const model1 = testModel[0]; + const model2 = nestedFormGroupModel.get(0); + + service.moveFormGroupControl(0, 2, testModel); + + expect(formGroup.controls[model1.id]).toBeTruthy(); + expect(testModel[2] === model1).toBe(true); + + service.moveFormGroupControl(0, 1, nestedFormGroupModel); + + expect((formGroup.controls.testFormGroup as FormGroup).controls[model2.id]).toBeTruthy(); + expect(nestedFormGroupModel.get(1) === model2).toBe(true); + }); + + it('should remove a form control from an existing form group', () => { + + const formGroup = service.createFormGroup(testModel); + const nestedFormGroup = formGroup.controls.testFormGroup as FormGroup; + const nestedFormGroupModel = testModel[7] as DynamicFormGroupModel; + const length = testModel.length; + const size = nestedFormGroupModel.size(); + const index = 1; + const id1 = testModel[index].id; + const id2 = nestedFormGroupModel.get(index).id; + + service.removeFormGroupControl(index, formGroup, testModel); + + expect(Object.keys(formGroup.controls).length).toBe(length - 1); + expect(formGroup.controls[id1]).toBeUndefined(); + + expect(testModel.length).toBe(length - 1); + expect(service.findById(id1, testModel)).toBeNull(); + + service.removeFormGroupControl(index, nestedFormGroup, nestedFormGroupModel); + + expect(Object.keys(nestedFormGroup.controls).length).toBe(size - 1); + expect(nestedFormGroup.controls[id2]).toBeUndefined(); + + expect(nestedFormGroupModel.size()).toBe(size - 1); + expect(service.findById(id2, testModel)).toBeNull(); + }); + + it('should create a form array', () => { + + const model = service.findById('testFormArray', testModel) as DynamicFormArrayModel; + let formArray; + + expect(service.createFormArray).toBeTruthy(); + + formArray = service.createFormArray(model); + + expect(formArray instanceof FormArray).toBe(true); + expect(formArray.length).toBe(model.initialCount); + }); + + it('should add a form array group', () => { + + const model = service.findById('testFormArray', testModel) as DynamicFormArrayModel; + const formArray = service.createFormArray(model); + + service.addFormArrayGroup(formArray, model); + + expect(formArray.length).toBe(model.initialCount + 1); + }); + + it('should insert a form array group', () => { + + const model = service.findById('testFormArray', testModel) as DynamicFormArrayModel; + const formArray = service.createFormArray(model); + + service.insertFormArrayGroup(0, formArray, model); + + expect(formArray.length).toBe(model.initialCount + 1); + }); + + it('should move up a form array group', () => { + + const model = service.findById('testFormArray', testModel) as DynamicFormArrayModel; + const formArray = service.createFormArray(model); + const index = 3; + const step = 1; + + (formArray.at(index) as FormGroup).controls.testFormArrayGroupInput.setValue('next test value 1'); + (formArray.at(index + step) as FormGroup).controls.testFormArrayGroupInput.setValue('next test value 2'); + + (model.get(index).get(0) as DynamicFormValueControlModel).valueUpdates.next('next test value 1'); + (model.get(index + step).get(0) as DynamicFormValueControlModel).valueUpdates.next('next test value 2'); + + service.moveFormArrayGroup(index, step, formArray, model); + + expect(formArray.length).toBe(model.initialCount); + + expect((formArray.at(index) as FormGroup).controls.testFormArrayGroupInput.value).toEqual('next test value 2'); + expect((formArray.at(index + step) as FormGroup).controls.testFormArrayGroupInput.value).toEqual('next test value 1'); + + expect((model.get(index).get(0) as DynamicFormValueControlModel).value).toEqual('next test value 2'); + expect((model.get(index + step).get(0) as DynamicFormValueControlModel).value).toEqual('next test value 1'); + }); + + it('should move down a form array group', () => { + + const model = service.findById('testFormArray', testModel) as DynamicFormArrayModel; + const formArray = service.createFormArray(model); + const index = 3; + const step = -1; + + (formArray.at(index) as FormGroup).controls.testFormArrayGroupInput.setValue('next test value 1'); + (formArray.at(index + step) as FormGroup).controls.testFormArrayGroupInput.setValue('next test value 2'); + + (model.get(index).get(0) as DynamicFormValueControlModel).valueUpdates.next('next test value 1'); + (model.get(index + step).get(0) as DynamicFormValueControlModel).valueUpdates.next('next test value 2'); + + service.moveFormArrayGroup(index, step, formArray, model); + + expect(formArray.length).toBe(model.initialCount); + + expect((formArray.at(index) as FormGroup).controls.testFormArrayGroupInput.value).toEqual('next test value 2'); + expect((formArray.at(index + step) as FormGroup).controls.testFormArrayGroupInput.value).toEqual('next test value 1'); + + expect((model.get(index).get(0) as DynamicFormValueControlModel).value).toEqual('next test value 2'); + expect((model.get(index + step).get(0) as DynamicFormValueControlModel).value).toEqual('next test value 1'); + }); + + it('should throw when form array group is to be moved out of bounds', () => { + + const model = service.findById('testFormArray', testModel) as DynamicFormArrayModel; + const formArray = service.createFormArray(model); + + expect(() => service.moveFormArrayGroup(2, -5, formArray, model)) + .toThrow(new Error(`form array group cannot be moved due to index or new index being out of bounds`)); + }); + + it('should remove a form array group', () => { + + const model = service.findById('testFormArray', testModel) as DynamicFormArrayModel; + const formArray = service.createFormArray(model); + + service.removeFormArrayGroup(0, formArray, model); + + expect(formArray.length).toBe(model.initialCount - 1); + }); + + it('should clear a form array', () => { + + const model = service.findById('testFormArray', testModel) as DynamicFormArrayModel; + const formArray = service.createFormArray(model); + + service.clearFormArray(formArray, model); + + expect(formArray.length === 0).toBe(true); + }); +}); diff --git a/src/app/shared/form/builder/form-builder.service.ts b/src/app/shared/form/builder/form-builder.service.ts new file mode 100644 index 0000000000..ec78225274 --- /dev/null +++ b/src/app/shared/form/builder/form-builder.service.ts @@ -0,0 +1,284 @@ +import { Injectable } from '@angular/core'; +import { AbstractControl, FormGroup } from '@angular/forms'; + +import { + DYNAMIC_FORM_CONTROL_TYPE_ARRAY, + DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX_GROUP, + DYNAMIC_FORM_CONTROL_TYPE_GROUP, + DYNAMIC_FORM_CONTROL_TYPE_RADIO_GROUP, + DynamicFormArrayModel, + DynamicFormControlModel, + DynamicFormGroupModel, + DynamicFormService, + DynamicPathable, + JSONUtils, +} from '@ng-dynamic-forms/core'; +import { isObject, isString, mergeWith } from 'lodash'; + +import { hasValue, isEmpty, isNotEmpty, isNotNull, isNotUndefined, isNull } from '../../empty.util'; +import { DynamicQualdropModel } from './ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; +import { SubmissionFormsModel } from '../../../core/shared/config/config-submission-forms.model'; +import { + DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP, + DynamicGroupModel +} from './ds-dynamic-form-ui/models/dynamic-group/dynamic-group.model'; +import { DYNAMIC_FORM_CONTROL_TYPE_TAG } from './ds-dynamic-form-ui/models/tag/dynamic-tag.model'; +import { RowParser } from './parsers/row-parser'; + +import { DynamicRowArrayModel } from './ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; +import { DsDynamicInputModel } from './ds-dynamic-form-ui/models/ds-dynamic-input.model'; +import { FormFieldMetadataValueObject } from './models/form-field-metadata-value.model'; + +@Injectable() +export class FormBuilderService extends DynamicFormService { + + findById(id: string, groupModel: DynamicFormControlModel[], arrayIndex = null): DynamicFormControlModel | null { + + let result = null; + const findByIdFn = (findId: string, findGroupModel: DynamicFormControlModel[], findArrayIndex): void => { + + for (const controlModel of findGroupModel) { + + if (controlModel.id === findId) { + + if (this.isArrayGroup(controlModel) && isNotNull(findArrayIndex)) { + result = (controlModel as DynamicFormArrayModel).get(findArrayIndex); + } else { + result = controlModel; + } + break; + } + + if (this.isGroup(controlModel)) { + findByIdFn(findId, (controlModel as DynamicFormGroupModel).group, findArrayIndex); + } + + if (this.isArrayGroup(controlModel) + && (isNull(findArrayIndex) || (controlModel as DynamicFormArrayModel).size > (findArrayIndex))) { + const index = (isNull(findArrayIndex)) ? 0 : findArrayIndex; + findByIdFn(findId, (controlModel as DynamicFormArrayModel).get(index).group, index); + } + } + }; + + findByIdFn(id, groupModel, arrayIndex); + + return result; + } + + clearAllModelsValue(groupModel: DynamicFormControlModel[]): void { + + const iterateControlModels = (findGroupModel: DynamicFormControlModel[]): void => { + + for (const controlModel of findGroupModel) { + + if (this.isGroup(controlModel)) { + iterateControlModels((controlModel as DynamicFormGroupModel).group); + continue; + } + + if (this.isArrayGroup(controlModel)) { + iterateControlModels((controlModel as DynamicFormArrayModel).groupFactory()); + continue; + } + + if (controlModel.hasOwnProperty('valueUpdates')) { + (controlModel as any).valueUpdates.next(undefined); + } + } + }; + + iterateControlModels(groupModel); + } + + getValueFromModel(groupModel: DynamicFormControlModel[]): void { + + let result = Object.create({}); + + const customizer = (objValue, srcValue) => { + if (Array.isArray(objValue)) { + return objValue.concat(srcValue); + } + }; + + const normalizeValue = (controlModel, controlValue, controlModelIndex) => { + const controlLanguage = (controlModel as DsDynamicInputModel).hasLanguages ? (controlModel as DsDynamicInputModel).language : null; + if (isString(controlValue)) { + return new FormFieldMetadataValueObject(controlValue, controlLanguage, null, null, controlModelIndex); + } else if (isObject(controlValue)) { + const authority = controlValue.authority || controlValue.id || null; + const place = controlModelIndex || controlValue.place; + return new FormFieldMetadataValueObject(controlValue.value, controlLanguage, authority, controlValue.display, place); + } + }; + + const iterateControlModels = (findGroupModel: DynamicFormControlModel[], controlModelIndex: number = 0): void => { + let iterateResult = Object.create({}); + + // Iterate over all group's controls + for (const controlModel of findGroupModel) { + + if (this.isRowGroup(controlModel) && !this.isCustomOrListGroup(controlModel)) { + iterateResult = mergeWith(iterateResult, iterateControlModels((controlModel as DynamicFormGroupModel).group), customizer); + continue; + } + + if (this.isGroup(controlModel) && !this.isCustomOrListGroup(controlModel)) { + iterateResult[controlModel.name] = iterateControlModels((controlModel as DynamicFormGroupModel).group); + continue; + } + + if (this.isRowArrayGroup(controlModel)) { + for (const arrayItemModel of (controlModel as DynamicRowArrayModel).groups) { + iterateResult = mergeWith(iterateResult, iterateControlModels(arrayItemModel.group, arrayItemModel.index), customizer); + } + continue; + } + + if (this.isArrayGroup(controlModel)) { + iterateResult[controlModel.name] = []; + for (const arrayItemModel of (controlModel as DynamicFormArrayModel).groups) { + iterateResult[controlModel.name].push(iterateControlModels(arrayItemModel.group, arrayItemModel.index)); + } + continue; + } + + let controlId; + // Get the field's name + if (this.isQualdropGroup(controlModel)) { + // If is instance of DynamicQualdropModel take the qualdrop id as field's name + controlId = (controlModel as DynamicQualdropModel).qualdropId; + } else { + controlId = controlModel.name; + } + + if (this.isRelationGroup(controlModel)) { + const values = (controlModel as DynamicGroupModel).getGroupValue(); + values.forEach((groupValue, groupIndex) => { + const newGroupValue = Object.create({}); + Object.keys(groupValue) + .forEach((key) => { + const normValue = normalizeValue(controlModel, groupValue[key], groupIndex); + if (isNotEmpty(normValue) && normValue.hasValue()) { + if (iterateResult.hasOwnProperty(key)) { + iterateResult[key].push(normValue); + } else { + iterateResult[key] = [normValue]; + } + } + }); + }) + } else if (isNotUndefined((controlModel as any).value) && isNotEmpty((controlModel as any).value)) { + const controlArrayValue = []; + // Normalize control value as an array of FormFieldMetadataValueObject + const values = Array.isArray((controlModel as any).value) ? (controlModel as any).value : [(controlModel as any).value]; + values.forEach((controlValue) => { + controlArrayValue.push(normalizeValue(controlModel, controlValue, controlModelIndex)) + }); + + if (controlId && iterateResult.hasOwnProperty(controlId) && isNotNull(iterateResult[controlId])) { + iterateResult[controlId] = iterateResult[controlId].concat(controlArrayValue); + } else { + iterateResult[controlId] = isNotEmpty(controlArrayValue) ? controlArrayValue : null; + } + } + + } + + return iterateResult; + }; + + result = iterateControlModels(groupModel); + + return result; + } + + modelFromConfiguration(json: string | SubmissionFormsModel, scopeUUID: string, initFormValues: any = {}, submissionScope?: string, readOnly = false): DynamicFormControlModel[] | never { + let rows: DynamicFormControlModel[] = []; + const rawData = typeof json === 'string' ? JSON.parse(json, JSONUtils.parseReviver) : json; + + if (rawData.rows && !isEmpty(rawData.rows)) { + rawData.rows.forEach((currentRow) => { + const rowParsed = new RowParser(currentRow, scopeUUID, initFormValues, submissionScope, readOnly).parse(); + if (isNotNull(rowParsed)) { + if (Array.isArray(rowParsed)) { + rows = rows.concat(rowParsed); + } else { + rows.push(rowParsed); + } + } + }); + } + + return rows; + } + + isModelInCustomGroup(model: DynamicFormControlModel): boolean { + return this.isCustomGroup((model as any).parent); + } + + hasArrayGroupValue(model: DynamicFormControlModel): boolean { + return model && (this.isListGroup(model) || model.type === DYNAMIC_FORM_CONTROL_TYPE_TAG); + } + + hasMappedGroupValue(model: DynamicFormControlModel): boolean { + return (this.isQualdropGroup((model as any).parent) + || this.isRelationGroup((model as any).parent)); + } + + isGroup(model: DynamicFormControlModel): boolean { + return model && (model.type === DYNAMIC_FORM_CONTROL_TYPE_GROUP || model.type === DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX_GROUP); + } + + isQualdropGroup(model: DynamicFormControlModel): boolean { + return (model && model.type === DYNAMIC_FORM_CONTROL_TYPE_GROUP && hasValue((model as any).qualdropId)); + } + + isCustomGroup(model: DynamicFormControlModel): boolean { + return model && ((model as any).type === DYNAMIC_FORM_CONTROL_TYPE_GROUP && (model as any).isCustomGroup === true); + } + + isRowGroup(model: DynamicFormControlModel): boolean { + return model && ((model as any).type === DYNAMIC_FORM_CONTROL_TYPE_GROUP && (model as any).isRowGroup === true); + } + + isCustomOrListGroup(model: DynamicFormControlModel): boolean { + return model && + (this.isCustomGroup(model) + || this.isListGroup(model)); + } + + isListGroup(model: DynamicFormControlModel): boolean { + return model && + ((model.type === DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX_GROUP && (model as any).isListGroup === true) + || (model.type === DYNAMIC_FORM_CONTROL_TYPE_RADIO_GROUP && (model as any).isListGroup === true)); + } + + isRelationGroup(model: DynamicFormControlModel): boolean { + return model && model.type === DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP; + } + + isRowArrayGroup(model: DynamicFormControlModel): boolean { + return model.type === DYNAMIC_FORM_CONTROL_TYPE_ARRAY && (model as any).isRowArray === true; + } + + isArrayGroup(model: DynamicFormControlModel): boolean { + return model.type === DYNAMIC_FORM_CONTROL_TYPE_ARRAY; + } + + getFormControlById(id: string, formGroup: FormGroup, groupModel: DynamicFormControlModel[], index = 0): AbstractControl { + const fieldModel = this.findById(id, groupModel, index); + return isNotEmpty(fieldModel) ? formGroup.get(this.getPath(fieldModel)) : null; + } + + getId(model: DynamicPathable): string { + if (this.isArrayGroup(model as DynamicFormControlModel)) { + return model.index.toString(); + } else { + return ((model as DynamicFormControlModel).id !== (model as DynamicFormControlModel).name) ? + (model as DynamicFormControlModel).name : + (model as DynamicFormControlModel).id; + } + } + +} diff --git a/src/app/shared/form/builder/models/form-field-language-value.model.ts b/src/app/shared/form/builder/models/form-field-language-value.model.ts new file mode 100644 index 0000000000..e626c0262d --- /dev/null +++ b/src/app/shared/form/builder/models/form-field-language-value.model.ts @@ -0,0 +1,14 @@ +export class FormFieldLanguageValueObject { + value: string; + language: string; + + constructor(value: string, language: string) { + this.value = value; + this.language = language; + } +} + +export interface LanguageCode { + display: string; + code: string; +} diff --git a/src/app/shared/form/builder/models/form-field-metadata-value.model.ts b/src/app/shared/form/builder/models/form-field-metadata-value.model.ts new file mode 100644 index 0000000000..542e5d029e --- /dev/null +++ b/src/app/shared/form/builder/models/form-field-metadata-value.model.ts @@ -0,0 +1,46 @@ +import { isNotEmpty, isNotNull } from '../../../empty.util'; + +export class FormFieldMetadataValueObject { + metadata?: string; + value: any; + display: string; + language: any; + authority: string; + confidence: number; + place: number; + closed: boolean; + label: string; + + constructor(value: any = null, + language: any = null, + authority: string = null, + display: string = null, + place: number = 0, + confidence: number = -1, + metadata: string = null) { + this.value = isNotNull(value) ? ((typeof value === 'string') ? value.trim() : value) : null; + this.language = language; + this.authority = authority; + this.display = display || value; + + this.confidence = confidence; + if (authority != null) { + this.confidence = 600; + } else if (isNotEmpty(confidence)) { + this.confidence = confidence; + } + + this.place = place; + if (isNotEmpty(metadata)) { + this.metadata = metadata; + } + } + + hasAuthority(): boolean { + return isNotEmpty(this.authority); + } + + hasValue(): boolean { + return isNotEmpty(this.value); + } +} diff --git a/src/app/shared/form/builder/models/form-field-previous-value-object.ts b/src/app/shared/form/builder/models/form-field-previous-value-object.ts new file mode 100644 index 0000000000..f0ead99f91 --- /dev/null +++ b/src/app/shared/form/builder/models/form-field-previous-value-object.ts @@ -0,0 +1,37 @@ +import { isEqual } from 'lodash'; + +export class FormFieldPreviousValueObject { + + private _path; + private _value; + + constructor(path: any[] = null, value: any = null) { + this._path = path; + this._value = value; + } + + get path() { + return this._path; + } + + set path(path: any[]) { + this._path = path; + } + + get value() { + return this._value; + } + + set value(value: any) { + this._value = value; + } + + public delete() { + this._value = null; + this._path = null; + } + + public isPathEqual(path) { + return this._path && isEqual(this._path, path); + } +} diff --git a/src/app/shared/form/builder/models/form-field-unexpected-object.model.ts b/src/app/shared/form/builder/models/form-field-unexpected-object.model.ts new file mode 100644 index 0000000000..1b37498a64 --- /dev/null +++ b/src/app/shared/form/builder/models/form-field-unexpected-object.model.ts @@ -0,0 +1,3 @@ +export interface FormFieldChangedObject { + string: any +} diff --git a/src/app/shared/form/builder/models/form-field.model.ts b/src/app/shared/form/builder/models/form-field.model.ts new file mode 100644 index 0000000000..95b8798d5f --- /dev/null +++ b/src/app/shared/form/builder/models/form-field.model.ts @@ -0,0 +1,43 @@ +import { autoserialize } from 'cerialize'; +import { FormRowModel } from '../../../../core/shared/config/config-submission-forms.model'; +import { LanguageCode } from './form-field-language-value.model'; +import { FormFieldMetadataValueObject } from './form-field-metadata-value.model'; + +export class FormFieldModel { + + @autoserialize + hints: string; + + @autoserialize + label: string; + + @autoserialize + languageCodes: LanguageCode[]; + + @autoserialize + mandatoryMessage: string; + + @autoserialize + mandatory: string; + + @autoserialize + repeatable: boolean; + + @autoserialize + input: { + type: string; + regex?: string; + }; + + @autoserialize + selectableMetadata: FormFieldMetadataValueObject[]; + + @autoserialize + rows: FormRowModel[]; + + @autoserialize + scope: string; + + @autoserialize + value: any; +} diff --git a/src/app/shared/form/builder/parsers/concat-field-parser.ts b/src/app/shared/form/builder/parsers/concat-field-parser.ts new file mode 100644 index 0000000000..802d866ad7 --- /dev/null +++ b/src/app/shared/form/builder/parsers/concat-field-parser.ts @@ -0,0 +1,105 @@ +import { FieldParser } from './field-parser'; +import { FormFieldModel } from '../models/form-field.model'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { DynamicFormControlLayout, DynamicInputModel, DynamicInputModelConfig } from '@ng-dynamic-forms/core'; +import { + CONCAT_FIRST_INPUT_SUFFIX, + CONCAT_GROUP_SUFFIX, + CONCAT_SECOND_INPUT_SUFFIX, + DynamicConcatModel, + DynamicConcatModelConfig +} from '../ds-dynamic-form-ui/models/ds-dynamic-concat.model'; +import { isNotEmpty } from '../../../empty.util'; +import { ParserOptions } from './parser-options'; + +export class ConcatFieldParser extends FieldParser { + + constructor(protected configData: FormFieldModel, + protected initFormValues, + protected parserOptions: ParserOptions, + protected separator: string, + protected firstPlaceholder: string = null, + protected secondPlaceholder: string = null) { + super(configData, initFormValues, parserOptions); + + this.separator = separator; + this.firstPlaceholder = firstPlaceholder; + this.secondPlaceholder = secondPlaceholder; + } + + public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { + + let clsGroup: DynamicFormControlLayout; + let clsInput: DynamicFormControlLayout; + let newId: string; + + if (this.configData.selectableMetadata[0].metadata.includes('.')) { + newId = this.configData.selectableMetadata[0].metadata + .split('.') + .slice(0, this.configData.selectableMetadata[0].metadata.split('.').length - 1) + .join('.'); + } else { + newId = this.configData.selectableMetadata[0].metadata + } + + clsInput = { + grid: { + host: 'col-sm-6' + } + }; + + const groupId = newId.replace(/\./g, '_') + CONCAT_GROUP_SUFFIX; + const concatGroup: DynamicConcatModelConfig = this.initModel(groupId, false, false); + + concatGroup.group = []; + concatGroup.separator = this.separator; + + const input1ModelConfig: DynamicInputModelConfig = this.initModel(newId + CONCAT_FIRST_INPUT_SUFFIX, label, false, false); + const input2ModelConfig: DynamicInputModelConfig = this.initModel(newId + CONCAT_SECOND_INPUT_SUFFIX, label, true, false); + + if (this.configData.mandatory) { + input1ModelConfig.required = true; + } + + if (isNotEmpty(this.firstPlaceholder)) { + input1ModelConfig.placeholder = this.firstPlaceholder; + } + + if (isNotEmpty(this.secondPlaceholder)) { + input2ModelConfig.placeholder = this.secondPlaceholder; + } + + // Init values + if (isNotEmpty(fieldValue)) { + const values = fieldValue.value.split(this.separator); + + if (values.length > 1) { + input1ModelConfig.value = values[0].trim(); + input2ModelConfig.value = values[1].trim(); + } + } + + // Split placeholder if is like 'placeholder1/placeholder2' + const placeholder = this.configData.label.split('/'); + if (placeholder.length === 2) { + input1ModelConfig.placeholder = placeholder[0]; + input2ModelConfig.placeholder = placeholder[1]; + } + + const model1 = new DynamicInputModel(input1ModelConfig, clsInput); + const model2 = new DynamicInputModel(input2ModelConfig, clsInput); + concatGroup.group.push(model1); + concatGroup.group.push(model2); + + clsGroup = { + element: { + control: 'form-row', + } + }; + const concatModel = new DynamicConcatModel(concatGroup, clsGroup); + concatModel.name = this.getFieldId(); + + return concatModel; + } + +} diff --git a/src/app/shared/form/builder/parsers/date-field-parser.spec.ts b/src/app/shared/form/builder/parsers/date-field-parser.spec.ts new file mode 100644 index 0000000000..bbcfa60621 --- /dev/null +++ b/src/app/shared/form/builder/parsers/date-field-parser.spec.ts @@ -0,0 +1,65 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { DynamicConcatModel } from '../ds-dynamic-form-ui/models/ds-dynamic-concat.model'; +import { SeriesFieldParser } from './series-field-parser'; +import { DateFieldParser } from './date-field-parser'; +import { DynamicDsDatePickerModel } from '../ds-dynamic-form-ui/models/date-picker/date-picker.model'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { ParserOptions } from './parser-options'; + +describe('DateFieldParser test suite', () => { + let field: FormFieldModel; + let initFormValues: any = {}; + + const parserOptions: ParserOptions = { + readOnly: false, + submissionScope: null, + authorityUuid: null + }; + + beforeEach(() => { + field = { + input: { + type: 'date' + }, + label: 'Date of Issue.', + mandatory: 'true', + repeatable: false, + hints: 'Please give the date of previous publication or public distribution. You can leave out the day and/or month if they aren\'t applicable.', + mandatoryMessage: 'You must enter at least the year.', + selectableMetadata: [ + { + metadata: 'date', + } + ], + languageCodes: [] + } as FormFieldModel; + + }); + + it('should init parser properly', () => { + const parser = new DateFieldParser(field, initFormValues, parserOptions); + + expect(parser instanceof DateFieldParser).toBe(true); + }); + + it('should return a DynamicDsDatePickerModel object when repeatable option is false', () => { + const parser = new DateFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DynamicDsDatePickerModel).toBe(true); + }); + + it('should set init value properly', () => { + initFormValues = { + date: [new FormFieldMetadataValueObject('1983-11-18')], + }; + const expectedValue = '1983-11-18'; + + const parser = new DateFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel.value).toEqual(expectedValue); + }); +}); diff --git a/src/app/shared/form/builder/parsers/date-field-parser.ts b/src/app/shared/form/builder/parsers/date-field-parser.ts new file mode 100644 index 0000000000..ae83c5a032 --- /dev/null +++ b/src/app/shared/form/builder/parsers/date-field-parser.ts @@ -0,0 +1,36 @@ +import { FieldParser } from './field-parser'; +import { DynamicDatePickerModelConfig } from '@ng-dynamic-forms/core'; +import { DynamicDsDatePickerModel } from '../ds-dynamic-form-ui/models/date-picker/date-picker.model'; +import { isNotEmpty } from '../../../empty.util'; +import { DS_DATE_PICKER_SEPARATOR } from '../ds-dynamic-form-ui/models/date-picker/date-picker.component'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; + +export class DateFieldParser extends FieldParser { + + public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { + let malformedDate = false; + const inputDateModelConfig: DynamicDatePickerModelConfig = this.initModel(null, label); + + inputDateModelConfig.toggleIcon = 'fa fa-calendar'; + this.setValues(inputDateModelConfig as any, fieldValue); + // Init Data and validity check + if (isNotEmpty(inputDateModelConfig.value)) { + const value = inputDateModelConfig.value.toString(); + if (value.length >= 4) { + const valuesArray = value.split(DS_DATE_PICKER_SEPARATOR); + if (valuesArray.length < 4) { + for (let i = 0; i < valuesArray.length; i++) { + const len = i === 0 ? 4 : 2; + if (valuesArray[i].length !== len) { + malformedDate = true; + } + } + } + } + + } + const dateModel = new DynamicDsDatePickerModel(inputDateModelConfig); + dateModel.malformedDate = malformedDate; + return dateModel; + } +} diff --git a/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts b/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts new file mode 100644 index 0000000000..5dfdcfa5ce --- /dev/null +++ b/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts @@ -0,0 +1,59 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { DropdownFieldParser } from './dropdown-field-parser'; +import { DynamicScrollableDropdownModel } from '../ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; +import { ParserOptions } from './parser-options'; + +describe('DropdownFieldParser test suite', () => { + let field: FormFieldModel; + + const initFormValues = {}; + const parserOptions: ParserOptions = { + readOnly: false, + submissionScope: 'testScopeUUID', + authorityUuid: null + }; + + beforeEach(() => { + field = { + input: { + type: 'dropdown' + }, + label: 'Type', + mandatory: 'false', + repeatable: false, + hints: 'Select the tyupe.', + selectableMetadata: [ + { + metadata: 'type', + authority: 'common_types_dataset', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel; + + }); + + it('should init parser properly', () => { + const parser = new DropdownFieldParser(field, initFormValues, parserOptions); + + expect(parser instanceof DropdownFieldParser).toBe(true); + }); + + it('should return a DynamicScrollableDropdownModel object when repeatable option is false', () => { + const parser = new DropdownFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DynamicScrollableDropdownModel).toBe(true); + }); + + it('should throw when authority is not passed', () => { + field.selectableMetadata[0].authority = null; + const parser = new DropdownFieldParser(field, initFormValues, parserOptions); + + expect(() => parser.parse()) + .toThrow(); + }); + +}); diff --git a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts new file mode 100644 index 0000000000..279f78f721 --- /dev/null +++ b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts @@ -0,0 +1,35 @@ +import { FieldParser } from './field-parser'; +import { DynamicFormControlLayout, } from '@ng-dynamic-forms/core'; +import { + DynamicScrollableDropdownModel, + DynamicScrollableDropdownModelConfig +} from '../ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; +import { isNotEmpty } from '../../../empty.util'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; + +export class DropdownFieldParser extends FieldParser { + + public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { + const dropdownModelConfig: DynamicScrollableDropdownModelConfig = this.initModel(null, label); + let layout: DynamicFormControlLayout; + + if (isNotEmpty(this.configData.selectableMetadata[0].authority)) { + this.setAuthorityOptions(dropdownModelConfig, this.parserOptions.authorityUuid); + if (isNotEmpty(fieldValue)) { + dropdownModelConfig.value = fieldValue; + } + layout = { + element: { + control: 'col' + }, + grid: { + host: 'col' + } + }; + const dropdownModel = new DynamicScrollableDropdownModel(dropdownModelConfig, layout); + return dropdownModel; + } else { + throw Error(`Authority name is not available. Please checks form configuration file.`); + } + } +} diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts new file mode 100644 index 0000000000..f37b3868f3 --- /dev/null +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -0,0 +1,307 @@ +import { hasValue, isNotEmpty, isNotNull, isNotUndefined } from '../../../empty.util'; +import { FormFieldModel } from '../models/form-field.model'; + +import { uniqueId } from 'lodash'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { + DynamicRowArrayModel, + DynamicRowArrayModelConfig +} from '../ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; +import { DsDynamicInputModel, DsDynamicInputModelConfig } from '../ds-dynamic-form-ui/models/ds-dynamic-input.model'; +import { DynamicFormControlLayout } from '@ng-dynamic-forms/core'; +import { setLayout } from './parser.utils'; +import { AuthorityOptions } from '../../../../core/integration/models/authority-options.model'; +import { ParserOptions } from './parser-options'; + +export abstract class FieldParser { + + protected fieldId: string; + + constructor(protected configData: FormFieldModel, protected initFormValues, protected parserOptions: ParserOptions) { + } + + public abstract modelFactory(fieldValue?: FormFieldMetadataValueObject, label?: boolean): any; + + public parse() { + if (((this.getInitValueCount() > 1 && !this.configData.repeatable) || (this.configData.repeatable)) + && (this.configData.input.type !== 'list') + && (this.configData.input.type !== 'tag') + && (this.configData.input.type !== 'group') + ) { + let arrayCounter = 0; + let fieldArrayCounter = 0; + + const config = { + id: uniqueId() + '_array', + label: this.configData.label, + initialCount: this.getInitArrayIndex(), + notRepeteable: !this.configData.repeatable, + groupFactory: () => { + let model; + if ((arrayCounter === 0)) { + model = this.modelFactory(); + arrayCounter++; + } else { + const fieldArrayOfValueLenght = this.getInitValueCount(arrayCounter - 1); + let fieldValue = null; + if (fieldArrayOfValueLenght > 0) { + fieldValue = this.getInitFieldValue(arrayCounter - 1, fieldArrayCounter++); + if (fieldArrayCounter === fieldArrayOfValueLenght) { + fieldArrayCounter = 0; + arrayCounter++; + } + } + model = this.modelFactory(fieldValue, false); + } + setLayout(model, 'element', 'host', 'col'); + if (model.hasLanguages) { + setLayout(model, 'grid', 'control', 'col'); + } + return [model]; + } + } as DynamicRowArrayModelConfig; + + const layout: DynamicFormControlLayout = { + grid: { + group: 'form-row' + } + }; + + return new DynamicRowArrayModel(config, layout); + + } else { + const model = this.modelFactory(this.getInitFieldValue()); + if (model.hasLanguages) { + setLayout(model, 'grid', 'control', 'col'); + } + return model; + } + } + + protected getInitValueCount(index = 0, fieldId?): number { + const fieldIds = fieldId || this.getAllFieldIds(); + if (isNotEmpty(this.initFormValues) && isNotNull(fieldIds) && fieldIds.length === 1 && this.initFormValues.hasOwnProperty(fieldIds[0])) { + return this.initFormValues[fieldIds[0]].length; + } else if (isNotEmpty(this.initFormValues) && isNotNull(fieldIds) && fieldIds.length > 1) { + const values = []; + fieldIds.forEach((id) => { + if (this.initFormValues.hasOwnProperty(id)) { + values.push(this.initFormValues[id].length); + } + }); + return values[index]; + } else { + return 0; + } + } + + protected getInitGroupValues(): FormFieldMetadataValueObject[] { + const fieldIds = this.getAllFieldIds(); + if (isNotEmpty(this.initFormValues) && isNotNull(fieldIds) && fieldIds.length === 1 && this.initFormValues.hasOwnProperty(fieldIds[0])) { + return this.initFormValues[fieldIds[0]]; + } + } + + protected getInitFieldValues(fieldId): FormFieldMetadataValueObject[] { + if (isNotEmpty(this.initFormValues) && isNotNull(fieldId) && this.initFormValues.hasOwnProperty(fieldId)) { + return this.initFormValues[fieldId]; + } + } + + protected getInitFieldValue(outerIndex = 0, innerIndex = 0, fieldId?): FormFieldMetadataValueObject { + const fieldIds = fieldId || this.getAllFieldIds(); + if (isNotEmpty(this.initFormValues) + && isNotNull(fieldIds) + && fieldIds.length === 1 + && this.initFormValues.hasOwnProperty(fieldIds[outerIndex]) + && this.initFormValues[fieldIds[outerIndex]].length > innerIndex) { + return this.initFormValues[fieldIds[outerIndex]][innerIndex]; + } else if (isNotEmpty(this.initFormValues) && isNotNull(fieldIds) && fieldIds.length > 1) { + const values: FormFieldMetadataValueObject[] = []; + fieldIds.forEach((id) => { + if (this.initFormValues.hasOwnProperty(id)) { + const valueObj: FormFieldMetadataValueObject = Object.assign(new FormFieldMetadataValueObject(), this.initFormValues[id][innerIndex]); + valueObj.metadata = id; + // valueObj.value = this.initFormValues[id][innerIndex]; + values.push(valueObj); + } + }); + return values[outerIndex]; + } else { + return null; + } + } + + protected getInitArrayIndex() { + const fieldIds: any = this.getAllFieldIds(); + if (isNotEmpty(this.initFormValues) && isNotNull(fieldIds) && fieldIds.length === 1 && this.initFormValues.hasOwnProperty(fieldIds)) { + return this.initFormValues[fieldIds].length; + } else if (isNotEmpty(this.initFormValues) && isNotNull(fieldIds) && fieldIds.length > 1) { + let counter = 0; + fieldIds.forEach((id) => { + if (this.initFormValues.hasOwnProperty(id)) { + counter = counter + this.initFormValues[id].length; + } + }); + return (counter === 0) ? 1 : counter; + } else { + return 1; + } + } + + protected getFieldId(): string { + const ids = this.getAllFieldIds(); + return isNotNull(ids) ? ids[0] : null; + } + + protected getAllFieldIds(): string[] { + if (Array.isArray(this.configData.selectableMetadata)) { + if (this.configData.selectableMetadata.length === 1) { + return [this.configData.selectableMetadata[0].metadata]; + } else { + const ids = []; + this.configData.selectableMetadata.forEach((entry) => ids.push(entry.metadata)); + return ids; + } + } else { + return null; + } + } + + protected initModel(id?: string, label = true, labelEmpty = false, setErrors = true) { + + const controlModel = Object.create(null); + + // Sets input ID + this.fieldId = id ? id : this.getFieldId(); + + // Sets input name (with the original field's id value) + controlModel.name = this.fieldId; + + // input ID doesn't allow dots, so replace them + controlModel.id = (this.fieldId).replace(/\./g, '_'); + + // Set read only option + controlModel.readOnly = this.parserOptions.readOnly; + controlModel.disabled = this.parserOptions.readOnly; + + // Set label + this.setLabel(controlModel, label, labelEmpty); + + controlModel.placeholder = this.configData.label; + + if (this.configData.mandatory && setErrors) { + this.markAsRequired(controlModel); + } + + if (this.hasRegex()) { + this.addPatternValidator(controlModel); + } + + // Available Languages + if (this.configData.languageCodes && this.configData.languageCodes.length > 0) { + (controlModel as DsDynamicInputModel).languageCodes = this.configData.languageCodes; + } + + return controlModel; + } + + protected hasRegex() { + return hasValue(this.configData.input.regex); + } + + protected addPatternValidator(controlModel) { + const regex = new RegExp(this.configData.input.regex); + controlModel.validators = Object.assign({}, controlModel.validators, {pattern: regex}); + controlModel.errorMessages = Object.assign( + {}, + controlModel.errorMessages, + {pattern: 'error.validation.pattern'}); + + } + + protected markAsRequired(controlModel) { + controlModel.required = true; + controlModel.validators = Object.assign({}, controlModel.validators, {required: null}); + controlModel.errorMessages = Object.assign( + {}, + controlModel.errorMessages, + {required: this.configData.mandatoryMessage}); + } + + protected setLabel(controlModel, label = true, labelEmpty = false) { + if (label) { + controlModel.label = (labelEmpty) ? ' ' : this.configData.label; + } + } + + protected setOptions(controlModel) { + // Checks if field has multiple values and sets options available + if (isNotUndefined(this.configData.selectableMetadata) && this.configData.selectableMetadata.length > 1) { + controlModel.options = []; + this.configData.selectableMetadata.forEach((option, key) => { + if (key === 0) { + controlModel.value = option.metadata; + } + controlModel.options.push({label: option.label, value: option.metadata}); + }); + } + } + + public setAuthorityOptions(controlModel, authorityUuid) { + if (isNotEmpty(this.configData.selectableMetadata[0].authority)) { + controlModel.authorityOptions = new AuthorityOptions( + this.configData.selectableMetadata[0].authority, + this.configData.selectableMetadata[0].metadata, + authorityUuid, + this.configData.selectableMetadata[0].closed + ) + } + } + + public setValues(modelConfig: DsDynamicInputModelConfig, fieldValue: any, forceValueAsObj: boolean = false, groupModel?: boolean) { + if (isNotEmpty(fieldValue)) { + if (groupModel) { + // Array, values is an array + modelConfig.value = this.getInitGroupValues(); + if (Array.isArray(modelConfig.value) && modelConfig.value.length > 0 && modelConfig.value[0].language) { + // Array Item has language, ex. AuthorityModel + modelConfig.language = modelConfig.value[0].language; + } + return; + } + + if (typeof fieldValue === 'object') { + modelConfig.language = fieldValue.language; + if (forceValueAsObj) { + modelConfig.value = fieldValue; + } else { + modelConfig.value = fieldValue.value; + } + // if (hasValue(fieldValue.language)) { + // // Instance of FormFieldLanguageValueObject + // modelConfig.value = fieldValue.value; + // } else if (hasValue(fieldValue.metadata)) { + // // Is a combobox field's value + // modelConfig.value = fieldValue.value; + // } else { + // // Instance of FormFieldMetadataValueObject + // modelConfig.value = fieldValue; + // } + } else { + if (forceValueAsObj) { + // If value isn't an instance of FormFieldMetadataValueObject instantiate it + modelConfig.value = new FormFieldMetadataValueObject(fieldValue); + } else { + if (typeof fieldValue === 'string') { + // Case only string + modelConfig.value = fieldValue; + } + } + } + } + + return modelConfig; + } + +} diff --git a/src/app/shared/form/builder/parsers/group-field-parser.spec.ts b/src/app/shared/form/builder/parsers/group-field-parser.spec.ts new file mode 100644 index 0000000000..abda8d7169 --- /dev/null +++ b/src/app/shared/form/builder/parsers/group-field-parser.spec.ts @@ -0,0 +1,111 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { GroupFieldParser } from './group-field-parser'; +import { DynamicGroupModel } from '../ds-dynamic-form-ui/models/dynamic-group/dynamic-group.model'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { ParserOptions } from './parser-options'; + +describe('GroupFieldParser test suite', () => { + let field: FormFieldModel; + let initFormValues = {}; + + const parserOptions: ParserOptions = { + readOnly: false, + submissionScope: 'testScopeUUID', + authorityUuid: 'WORKSPACE' + }; + + beforeEach(() => { + field = { + input: { + type: 'group' + }, + rows: [ + { + fields: [ + { + input: { + type: 'onebox' + }, + label: 'Author', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name of the author.', + selectableMetadata: [ + { + metadata: 'author' + } + ], + languageCodes: [] + }, + { + input: { + type: 'onebox' + }, + label: 'Affiliation', + mandatory: false, + repeatable: true, + hints: 'Enter the affiliation of the author.', + selectableMetadata: [ + { + metadata: 'affiliation' + } + ], + languageCodes: [] + } + ] + } + ], + label: 'Authors', + mandatory: 'true', + repeatable: false, + mandatoryMessage: 'Entering at least the first author is mandatory.', + hints: 'Enter the names of the authors of this item.', + selectableMetadata: [ + { + metadata: 'author' + } + ], + languageCodes: [] + } as FormFieldModel; + + }); + + it('should init parser properly', () => { + const parser = new GroupFieldParser(field, initFormValues, parserOptions); + + expect(parser instanceof GroupFieldParser).toBe(true); + }); + + it('should return a DynamicGroupModel object', () => { + const parser = new GroupFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DynamicGroupModel).toBe(true); + }); + + it('should throw when rows configuration is empty', () => { + field.rows = null; + const parser = new GroupFieldParser(field, initFormValues, parserOptions); + + expect(() => parser.parse()) + .toThrow(); + }); + + it('should set group init value properly', () => { + initFormValues = { + author: [new FormFieldMetadataValueObject('test author')], + affiliation: [new FormFieldMetadataValueObject('test affiliation')] + }; + const parser = new GroupFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + const expectedValue = [{ + author: new FormFieldMetadataValueObject('test author'), + affiliation: new FormFieldMetadataValueObject('test affiliation') + }]; + + expect(fieldModel.value).toEqual(expectedValue); + }); + +}); diff --git a/src/app/shared/form/builder/parsers/group-field-parser.ts b/src/app/shared/form/builder/parsers/group-field-parser.ts new file mode 100644 index 0000000000..9a7b2bc42d --- /dev/null +++ b/src/app/shared/form/builder/parsers/group-field-parser.ts @@ -0,0 +1,62 @@ +import { FieldParser } from './field-parser'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { FormFieldModel } from '../models/form-field.model'; +import { + DynamicGroupModel, + DynamicGroupModelConfig, + PLACEHOLDER_PARENT_METADATA +} from '../ds-dynamic-form-ui/models/dynamic-group/dynamic-group.model'; +import { isNotEmpty } from '../../../empty.util'; +import { FormRowModel } from '../../../../core/shared/config/config-submission-forms.model'; + +export class GroupFieldParser extends FieldParser { + + public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean) { + const modelConfiguration: DynamicGroupModelConfig = this.initModel(null, label); + + modelConfiguration.scopeUUID = this.parserOptions.authorityUuid; + modelConfiguration.submissionScope = this.parserOptions.submissionScope; + if (this.configData && this.configData.rows && this.configData.rows.length > 0) { + modelConfiguration.formConfiguration = this.configData.rows; + modelConfiguration.relationFields = []; + this.configData.rows.forEach((row: FormRowModel) => { + row.fields.forEach((field: FormFieldModel) => { + if (field.selectableMetadata[0].metadata === this.configData.selectableMetadata[0].metadata) { + if (!field.mandatory) { + // throw new Error(`Configuration not valid: Main field ${this.configData.selectableMetadata[0].metadata} may be mandatory`); + } + modelConfiguration.mandatoryField = this.configData.selectableMetadata[0].metadata; + } else { + modelConfiguration.relationFields.push(field.selectableMetadata[0].metadata); + } + }) + }); + } else { + throw new Error(`Configuration not valid: ${modelConfiguration.name}`); + } + + if (isNotEmpty(this.getInitGroupValues())) { + modelConfiguration.value = []; + const mandatoryFieldEntries: FormFieldMetadataValueObject[] = this.getInitFieldValues(modelConfiguration.mandatoryField); + mandatoryFieldEntries.forEach((entry, index) => { + const item = Object.create(null); + const listFields = [modelConfiguration.mandatoryField].concat(modelConfiguration.relationFields); + listFields.forEach((fieldId) => { + const value = this.getInitFieldValue(0, index, [fieldId]); + item[fieldId] = isNotEmpty(value) ? value : PLACEHOLDER_PARENT_METADATA; + }); + modelConfiguration.value.push(item); + }) + } + const cls = { + element: { + container: 'mb-3' + } + }; + + const model = new DynamicGroupModel(modelConfiguration, cls); + model.name = this.getFieldId(); + return model; + } + +} diff --git a/src/app/shared/form/builder/parsers/list-field-parser.spec.ts b/src/app/shared/form/builder/parsers/list-field-parser.spec.ts new file mode 100644 index 0000000000..b2fa0b2089 --- /dev/null +++ b/src/app/shared/form/builder/parsers/list-field-parser.spec.ts @@ -0,0 +1,75 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { ListFieldParser } from './list-field-parser'; +import { DynamicListCheckboxGroupModel } from '../ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model'; +import { DynamicListRadioGroupModel } from '../ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model'; +import { ParserOptions } from './parser-options'; + +describe('ListFieldParser test suite', () => { + let field: FormFieldModel; + let initFormValues = {}; + + const parserOptions: ParserOptions = { + readOnly: false, + submissionScope: 'testScopeUUID', + authorityUuid: null + }; + + beforeEach(() => { + field = { + input: { + type: 'list' + }, + label: 'Type', + mandatory: 'false', + repeatable: true, + hints: 'Select the type.', + selectableMetadata: [ + { + metadata: 'type', + authority: 'type_programme', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel; + + }); + + it('should init parser properly', () => { + const parser = new ListFieldParser(field, initFormValues, parserOptions); + + expect(parser instanceof ListFieldParser).toBe(true); + }); + + it('should return a DynamicListCheckboxGroupModel object when repeatable option is true', () => { + const parser = new ListFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DynamicListCheckboxGroupModel).toBe(true); + }); + + it('should return a DynamicListRadioGroupModel object when repeatable option is false', () => { + field.repeatable = false; + const parser = new ListFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DynamicListRadioGroupModel).toBe(true); + }); + + it('should set init value properly', () => { + initFormValues = { + type: [new FormFieldMetadataValueObject('test type')], + }; + const expectedValue = [new FormFieldMetadataValueObject('test type')]; + + const parser = new ListFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel.value).toEqual(expectedValue); + }); + +}); diff --git a/src/app/shared/form/builder/parsers/list-field-parser.ts b/src/app/shared/form/builder/parsers/list-field-parser.ts new file mode 100644 index 0000000000..273c9d1665 --- /dev/null +++ b/src/app/shared/form/builder/parsers/list-field-parser.ts @@ -0,0 +1,44 @@ +import { FieldParser } from './field-parser'; +import { isNotEmpty } from '../../../empty.util'; +import { IntegrationSearchOptions } from '../../../../core/integration/models/integration-options.model'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { DynamicListCheckboxGroupModel } from '../ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model'; +import { DynamicListRadioGroupModel } from '../ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model'; + +export class ListFieldParser extends FieldParser { + searchOptions: IntegrationSearchOptions; + + public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { + const listModelConfig = this.initModel(null, label); + listModelConfig.repeatable = this.configData.repeatable; + + if (this.configData.selectableMetadata[0].authority + && this.configData.selectableMetadata[0].authority.length > 0) { + + if (isNotEmpty(this.getInitGroupValues())) { + listModelConfig.value = []; + this.getInitGroupValues().forEach((value: any) => { + if (value instanceof FormFieldMetadataValueObject) { + listModelConfig.value.push(value); + } else { + const valueObj = new FormFieldMetadataValueObject(value); + listModelConfig.value.push(valueObj); + } + }); + } + this.setAuthorityOptions(listModelConfig, this.parserOptions.authorityUuid); + } + + let listModel; + if (listModelConfig.repeatable) { + listModelConfig.group = []; + listModel = new DynamicListCheckboxGroupModel(listModelConfig); + } else { + listModelConfig.options = []; + listModel = new DynamicListRadioGroupModel(listModelConfig); + } + + return listModel; + } + +} diff --git a/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts b/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts new file mode 100644 index 0000000000..c45d39d5bb --- /dev/null +++ b/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts @@ -0,0 +1,65 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { LookupFieldParser } from './lookup-field-parser'; +import { DynamicLookupModel } from '../ds-dynamic-form-ui/models/lookup/dynamic-lookup.model'; +import { ParserOptions } from './parser-options'; + +describe('LookupFieldParser test suite', () => { + let field: FormFieldModel; + let initFormValues = {}; + + const parserOptions: ParserOptions = { + readOnly: false, + submissionScope: 'testScopeUUID', + authorityUuid: null + }; + + beforeEach(() => { + field = { + input: { + type: 'lookup' + }, + label: 'Journal', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name of the journal where the item has been published, if any.', + selectableMetadata: [ + { + metadata: 'journal', + authority: 'JOURNALAuthority', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel; + + }); + + it('should init parser properly', () => { + const parser = new LookupFieldParser(field, initFormValues, parserOptions); + + expect(parser instanceof LookupFieldParser).toBe(true); + }); + + it('should return a DynamicLookupModel object when repeatable option is false', () => { + const parser = new LookupFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DynamicLookupModel).toBe(true); + }); + + it('should set init value properly', () => { + initFormValues = { + journal: [new FormFieldMetadataValueObject('test journal')], + }; + const expectedValue = new FormFieldMetadataValueObject('test journal'); + + const parser = new LookupFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel.value).toEqual(expectedValue); + }); + +}); diff --git a/src/app/shared/form/builder/parsers/lookup-field-parser.ts b/src/app/shared/form/builder/parsers/lookup-field-parser.ts new file mode 100644 index 0000000000..9e9c434c4f --- /dev/null +++ b/src/app/shared/form/builder/parsers/lookup-field-parser.ts @@ -0,0 +1,19 @@ +import { FieldParser } from './field-parser'; +import { DynamicLookupModel, DynamicLookupModelConfig } from '../ds-dynamic-form-ui/models/lookup/dynamic-lookup.model'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; + +export class LookupFieldParser extends FieldParser { + + public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { + if (this.configData.selectableMetadata[0].authority) { + const lookupModelConfig: DynamicLookupModelConfig = this.initModel(null, label); + + this.setAuthorityOptions(lookupModelConfig, this.parserOptions.authorityUuid); + + this.setValues(lookupModelConfig, fieldValue, true); + + return new DynamicLookupModel(lookupModelConfig); + + } + } +} diff --git a/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts b/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts new file mode 100644 index 0000000000..b324ba7a7e --- /dev/null +++ b/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts @@ -0,0 +1,67 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { LookupFieldParser } from './lookup-field-parser'; +import { DynamicLookupModel } from '../ds-dynamic-form-ui/models/lookup/dynamic-lookup.model'; +import { LookupNameFieldParser } from './lookup-name-field-parser'; +import { DynamicLookupNameModel } from '../ds-dynamic-form-ui/models/lookup/dynamic-lookup-name.model'; +import { ParserOptions } from './parser-options'; + +describe('LookupNameFieldParser test suite', () => { + let field: FormFieldModel; + let initFormValues = {}; + + const parserOptions: ParserOptions = { + readOnly: false, + submissionScope: 'testScopeUUID', + authorityUuid: null + }; + + beforeEach(() => { + field = { + input: { + type: 'lookup-name' + }, + label: 'Author', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name of the author.', + selectableMetadata: [ + { + metadata: 'author', + authority: 'RPAuthority', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel; + + }); + + it('should init parser properly', () => { + const parser = new LookupNameFieldParser(field, initFormValues, parserOptions); + + expect(parser instanceof LookupNameFieldParser).toBe(true); + }); + + it('should return a DynamicLookupNameModel object when repeatable option is false', () => { + const parser = new LookupNameFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DynamicLookupNameModel).toBe(true); + }); + + it('should set init value properly', () => { + initFormValues = { + author: [new FormFieldMetadataValueObject('test author')], + }; + const expectedValue = new FormFieldMetadataValueObject('test author'); + + const parser = new LookupNameFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel.value).toEqual(expectedValue); + }); + +}); diff --git a/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts b/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts new file mode 100644 index 0000000000..684e06bcb6 --- /dev/null +++ b/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts @@ -0,0 +1,22 @@ +import { FieldParser } from './field-parser'; +import { + DynamicLookupNameModel, + DynamicLookupNameModelConfig +} from '../ds-dynamic-form-ui/models/lookup/dynamic-lookup-name.model'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; + +export class LookupNameFieldParser extends FieldParser { + + public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { + if (this.configData.selectableMetadata[0].authority) { + const lookupModelConfig: DynamicLookupNameModelConfig = this.initModel(null, label); + + this.setAuthorityOptions(lookupModelConfig, this.parserOptions.authorityUuid); + + this.setValues(lookupModelConfig, fieldValue, true); + + return new DynamicLookupNameModel(lookupModelConfig); + } + } + +} diff --git a/src/app/shared/form/builder/parsers/name-field-parser.spec.ts b/src/app/shared/form/builder/parsers/name-field-parser.spec.ts new file mode 100644 index 0000000000..889244e8f2 --- /dev/null +++ b/src/app/shared/form/builder/parsers/name-field-parser.spec.ts @@ -0,0 +1,106 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { NameFieldParser } from './name-field-parser'; +import { DynamicConcatModel } from '../ds-dynamic-form-ui/models/ds-dynamic-concat.model'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { ParserOptions } from './parser-options'; + +describe('NameFieldParser test suite', () => { + let field1: FormFieldModel; + let field2: FormFieldModel; + let field3: FormFieldModel; + let initFormValues: any = {}; + + const parserOptions: ParserOptions = { + readOnly: false, + submissionScope: 'testScopeUUID', + authorityUuid: null + }; + + beforeEach(() => { + field1 = { + input: { + type: 'name' + }, + label: 'Name', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name.', + selectableMetadata: [ + { + metadata: 'name', + } + ], + languageCodes: [] + } as FormFieldModel; + + field2 = { + hints: 'If the item has any identification numbers or codes associated with↵ it, please enter the types and the actual numbers or codes.', + input: {type: 'onebox'}, + label: 'Identifiers', + languageCodes: [], + mandatory: 'false', + repeatable: false, + selectableMetadata: [ + {metadata: 'dc.identifier.issn', label: 'ISSN'}, + {metadata: 'dc.identifier.other', label: 'Other'}, + {metadata: 'dc.identifier.ismn', label: 'ISMN'}, + {metadata: 'dc.identifier.govdoc', label: 'Gov\'t Doc #'}, + {metadata: 'dc.identifier.uri', label: 'URI'}, + {metadata: 'dc.identifier.isbn', label: 'ISBN'}, + {metadata: 'dc.identifier.doi', label: 'DOI'}, + {metadata: 'dc.identifier.pmid', label: 'PubMed ID'}, + {metadata: 'dc.identifier.arxiv', label: 'arXiv'} + ] + } as FormFieldModel; + + field3 = { + input: {type: 'onebox'}, + label: 'Title', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name of the events, if any.', + selectableMetadata: [ + { + metadata: 'title', + } + ], + languageCodes: [] + } as FormFieldModel; + }); + + it('should init parser properly', () => { + const parser = new NameFieldParser(field1, initFormValues, parserOptions); + + expect(parser instanceof NameFieldParser).toBe(true); + }); + + it('should return a DynamicConcatModel object when repeatable option is false', () => { + const parser = new NameFieldParser(field2, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DynamicConcatModel).toBe(true); + }); + + it('should return a DynamicConcatModel object with the correct separator', () => { + const parser = new NameFieldParser(field2, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect((fieldModel as DynamicConcatModel).separator).toBe(', '); + }); + + it('should set init value properly', () => { + initFormValues = { + name: [new FormFieldMetadataValueObject('test, name')], + }; + const expectedValue = new FormFieldMetadataValueObject('test, name'); + + const parser = new NameFieldParser(field1, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel.value).toEqual(expectedValue); + }); + +}); diff --git a/src/app/shared/form/builder/parsers/name-field-parser.ts b/src/app/shared/form/builder/parsers/name-field-parser.ts new file mode 100644 index 0000000000..896b3cc478 --- /dev/null +++ b/src/app/shared/form/builder/parsers/name-field-parser.ts @@ -0,0 +1,10 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { ConcatFieldParser } from './concat-field-parser'; +import { ParserOptions } from './parser-options'; + +export class NameFieldParser extends ConcatFieldParser { + + constructor(protected configData: FormFieldModel, protected initFormValues, protected parserOptions: ParserOptions) { + super(configData, initFormValues, parserOptions, ',', 'form.last-name', 'form.first-name'); + } +} diff --git a/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts b/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts new file mode 100644 index 0000000000..89c576bf3a --- /dev/null +++ b/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts @@ -0,0 +1,102 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { OneboxFieldParser } from './onebox-field-parser'; +import { DynamicQualdropModel } from '../ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; +import { DynamicTypeaheadModel } from '../ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.model'; +import { DsDynamicInputModel } from '../ds-dynamic-form-ui/models/ds-dynamic-input.model'; +import { ParserOptions } from './parser-options'; + +describe('OneboxFieldParser test suite', () => { + let field1: FormFieldModel; + let field2: FormFieldModel; + let field3: FormFieldModel; + + const initFormValues = {}; + const parserOptions: ParserOptions = { + readOnly: false, + submissionScope: 'testScopeUUID', + authorityUuid: null + }; + + beforeEach(() => { + field1 = { + input: {type: 'onebox'}, + label: 'Title', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name of the events, if any.', + selectableMetadata: [ + { + metadata: 'title', + authority: 'EVENTAuthority', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel; + + field2 = { + hints: 'If the item has any identification numbers or codes associated with↵ it, please enter the types and the actual numbers or codes.', + input: {type: 'onebox'}, + label: 'Identifiers', + languageCodes: [], + mandatory: 'false', + repeatable: false, + selectableMetadata: [ + {metadata: 'dc.identifier.issn', label: 'ISSN'}, + {metadata: 'dc.identifier.other', label: 'Other'}, + {metadata: 'dc.identifier.ismn', label: 'ISMN'}, + {metadata: 'dc.identifier.govdoc', label: 'Gov\'t Doc #'}, + {metadata: 'dc.identifier.uri', label: 'URI'}, + {metadata: 'dc.identifier.isbn', label: 'ISBN'}, + {metadata: 'dc.identifier.doi', label: 'DOI'}, + {metadata: 'dc.identifier.pmid', label: 'PubMed ID'}, + {metadata: 'dc.identifier.arxiv', label: 'arXiv'} + ] + } as FormFieldModel; + + field3 = { + input: {type: 'onebox'}, + label: 'Title', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name of the events, if any.', + selectableMetadata: [ + { + metadata: 'title', + } + ], + languageCodes: [] + } as FormFieldModel; + }); + + it('should init parser properly', () => { + const parser = new OneboxFieldParser(field1, initFormValues, parserOptions); + + expect(parser instanceof OneboxFieldParser).toBe(true); + }); + + it('should return a DynamicQualdropModel object when selectableMetadata is multiple', () => { + const parser = new OneboxFieldParser(field2, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DynamicQualdropModel).toBe(true); + }); + + it('should return a DsDynamicInputModel object when selectableMetadata is not multiple', () => { + const parser = new OneboxFieldParser(field3, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DsDynamicInputModel).toBe(true); + }); + + it('should return a DynamicTypeaheadModel object when selectableMetadata has authority', () => { + const parser = new OneboxFieldParser(field1, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DynamicTypeaheadModel).toBe(true); + }); + +}); diff --git a/src/app/shared/form/builder/parsers/onebox-field-parser.ts b/src/app/shared/form/builder/parsers/onebox-field-parser.ts new file mode 100644 index 0000000000..8949972918 --- /dev/null +++ b/src/app/shared/form/builder/parsers/onebox-field-parser.ts @@ -0,0 +1,85 @@ +import { DynamicSelectModel, DynamicSelectModelConfig } from '@ng-dynamic-forms/core'; + +import { FieldParser } from './field-parser'; +import { + DsDynamicQualdropModelConfig, + DynamicQualdropModel, + QUALDROP_GROUP_SUFFIX, + QUALDROP_METADATA_SUFFIX, + QUALDROP_VALUE_SUFFIX +} from '../ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { isNotEmpty } from '../../../empty.util'; +import { DsDynamicInputModel, DsDynamicInputModelConfig } from '../ds-dynamic-form-ui/models/ds-dynamic-input.model'; +import { + DsDynamicTypeaheadModelConfig, + DynamicTypeaheadModel +} from '../ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.model'; + +export class OneboxFieldParser extends FieldParser { + + public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { + if (this.configData.selectableMetadata.length > 1) { + // Case ComboBox + const clsGroup = { + element: { + control: 'form-row', + } + }; + + const clsSelect = { + element: { + control: 'input-group-addon ds-form-input-addon', + }, + grid: { + host: 'col-sm-4 pr-0' + } + }; + + const clsInput = { + element: { + control: 'ds-form-input-value', + }, + grid: { + host: 'col-sm-8 pl-0' + } + }; + + const newId = this.configData.selectableMetadata[0].metadata + .split('.') + .slice(0, this.configData.selectableMetadata[0].metadata.split('.').length - 1) + .join('.'); + + const inputSelectGroup: DsDynamicQualdropModelConfig = Object.create(null); + inputSelectGroup.id = newId.replace(/\./g, '_') + QUALDROP_GROUP_SUFFIX; + inputSelectGroup.group = []; + inputSelectGroup.legend = this.configData.label; + + const selectModelConfig: DynamicSelectModelConfig = this.initModel(newId + QUALDROP_METADATA_SUFFIX, label); + this.setOptions(selectModelConfig); + if (isNotEmpty(fieldValue)) { + selectModelConfig.value = fieldValue.metadata; + } + inputSelectGroup.group.push(new DynamicSelectModel(selectModelConfig, clsSelect)); + + const inputModelConfig: DsDynamicInputModelConfig = this.initModel(newId + QUALDROP_VALUE_SUFFIX, label, true); + this.setValues(inputModelConfig, fieldValue); + + inputSelectGroup.readOnly = selectModelConfig.disabled && inputModelConfig.readOnly; + inputSelectGroup.group.push(new DsDynamicInputModel(inputModelConfig, clsInput)); + + return new DynamicQualdropModel(inputSelectGroup, clsGroup); + } else if (this.configData.selectableMetadata[0].authority) { + const typeaheadModelConfig: DsDynamicTypeaheadModelConfig = this.initModel(null, label); + this.setAuthorityOptions(typeaheadModelConfig, this.parserOptions.authorityUuid); + this.setValues(typeaheadModelConfig, fieldValue, true); + const typeaheadModel = new DynamicTypeaheadModel(typeaheadModelConfig); + return typeaheadModel; + } else { + const inputModelConfig: DsDynamicInputModelConfig = this.initModel(null, label); + this.setValues(inputModelConfig, fieldValue); + const inputModel = new DsDynamicInputModel(inputModelConfig); + return inputModel; + } + } +} diff --git a/src/app/shared/form/builder/parsers/parser-factory.ts b/src/app/shared/form/builder/parsers/parser-factory.ts new file mode 100644 index 0000000000..da5bee0be2 --- /dev/null +++ b/src/app/shared/form/builder/parsers/parser-factory.ts @@ -0,0 +1,58 @@ +import { ParserType } from './parser-type'; +import { GenericConstructor } from '../../../../core/shared/generic-constructor'; +import { FieldParser } from './field-parser'; +import { DateFieldParser } from './date-field-parser'; +import { DropdownFieldParser } from './dropdown-field-parser'; +import { GroupFieldParser } from './group-field-parser'; +import { ListFieldParser } from './list-field-parser'; +import { LookupFieldParser } from './lookup-field-parser'; +import { LookupNameFieldParser } from './lookup-name-field-parser'; +import { OneboxFieldParser } from './onebox-field-parser'; +import { NameFieldParser } from './name-field-parser'; +import { SeriesFieldParser } from './series-field-parser'; +import { TagFieldParser } from './tag-field-parser'; +import { TextareaFieldParser } from './textarea-field-parser'; + +export class ParserFactory { + public static getConstructor(type: ParserType): GenericConstructor { + switch (type) { + case ParserType.Date: { + return DateFieldParser + } + case ParserType.Dropdown: { + return DropdownFieldParser + } + case ParserType.Group: { + return GroupFieldParser + } + case ParserType.List: { + return ListFieldParser + } + case ParserType.Lookup: { + return LookupFieldParser + } + case ParserType.LookupName: { + return LookupNameFieldParser + } + case ParserType.Onebox: { + return OneboxFieldParser + } + case ParserType.Name: { + return NameFieldParser + } + case ParserType.Series: { + return SeriesFieldParser + } + case ParserType.Tag: { + return TagFieldParser + } + case ParserType.Textarea: { + return TextareaFieldParser + } + + default: { + return undefined; + } + } + } +} diff --git a/src/app/shared/form/builder/parsers/parser-options.ts b/src/app/shared/form/builder/parsers/parser-options.ts new file mode 100644 index 0000000000..f96ce0f2f3 --- /dev/null +++ b/src/app/shared/form/builder/parsers/parser-options.ts @@ -0,0 +1,5 @@ +export interface ParserOptions { + readOnly: boolean; + submissionScope: string; + authorityUuid: string +} diff --git a/src/app/shared/form/builder/parsers/parser-type.ts b/src/app/shared/form/builder/parsers/parser-type.ts new file mode 100644 index 0000000000..d6bddf867c --- /dev/null +++ b/src/app/shared/form/builder/parsers/parser-type.ts @@ -0,0 +1,13 @@ +export enum ParserType { + Date = 'date', + Dropdown = 'dropdown', + Group = 'group', + List = 'list', + Lookup = 'lookup', + LookupName = 'lookup-name', + Onebox = 'onebox', + Name = 'name', + Series = 'series', + Tag = 'tag', + Textarea = 'textarea' +} diff --git a/src/app/shared/form/builder/parsers/parser.utils.ts b/src/app/shared/form/builder/parsers/parser.utils.ts new file mode 100644 index 0000000000..734ec4b83a --- /dev/null +++ b/src/app/shared/form/builder/parsers/parser.utils.ts @@ -0,0 +1,17 @@ +import { isNull, isUndefined } from '../../../empty.util'; +import { DynamicFormControlLayout, DynamicFormControlLayoutConfig } from '@ng-dynamic-forms/core'; + +export function setLayout(model: any, controlLayout: string, controlLayoutConfig: string, style: string) { + if (isNull(model.layout)) { + model.layout = {} as DynamicFormControlLayout; + model.layout[controlLayout] = {} as DynamicFormControlLayoutConfig; + model.layout[controlLayout][controlLayoutConfig] = style; + } else if (isUndefined(model.layout[controlLayout])) { + model.layout[controlLayout] = {} as DynamicFormControlLayoutConfig; + model.layout[controlLayout][controlLayoutConfig] = style; + } else if (isUndefined(model.layout[controlLayout][controlLayoutConfig])) { + model.layout[controlLayout][controlLayoutConfig] = style; + } else { + model.layout[controlLayout][controlLayoutConfig] = model.layout[controlLayout][controlLayoutConfig].concat(` ${style}`); + } +} diff --git a/src/app/shared/form/builder/parsers/row-parser.spec.ts b/src/app/shared/form/builder/parsers/row-parser.spec.ts new file mode 100644 index 0000000000..54a6bc6a27 --- /dev/null +++ b/src/app/shared/form/builder/parsers/row-parser.spec.ts @@ -0,0 +1,403 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { FormRowModel } from '../../../../core/shared/config/config-submission-forms.model'; +import { RowParser } from './row-parser'; +import { DynamicRowGroupModel } from '../ds-dynamic-form-ui/models/ds-dynamic-row-group-model'; +import { DynamicRowArrayModel } from '../ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; + +describe('RowParser test suite', () => { + + let row1: FormRowModel; + let row2: FormRowModel; + let row3: FormRowModel; + let row4: FormRowModel; + let row5: FormRowModel; + let row6: FormRowModel; + let row7: FormRowModel; + let row8: FormRowModel; + let row9: FormRowModel; + let row10: FormRowModel; + + const scopeUUID = 'testScopeUUID'; + const initFormValues = {}; + const submissionScope = 'WORKSPACE'; + const readOnly = false; + + beforeEach(() => { + row1 = { + fields: [ + { + input: {type: 'lookup'}, + label: 'Journal', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name of the journal where the item has been\n\t\t\t\t\tpublished, if any.', + selectableMetadata: [ + { + metadata: 'journal', + authority: 'JOURNALAuthority', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel, + { + input: {type: 'onebox'}, + label: 'Issue', + mandatory: 'false', + repeatable: false, + hints: ' Enter issue number.', + selectableMetadata: [ + { + metadata: 'issue' + } + ], + languageCodes: [] + } as FormFieldModel, + { + input: {type: 'name'}, + label: 'Name', + mandatory: 'false', + repeatable: false, + hints: 'Enter full name.', + selectableMetadata: [ + { + metadata: 'name' + } + ], + languageCodes: [] + } as FormFieldModel + ] + } as FormRowModel; + row2 = { + fields: [ + { + input: { + type: 'onebox', + regex: '^[a-zA-Z0-9]+$' + }, + label: 'Title', + mandatory: 'false', + repeatable: true, + hints: 'Enter the name of the events, if any.', + selectableMetadata: [ + { + metadata: 'title', + authority: 'EVENTAuthority', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel + ] + } as FormRowModel; + + row3 = { + fields: [ + { + input: {type: 'onebox'}, + label: 'Title', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name of the events, if any.', + selectableMetadata: [ + { + metadata: 'title', + authority: 'EVENTAuthority', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel, + { + input: {type: 'onebox'}, + label: 'Other title', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name of the events, if any.', + scope: 'WORKFLOW', + selectableMetadata: [ + { + metadata: 'otherTitle', + authority: 'EVENTAuthority', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel + ] + } as FormRowModel; + + row4 = { + fields: [ + { + input: { + type: 'dropdown' + }, + label: 'Type', + mandatory: 'false', + repeatable: false, + hints: 'Select the tyupe.', + selectableMetadata: [ + { + metadata: 'type', + authority: 'common_types_dataset', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel, + { + input: {type: 'series'}, + label: 'Series/Report No.', + mandatory: 'false', + repeatable: false, + hints: 'Enter the series and number assigned to this item by your community.', + selectableMetadata: [ + { + metadata: 'series', + } + ], + languageCodes: [] + } as FormFieldModel + ] + } as FormRowModel; + + row5 = { + fields: [ + { + input: { + type: 'lookup-name' + }, + label: 'Author', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name of the author.', + selectableMetadata: [ + { + metadata: 'author', + authority: 'RPAuthority', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel + ] + } as FormRowModel; + + row6 = { + fields: [ + { + input: { + type: 'list' + }, + label: 'Type', + mandatory: 'false', + repeatable: true, + hints: 'Select the type.', + selectableMetadata: [ + { + metadata: 'type', + authority: 'type_programme', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel + ] + } as FormRowModel; + + row7 = { + fields: [ + { + input: { + type: 'date' + }, + label: 'Date of Issue.', + mandatory: 'true', + repeatable: false, + hints: 'Please give the date of previous publication or public distribution. You can leave out the day and/or month if they aren\'t applicable.', + mandatoryMessage: 'You must enter at least the year.', + selectableMetadata: [ + { + metadata: 'date', + } + ], + languageCodes: [] + } as FormFieldModel + ] + } as FormRowModel; + + row8 = { + fields: [ + { + input: { + type: 'tag' + }, + label: 'Keywords', + mandatory: 'false', + repeatable: false, + hints: 'Local controlled vocabulary.', + selectableMetadata: [ + { + metadata: 'subject', + authority: 'JOURNALAuthority', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel + ] + } as FormRowModel; + + row9 = { + fields: [ + { + input: { + type: 'textarea' + }, + label: 'Description', + mandatory: 'false', + repeatable: false, + hints: 'Enter a description.', + selectableMetadata: [ + { + metadata: 'description' + } + ], + languageCodes: [] + } as FormFieldModel + ] + } as FormRowModel; + + row10 = { + fields: [ + { + input: { + type: 'group' + }, + rows: [ + { + fields: [ + { + input: { + type: 'onebox' + }, + label: 'Author', + mandatory: 'false', + repeatable: false, + hints: 'Enter the name of the author.', + selectableMetadata: [ + { + metadata: 'author' + } + ], + languageCodes: [] + }, + { + input: { + type: 'onebox' + }, + label: 'Affiliation', + mandatory: false, + repeatable: true, + hints: 'Enter the affiliation of the author.', + selectableMetadata: [ + { + metadata: 'affiliation' + } + ], + languageCodes: [] + } + ] + } + ], + label: 'Authors', + mandatory: 'true', + repeatable: false, + mandatoryMessage: 'Entering at least the first author is mandatory.', + hints: 'Enter the names of the authors of this item.', + selectableMetadata: [ + { + metadata: 'author' + } + ], + languageCodes: [] + } as FormFieldModel + ] + } as FormRowModel; + }); + + it('should init parser properly', () => { + let parser = new RowParser(row1, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(parser instanceof RowParser).toBe(true); + + parser = new RowParser(row2, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(parser instanceof RowParser).toBe(true); + + parser = new RowParser(row3, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(parser instanceof RowParser).toBe(true); + + parser = new RowParser(row4, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(parser instanceof RowParser).toBe(true); + + parser = new RowParser(row5, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(parser instanceof RowParser).toBe(true); + + parser = new RowParser(row6, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(parser instanceof RowParser).toBe(true); + + parser = new RowParser(row7, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(parser instanceof RowParser).toBe(true); + + parser = new RowParser(row8, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(parser instanceof RowParser).toBe(true); + + parser = new RowParser(row9, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(parser instanceof RowParser).toBe(true); + + parser = new RowParser(row10, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(parser instanceof RowParser).toBe(true); + }); + + it('should return a DynamicRowGroupModel object', () => { + const parser = new RowParser(row1, scopeUUID, initFormValues, submissionScope, readOnly); + + const rowModel = parser.parse(); + + expect(rowModel instanceof DynamicRowGroupModel).toBe(true); + }); + + it('should return a row with three fields', () => { + const parser = new RowParser(row1, scopeUUID, initFormValues, submissionScope, readOnly); + + const rowModel = parser.parse(); + + expect((rowModel as DynamicRowGroupModel).group.length).toBe(3); + }); + + it('should return a DynamicRowArrayModel object', () => { + const parser = new RowParser(row2, scopeUUID, initFormValues, submissionScope, readOnly); + + const rowModel = parser.parse(); + + expect(rowModel instanceof DynamicRowArrayModel).toBe(true); + }); + + it('should return a row that contains only scoped fields', () => { + const parser = new RowParser(row3, scopeUUID, initFormValues, submissionScope, readOnly); + + const rowModel = parser.parse(); + + expect((rowModel as DynamicRowGroupModel).group.length).toBe(1); + }); +}); diff --git a/src/app/shared/form/builder/parsers/row-parser.ts b/src/app/shared/form/builder/parsers/row-parser.ts new file mode 100644 index 0000000000..f315be451e --- /dev/null +++ b/src/app/shared/form/builder/parsers/row-parser.ts @@ -0,0 +1,113 @@ +import { DYNAMIC_FORM_CONTROL_TYPE_ARRAY, DynamicFormGroupModelConfig } from '@ng-dynamic-forms/core'; +import { uniqueId } from 'lodash'; + +import { IntegrationSearchOptions } from '../../../../core/integration/models/integration-options.model'; +import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from '../ds-dynamic-form-ui/models/dynamic-group/dynamic-group.model'; +import { DynamicRowGroupModel } from '../ds-dynamic-form-ui/models/ds-dynamic-row-group-model'; +import { isEmpty } from '../../../empty.util'; +import { setLayout } from './parser.utils'; +import { FormFieldModel } from '../models/form-field.model'; +import { ParserType } from './parser-type'; +import { ParserOptions } from './parser-options'; +import { ParserFactory } from './parser-factory'; + +export const ROW_ID_PREFIX = 'df-row-group-config-'; + +export class RowParser { + protected authorityOptions: IntegrationSearchOptions; + + constructor(protected rowData, + protected scopeUUID, + protected initFormValues: any, + protected submissionScope, + protected readOnly: boolean) { + this.authorityOptions = new IntegrationSearchOptions(scopeUUID); + } + + public parse(): DynamicRowGroupModel { + let fieldModel: any = null; + let parsedResult = null; + const config: DynamicFormGroupModelConfig = { + id: uniqueId(ROW_ID_PREFIX), + group: [], + }; + + const scopedFields: FormFieldModel[] = this.filterScopedFields(this.rowData.fields); + + const layoutGridClass = ' col-sm-' + Math.trunc(12 / scopedFields.length) + ' d-flex flex-column justify-content-start'; + + const parserOptions: ParserOptions = { + readOnly: this.readOnly, + submissionScope: this.submissionScope, + authorityUuid: this.authorityOptions.uuid + }; + + // Iterate over row's fields + scopedFields.forEach((fieldData: FormFieldModel) => { + + const parserCo = ParserFactory.getConstructor(fieldData.input.type as ParserType); + if (parserCo) { + fieldModel = new parserCo(fieldData, this.initFormValues, parserOptions).parse(); + } else { + throw new Error(`unknown form control model type defined with label "${fieldData.label}"`); + } + + if (fieldModel) { + if (fieldModel.type === DYNAMIC_FORM_CONTROL_TYPE_ARRAY || fieldModel.type === DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP) { + if (this.rowData.fields.length > 1) { + setLayout(fieldModel, 'grid', 'host', layoutGridClass); + config.group.push(fieldModel); + // if (isEmpty(parsedResult)) { + // parsedResult = []; + // } + // parsedResult.push(fieldModel); + } else { + parsedResult = fieldModel; + } + return; + } else { + if (Array.isArray(fieldModel)) { + fieldModel.forEach((model) => { + parsedResult = model; + return; + }) + } else { + setLayout(fieldModel, 'grid', 'host', layoutGridClass); + config.group.push(fieldModel); + } + } + fieldModel = null; + } + }); + + if (config && !isEmpty(config.group)) { + const clsGroup = { + element: { + control: 'form-row', + } + }; + const groupModel = new DynamicRowGroupModel(config, clsGroup); + if (Array.isArray(parsedResult)) { + parsedResult.push(groupModel) + } else { + parsedResult = groupModel; + } + } + return parsedResult; + } + + checksFieldScope(fieldScope) { + return (isEmpty(fieldScope) || isEmpty(this.submissionScope) || fieldScope === this.submissionScope); + } + + filterScopedFields(fields: FormFieldModel[]): FormFieldModel[] { + const filteredFields: FormFieldModel[] = []; + fields.forEach((field: FormFieldModel) => { + // Whether field scope doesn't match the submission scope, skip it + if (this.checksFieldScope(field.scope)) { + filteredFields.push(field); + } + }); + return filteredFields; + } +} diff --git a/src/app/shared/form/builder/parsers/series-field-parser.spec.ts b/src/app/shared/form/builder/parsers/series-field-parser.spec.ts new file mode 100644 index 0000000000..95351d027f --- /dev/null +++ b/src/app/shared/form/builder/parsers/series-field-parser.spec.ts @@ -0,0 +1,69 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { DynamicConcatModel } from '../ds-dynamic-form-ui/models/ds-dynamic-concat.model'; +import { SeriesFieldParser } from './series-field-parser'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { ParserOptions } from './parser-options'; + +describe('SeriesFieldParser test suite', () => { + let field: FormFieldModel; + let initFormValues: any = {}; + + const parserOptions: ParserOptions = { + readOnly: false, + submissionScope: 'testScopeUUID', + authorityUuid: null + }; + + beforeEach(() => { + field = { + input: {type: 'series'}, + label: 'Series/Report No.', + mandatory: 'false', + repeatable: false, + hints: 'Enter the series and number assigned to this item by your community.', + selectableMetadata: [ + { + metadata: 'series', + } + ], + languageCodes: [] + } as FormFieldModel; + + }); + + it('should init parser properly', () => { + const parser = new SeriesFieldParser(field, initFormValues, parserOptions); + + expect(parser instanceof SeriesFieldParser).toBe(true); + }); + + it('should return a DynamicConcatModel object when repeatable option is false', () => { + const parser = new SeriesFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DynamicConcatModel).toBe(true); + }); + + it('should return a DynamicConcatModel object with the correct separator', () => { + const parser = new SeriesFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect((fieldModel as DynamicConcatModel).separator).toBe('; '); + }); + + it('should set init value properly', () => { + initFormValues = { + series: [new FormFieldMetadataValueObject('test; series')], + }; + const expectedValue = new FormFieldMetadataValueObject('test; series'); + + const parser = new SeriesFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel.value).toEqual(expectedValue); + }); + +}); diff --git a/src/app/shared/form/builder/parsers/series-field-parser.ts b/src/app/shared/form/builder/parsers/series-field-parser.ts new file mode 100644 index 0000000000..9857b4e993 --- /dev/null +++ b/src/app/shared/form/builder/parsers/series-field-parser.ts @@ -0,0 +1,10 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { ConcatFieldParser } from './concat-field-parser'; +import { ParserOptions } from './parser-options'; + +export class SeriesFieldParser extends ConcatFieldParser { + + constructor(protected configData: FormFieldModel, protected initFormValues, protected parserOptions: ParserOptions) { + super(configData, initFormValues, parserOptions, ';'); + } +} diff --git a/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts b/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts new file mode 100644 index 0000000000..3051dc6395 --- /dev/null +++ b/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts @@ -0,0 +1,67 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { TagFieldParser } from './tag-field-parser'; +import { DynamicTagModel } from '../ds-dynamic-form-ui/models/tag/dynamic-tag.model'; +import { ParserOptions } from './parser-options'; + +describe('TagFieldParser test suite', () => { + let field: FormFieldModel; + let initFormValues: any = {}; + + const parserOptions: ParserOptions = { + readOnly: false, + submissionScope: 'testScopeUUID', + authorityUuid: null + }; + + beforeEach(() => { + field = { + input: { + type: 'tag' + }, + label: 'Keywords', + mandatory: 'false', + repeatable: false, + hints: 'Local controlled vocabulary.', + selectableMetadata: [ + { + metadata: 'subject', + authority: 'JOURNALAuthority', + closed: false + } + ], + languageCodes: [] + } as FormFieldModel; + + }); + + it('should init parser properly', () => { + const parser = new TagFieldParser(field, initFormValues, parserOptions); + + expect(parser instanceof TagFieldParser).toBe(true); + }); + + it('should return a DynamicTagModel object when repeatable option is false', () => { + const parser = new TagFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DynamicTagModel).toBe(true); + }); + + it('should set init value properly', () => { + initFormValues = { + subject: [ + new FormFieldMetadataValueObject('test subject'), + new FormFieldMetadataValueObject('another test subject'), + ], + }; + + const parser = new TagFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel.value).toEqual(initFormValues.subject); + }); + +}); diff --git a/src/app/shared/form/builder/parsers/tag-field-parser.ts b/src/app/shared/form/builder/parsers/tag-field-parser.ts new file mode 100644 index 0000000000..c1c39feb2b --- /dev/null +++ b/src/app/shared/form/builder/parsers/tag-field-parser.ts @@ -0,0 +1,21 @@ +import { FieldParser } from './field-parser'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { DynamicTagModel, DynamicTagModelConfig } from '../ds-dynamic-form-ui/models/tag/dynamic-tag.model'; + +export class TagFieldParser extends FieldParser { + + public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { + const tagModelConfig: DynamicTagModelConfig = this.initModel(null, label); + if (this.configData.selectableMetadata[0].authority + && this.configData.selectableMetadata[0].authority.length > 0) { + this.setAuthorityOptions(tagModelConfig, this.parserOptions.authorityUuid); + } + + this.setValues(tagModelConfig, fieldValue, null, true); + + const tagModel = new DynamicTagModel(tagModelConfig); + + return tagModel; + } + +} diff --git a/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts b/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts new file mode 100644 index 0000000000..c26d758e48 --- /dev/null +++ b/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts @@ -0,0 +1,65 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { TextareaFieldParser } from './textarea-field-parser'; +import { DsDynamicTextAreaModel } from '../ds-dynamic-form-ui/models/ds-dynamic-textarea.model'; +import { ParserOptions } from './parser-options'; + +describe('TextareaFieldParser test suite', () => { + let field: FormFieldModel; + let initFormValues: any = {}; + + const parserOptions: ParserOptions = { + readOnly: false, + submissionScope: null, + authorityUuid: null + }; + + beforeEach(() => { + field = { + input: { + type: 'textarea' + }, + label: 'Description', + mandatory: 'false', + repeatable: false, + hints: 'Enter a description.', + selectableMetadata: [ + { + metadata: 'description' + } + ], + languageCodes: [] + } as FormFieldModel; + + }); + + it('should init parser properly', () => { + const parser = new TextareaFieldParser(field, initFormValues, parserOptions); + + expect(parser instanceof TextareaFieldParser).toBe(true); + }); + + it('should return a DsDynamicTextAreaModel object when repeatable option is false', () => { + const parser = new TextareaFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DsDynamicTextAreaModel).toBe(true); + }); + + it('should set init value properly', () => { + initFormValues = { + description: [ + new FormFieldMetadataValueObject('test description'), + ], + }; + const expectedValue ='test description'; + + const parser = new TextareaFieldParser(field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel.value).toEqual(expectedValue); + }); + +}); diff --git a/src/app/shared/form/builder/parsers/textarea-field-parser.ts b/src/app/shared/form/builder/parsers/textarea-field-parser.ts new file mode 100644 index 0000000000..740e94721f --- /dev/null +++ b/src/app/shared/form/builder/parsers/textarea-field-parser.ts @@ -0,0 +1,28 @@ +import { FieldParser } from './field-parser'; +import { DynamicFormControlLayout } from '@ng-dynamic-forms/core'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { + DsDynamicTextAreaModel, + DsDynamicTextAreaModelConfig +} from '../ds-dynamic-form-ui/models/ds-dynamic-textarea.model'; + +export class TextareaFieldParser extends FieldParser { + + public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { + const textAreaModelConfig: DsDynamicTextAreaModelConfig = this.initModel(null, label); + + let layout: DynamicFormControlLayout; + + layout = { + element: { + label: 'col-form-label' + } + }; + + textAreaModelConfig.rows = 10; + this.setValues(textAreaModelConfig, fieldValue); + const textAreaModel = new DsDynamicTextAreaModel(textAreaModelConfig, layout); + + return textAreaModel; + } +} diff --git a/src/app/shared/form/form.actions.ts b/src/app/shared/form/form.actions.ts new file mode 100644 index 0000000000..3eb3fb2716 --- /dev/null +++ b/src/app/shared/form/form.actions.ts @@ -0,0 +1,154 @@ +import { Action } from '@ngrx/store'; + +import { type } from '../ngrx/type'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const FormActionTypes = { + FORM_INIT: type('dspace/form/FORM_INIT'), + FORM_CHANGE: type('dspace/form/FORM_CHANGE'), + FORM_REMOVE: type('dspace/form/FORM_REMOVE'), + FORM_STATUS_CHANGE: type('dspace/form/FORM_STATUS_CHANGE'), + FORM_ADD_ERROR: type('dspace/form/FORM_ADD_ERROR'), + FORM_REMOVE_ERROR: type('dspace/form/FORM_REMOVE_ERROR'), + FORM_CLEAR_ERRORS: type('dspace/form/FORM_CLEAR_ERRORS'), +}; + +/* tslint:disable:max-classes-per-file */ +export class FormInitAction implements Action { + type = FormActionTypes.FORM_INIT; + payload: { + formId: string; + formData: any; + valid: boolean; + }; + + /** + * Create a new FormInitAction + * + * @param formId + * the Form's ID + * @param formData + * the FormGroup Object + * @param valid + * the Form validation status + */ + constructor(formId: string, formData: any, valid: boolean) { + this.payload = {formId, formData, valid}; + } +} + +export class FormChangeAction implements Action { + type = FormActionTypes.FORM_CHANGE; + payload: { + formId: string; + formData: any; + }; + + /** + * Create a new FormInitAction + * + * @param formId + * the Form's ID + * @param formData + * the FormGroup Object + */ + constructor(formId: string, formData: any) { + this.payload = {formId, formData}; + } +} + +export class FormRemoveAction implements Action { + type = FormActionTypes.FORM_REMOVE; + payload: { + formId: string; + }; + + /** + * Create a new FormRemoveAction + * + * @param formId + * the Form's ID + */ + constructor(formId: string) { + this.payload = {formId}; + } +} + +export class FormStatusChangeAction implements Action { + type = FormActionTypes.FORM_STATUS_CHANGE; + payload: { + formId: string; + valid: boolean; + }; + + /** + * Create a new FormInitAction + * + * @param formId + * the Form's ID + * @param valid + * the Form validation status + */ + constructor(formId: string, valid: boolean) { + this.payload = {formId, valid}; + } +} + +export class FormAddError implements Action { + type = FormActionTypes.FORM_ADD_ERROR; + payload: { + formId: string, + fieldId: string, + fieldIndex: number, + errorMessage: string, + }; + + constructor(formId: string, fieldId: string, fieldIndex: number, errorMessage: string) { + this.payload = {formId, fieldId, fieldIndex, errorMessage}; + } +} + +export class FormRemoveErrorAction implements Action { + type = FormActionTypes.FORM_REMOVE_ERROR; + payload: { + formId: string, + fieldId: string, + fieldIndex: number, + }; + + constructor(formId: string, fieldId: string, fieldIndex: number,) { + this.payload = {formId, fieldId, fieldIndex}; + } +} + +export class FormClearErrorsAction implements Action { + type = FormActionTypes.FORM_CLEAR_ERRORS; + payload: { + formId: string + }; + + constructor(formId: string) { + this.payload = {formId}; + } +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types + */ +export type FormAction = FormInitAction + | FormChangeAction + | FormRemoveAction + | FormStatusChangeAction + | FormAddError + | FormClearErrorsAction + | FormRemoveErrorAction diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html new file mode 100644 index 0000000000..1b5f2ef72f --- /dev/null +++ b/src/app/shared/form/form.component.html @@ -0,0 +1,63 @@ +
+
+ + + + + + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ +
+
+
+ +
+ + +
+
+
+ +
+ +
diff --git a/src/app/shared/form/form.component.scss b/src/app/shared/form/form.component.scss new file mode 100644 index 0000000000..0f8145f262 --- /dev/null +++ b/src/app/shared/form/form.component.scss @@ -0,0 +1,23 @@ +@import "../../../styles/_variables.scss"; + +.ds-form-input-addon { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: 0; +} + +.ds-form-input-btn { + border: $input-btn-border-width solid $input-border-color; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 0; +} + +.ds-form-input-btn:focus { + box-shadow: none !important; +} + +.ds-form-input-value { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} diff --git a/src/app/shared/form/form.component.spec.ts b/src/app/shared/form/form.component.spec.ts new file mode 100644 index 0000000000..53cc1d7736 --- /dev/null +++ b/src/app/shared/form/form.component.spec.ts @@ -0,0 +1,437 @@ +import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed, } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { BrowserModule } from '@angular/platform-browser'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import 'rxjs/add/observable/of'; +import { + DynamicFormArrayModel, + DynamicFormControlEvent, + DynamicFormControlModel, + DynamicFormValidationService, + DynamicInputModel +} from '@ng-dynamic-forms/core'; +import { Store } from '@ngrx/store'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +import { FormComponent } from './form.component'; +import { FormService } from './form.service'; +import { FormBuilderService } from './builder/form-builder.service'; +import { FormState } from './form.reducer'; +import { FormChangeAction, FormStatusChangeAction } from './form.actions'; +import { MockStore } from '../testing/mock-store'; +import { FormFieldMetadataValueObject } from './builder/models/form-field-metadata-value.model'; +import { GLOBAL_CONFIG } from '../../../config'; +import { createTestComponent } from '../testing/utils'; + +export const TEST_FORM_MODEL = [ + + new DynamicInputModel( + { + id: 'dc_title', + label: 'Title', + placeholder: 'Title', + validators: { + required: null + }, + errorMessages: { + required: 'You must enter a main title for this item.' + } + } + ), + + new DynamicInputModel( + { + id: 'dc_title_alternative', + label: 'Other Titles', + placeholder: 'Other Titles', + } + ), + + new DynamicInputModel( + { + id: 'dc_publisher', + label: 'Publisher', + placeholder: 'Publisher', + } + ), + + new DynamicInputModel( + { + id: 'dc_identifier_citation', + label: 'Citation', + placeholder: 'Citation', + } + ), + + new DynamicInputModel( + { + id: 'dc_identifier_issn', + label: 'Identifiers', + placeholder: 'Identifiers', + } + ), +]; + +export const TEST_FORM_MODEL_WITH_ARRAY = [ + new DynamicFormArrayModel({ + + id: 'bootstrapFormArray', + initialCount: 1, + label: 'Form Array', + groupFactory: () => { + return [ + new DynamicInputModel({ + + id: 'bootstrapArrayGroupInput', + placeholder: 'example array group input', + readOnly: false + }) + ]; + } + }) +]; + +describe('FormComponent test suite', () => { + let testComp: TestComponent; + let formComp: FormComponent; + let testFixture: ComponentFixture; + let formFixture: ComponentFixture; + + const config = { + form: { + validatorMap: { + required: 'required', + regex: 'pattern' + } + } + } as any; + const formState: FormState = { + testForm: { + data: { + dc_title: null, + dc_title_alternative: null, + dc_publisher: null, + dc_identifier_citation: null, + dc_identifier_issn: null + }, + valid: false, + errors: [] + } + }; + let html; + + const store: MockStore = new MockStore(formState); + + // async beforeEach + beforeEach(async(() => { + + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + CommonModule, + FormsModule, + ReactiveFormsModule, + NgbModule.forRoot(), + TranslateModule.forRoot() + ], + declarations: [ + FormComponent, + TestComponent, + ], // declare the test component + providers: [ + ChangeDetectorRef, + DynamicFormValidationService, + FormBuilderService, + FormComponent, + FormService, + {provide: GLOBAL_CONFIG, useValue: config}, + { + provide: Store, useValue: store + } + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + + })); + + describe('', () => { + // synchronous beforeEach + beforeEach(() => { + html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + it('should create FormComponent', inject([FormComponent], (app: FormComponent) => { + + expect(app).toBeDefined(); + })); + }); + + describe('', () => { + beforeEach(() => { + + formFixture = TestBed.createComponent(FormComponent); + formComp = formFixture.componentInstance; // FormComponent test instance + formComp.formId = 'testForm'; + formComp.formModel = TEST_FORM_MODEL; + formComp.displaySubmit = false; + formFixture.detectChanges(); + spyOn(store, 'dispatch'); + }); + + afterEach(() => { + formFixture.destroy(); + formComp = null; + }); + + it('should dispatch a FormStatusChangeAction when Form group status changes', () => { + const control = formComp.formGroup.get(['dc_title']); + control.setValue('Test Title'); + + expect(store.dispatch).toHaveBeenCalledWith(new FormStatusChangeAction('testForm', formComp.formGroup.valid)); + + }); + + it('should display form errors when errors are added to the state', () => { + const errors = [{ + fieldId: 'dc_title', + fieldIndex: 0, + message: 'error.validation.required' + }]; + + formState.testForm.errors = errors; + store.nextState(formState); + formFixture.detectChanges(); + + expect((formComp as any).formErrors).toEqual(errors); + + }); + + it('should remove form errors when errors are empty in the state', () => { + (formComp as any).formErrors = [{ + fieldId: 'dc_title', + message: 'error.validation.required' + }]; + const errors = []; + + formState.testForm.errors = errors; + store.nextState(formState); + formFixture.detectChanges(); + + expect((formComp as any).formErrors).toEqual(errors); + + }); + + it('should dispatch FormChangeAction on form change', inject([FormBuilderService], (service: FormBuilderService) => { + const event = { + $event: new FormFieldMetadataValueObject('Test Title'), + context: null, + control: formComp.formGroup.get('dc_title'), + group: formComp.formGroup, + model: formComp.formModel[0], + type: 'change' + } as DynamicFormControlEvent; + + spyOn(formComp.change, 'emit'); + + formComp.onChange(event); + + expect(store.dispatch).toHaveBeenCalledWith(new FormChangeAction('testForm', service.getValueFromModel(formComp.formModel))); + expect(formComp.change.emit).toHaveBeenCalled(); + })); + + it('should emit change on form change', inject([FormBuilderService], (service: FormBuilderService) => { + const event = { + $event: new FormFieldMetadataValueObject('Test Title'), + context: null, + control: formComp.formGroup.get('dc_title'), + group: formComp.formGroup, + model: formComp.formModel[0], + type: 'change' + } as DynamicFormControlEvent; + + spyOn(formComp.change, 'emit'); + + formComp.onChange(event); + + expect(formComp.change.emit).toHaveBeenCalled(); + })); + + it('should not emit change Event on form change when emitChange is false', inject([FormBuilderService], (service: FormBuilderService) => { + const event = { + $event: new FormFieldMetadataValueObject('Test Title'), + context: null, + control: formComp.formGroup.get('dc_title'), + group: formComp.formGroup, + model: formComp.formModel[0], + type: 'change' + } as DynamicFormControlEvent; + + formComp.emitChange = false; + spyOn(formComp.change, 'emit'); + + formComp.onChange(event); + + expect(formComp.change.emit).not.toHaveBeenCalled(); + })); + + it('should emit blur Event on blur', () => { + const event = { + $event: new FocusEvent('blur'), + context: null, + control: formComp.formGroup.get('dc_title'), + group: formComp.formGroup, + model: formComp.formModel[0], + type: 'blur' + } as DynamicFormControlEvent; + + spyOn(formComp.blur, 'emit'); + + formComp.onBlur(event); + + expect(formComp.blur.emit).toHaveBeenCalled(); + }); + + it('should emit focus Event on focus', () => { + const event = { + $event: new FocusEvent('focus'), + context: null, + control: formComp.formGroup.get('dc_title'), + group: formComp.formGroup, + model: formComp.formModel[0], + type: 'focus' + } as DynamicFormControlEvent; + + spyOn(formComp.focus, 'emit'); + + formComp.onFocus(event); + + expect(formComp.focus.emit).toHaveBeenCalled(); + }); + + it('should return Observable of form status', () => { + + const control = formComp.formGroup.get(['dc_title']); + control.setValue('Test Title'); + formState.testForm.valid = true; + store.nextState(formState); + formFixture.detectChanges(); + + formComp.isValid().subscribe((valid) => { + expect(valid).toBe(true); + }); + }); + + it('should emit submit Event on form submit whether the form is valid', () => { + + const control = formComp.formGroup.get(['dc_title']); + control.setValue('Test Title'); + formState.testForm.valid = true; + spyOn(formComp.submit, 'emit'); + + store.nextState(formState); + formFixture.detectChanges(); + + formComp.onSubmit(); + expect(formComp.submit.emit).toHaveBeenCalled(); + }); + + it('should not emit submit Event on form submit whether the form is not valid', () => { + + spyOn((formComp as any).formService, 'validateAllFormFields'); + + store.nextState(formState); + formFixture.detectChanges(); + + formComp.onSubmit(); + expect((formComp as any).formService.validateAllFormFields).toHaveBeenCalled(); + }); + + it('should reset form group', () => { + + spyOn(formComp.formGroup, 'reset'); + + formComp.reset(); + + expect(formComp.formGroup.reset).toHaveBeenCalled(); + }); + }); + + describe('', () => { + beforeEach(() => { + + formFixture = TestBed.createComponent(FormComponent); + formComp = formFixture.componentInstance; // FormComponent test instance + formComp.formId = 'testFormArray'; + formComp.formModel = TEST_FORM_MODEL_WITH_ARRAY; + formComp.displaySubmit = false; + formFixture.detectChanges(); + spyOn(store, 'dispatch'); + }); + + afterEach(() => { + formFixture.destroy(); + formComp = null; + }); + + it('should return ReadOnly property from array item', inject([FormBuilderService], (service: FormBuilderService) => { + const readOnly = formComp.isItemReadOnly(formComp.formModel[0] as DynamicFormArrayModel, 0); + + expect(readOnly).toBe(false); + })); + + it('should dispatch FormChangeAction when an item has been added to an array', inject([FormBuilderService], (service: FormBuilderService) => { + formComp.insertItem(new Event('click'), formComp.formModel[0] as DynamicFormArrayModel, 1); + + expect(store.dispatch).toHaveBeenCalledWith(new FormChangeAction('testFormArray', service.getValueFromModel(formComp.formModel))); + })); + + it('should emit addArrayItem Event when an item has been added to an array', inject([FormBuilderService], (service: FormBuilderService) => { + spyOn(formComp.addArrayItem, 'emit'); + + formComp.insertItem(new Event('click'), formComp.formModel[0] as DynamicFormArrayModel, 1); + + expect(formComp.addArrayItem.emit).toHaveBeenCalled(); + })); + + it('should dispatch FormChangeAction when an item has been removed from an array', inject([FormBuilderService], (service: FormBuilderService) => { + formComp.removeItem(new Event('click'), formComp.formModel[0] as DynamicFormArrayModel, 1); + + expect(store.dispatch).toHaveBeenCalledWith(new FormChangeAction('testFormArray', service.getValueFromModel(formComp.formModel))); + })); + + it('should emit removeArrayItem Event when an item has been removed from an array', inject([FormBuilderService], (service: FormBuilderService) => { + spyOn(formComp.removeArrayItem, 'emit'); + + formComp.removeItem(new Event('click'), formComp.formModel[0] as DynamicFormArrayModel, 1); + + expect(formComp.removeArrayItem.emit).toHaveBeenCalled(); + })); + }) +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + public formId; + public formModel: DynamicFormControlModel[]; + public displaySubmit = false; + + constructor() { + this.formId = 'testForm'; + this.formModel = TEST_FORM_MODEL; + } + +} diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts new file mode 100644 index 0000000000..54a39a80fa --- /dev/null +++ b/src/app/shared/form/form.component.ts @@ -0,0 +1,317 @@ +import { ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { AbstractControl, FormArray, FormControl, FormGroup } from '@angular/forms'; + +import { + DynamicFormArrayModel, + DynamicFormControlEvent, + DynamicFormControlModel, + DynamicFormGroupModel, + DynamicFormLayout, +} from '@ng-dynamic-forms/core'; +import { Store } from '@ngrx/store'; +import { findIndex } from 'lodash'; + +import { AppState } from '../../app.reducer'; +import { + FormChangeAction, + FormInitAction, + FormRemoveAction, + FormRemoveErrorAction, + FormStatusChangeAction +} from './form.actions'; +import { FormBuilderService } from './builder/form-builder.service'; +import { Observable } from 'rxjs/Observable'; +import { Subscription } from 'rxjs/Subscription'; +import { hasValue, isNotEmpty, isNotNull, isNull } from '../empty.util'; +import { FormService } from './form.service'; +import { formObjectFromIdSelector } from './selectors'; +import { FormEntry, FormError } from './form.reducer'; + +/** + * The default form component. + */ +@Component({ + exportAs: 'formComponent', + selector: 'ds-form', + styleUrls: ['form.component.scss'], + templateUrl: 'form.component.html', +}) +export class FormComponent implements OnDestroy, OnInit { + + private formErrors: FormError[] = []; + private formValid: boolean; + + /** + * A boolean that indicate if to display form's submit and cancel buttons + */ + @Input() displaySubmit = true; + + /** + * A boolean that indicate if to emit a form change event + */ + @Input() emitChange = true; + + /** + * The form unique ID + */ + @Input() formId: string; + + /** + * An array of DynamicFormControlModel type + */ + @Input() formModel: DynamicFormControlModel[]; + @Input() parentFormModel: DynamicFormGroupModel | DynamicFormGroupModel[]; + @Input() formGroup: FormGroup; + @Input() formLayout: DynamicFormLayout = null; + + /* tslint:disable:no-output-rename */ + @Output('dfBlur') blur: EventEmitter = new EventEmitter(); + @Output('dfChange') change: EventEmitter = new EventEmitter(); + @Output('dfFocus') focus: EventEmitter = new EventEmitter(); + /* tslint:enable:no-output-rename */ + @Output() addArrayItem: EventEmitter = new EventEmitter(); + @Output() removeArrayItem: EventEmitter = new EventEmitter(); + + /** + * An event fired when form is valid and submitted . + * Event's payload equals to the form content. + */ + @Output() submit: EventEmitter> = new EventEmitter>(); + + /** + * An object of FormGroup type + */ + // public formGroup: FormGroup; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + constructor(private formService: FormService, + protected changeDetectorRef: ChangeDetectorRef, + private formBuilderService: FormBuilderService, + private store: Store) { + } + + /** + * Method provided by Angular. Invoked after the view has been initialized. + */ + + /*ngAfterViewChecked(): void { + this.subs.push(this.formGroup.valueChanges + .filter((formGroup) => this.formGroup.dirty) + .subscribe(() => { + // Dispatch a FormChangeAction if the user has changed the value in the UI + this.store.dispatch(new FormChangeAction(this.formId, this.formGroup.value)); + this.formGroup.markAsPristine(); + })); + }*/ + + private getFormGroup(): FormGroup { + if (!!this.parentFormModel) { + return this.formGroup.parent as FormGroup; + } + + return this.formGroup; + } + + private getFormGroupValue() { + return this.getFormGroup().value; + } + + private getFormGroupValidStatus() { + return this.getFormGroup().valid; + } + + /** + * Method provided by Angular. Invoked after the constructor + */ + ngOnInit() { + if (!this.formGroup) { + this.formGroup = this.formBuilderService.createFormGroup(this.formModel); + } else { + this.formModel.forEach((model) => { + this.formBuilderService.addFormGroupControl(this.formGroup, this.parentFormModel, model); + }); + } + + this.store.dispatch(new FormInitAction(this.formId, this.formBuilderService.getValueFromModel(this.formModel), this.getFormGroupValidStatus())); + + // TODO: take a look to the following method: + // this.keepSync(); + + this.formValid = this.getFormGroupValidStatus(); + + this.subs.push(this.formGroup.statusChanges + .filter((currentStatus) => this.formValid !== this.getFormGroupValidStatus()) + .subscribe((currentStatus) => { + // Dispatch a FormStatusChangeAction if the form status has changed + this.store.dispatch(new FormStatusChangeAction(this.formId, this.getFormGroupValidStatus())); + this.formValid = this.getFormGroupValidStatus(); + })); + + this.subs.push( + this.store.select(formObjectFromIdSelector(this.formId)) + .filter((formState: FormEntry) => !!formState && (isNotEmpty(formState.errors) || isNotEmpty(this.formErrors))) + .map((formState) => formState.errors) + .distinctUntilChanged() + // .delay(100) // this terrible delay is here to prevent the detection change error + .subscribe((errors: FormError[]) => { + const {formGroup, formModel} = this; + + errors + .filter((error: FormError) => findIndex(this.formErrors, {fieldId: error.fieldId, fieldIndex: error.fieldIndex}) === -1) + .forEach((error: FormError) => { + const {fieldId} = error; + const {fieldIndex} = error; + let field: AbstractControl; + if (!!this.parentFormModel) { + field = this.formBuilderService.getFormControlById(fieldId, formGroup.parent as FormGroup, formModel, fieldIndex); + } else { + field = this.formBuilderService.getFormControlById(fieldId, formGroup, formModel, fieldIndex); + } + + if (field) { + const model: DynamicFormControlModel = this.formBuilderService.findById(fieldId, formModel); + this.formService.addErrorToField(field, model, error.message); + // this.formService.validateAllFormFields(formGroup); + this.changeDetectorRef.detectChanges(); + } + }); + + this.formErrors + .filter((error: FormError) => findIndex(errors, {fieldId: error.fieldId, fieldIndex: error.fieldIndex}) === -1) + .forEach((error: FormError) => { + const {fieldId} = error; + const {fieldIndex} = error; + let field: AbstractControl; + if (!!this.parentFormModel) { + field = this.formBuilderService.getFormControlById(fieldId, formGroup.parent as FormGroup, formModel, fieldIndex); + } else { + field = this.formBuilderService.getFormControlById(fieldId, formGroup, formModel, fieldIndex); + } + + if (field) { + const model: DynamicFormControlModel = this.formBuilderService.findById(fieldId, formModel); + this.formService.removeErrorFromField(field, model, error.message); + } + }); + + this.formErrors = errors; + this.changeDetectorRef.detectChanges(); + }) + ); + } + + /** + * Method provided by Angular. Invoked when the instance is destroyed + */ + ngOnDestroy() { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + this.store.dispatch(new FormRemoveAction(this.formId)); + } + + /** + * Method to check if the form status is valid or not + */ + public isValid(): Observable { + return this.formService.isValid(this.formId) + } + + /** + * Method to keep synchronized form controls values with form state + */ + private keepSync(): void { + this.subs.push(this.formService.getFormData(this.formId) + .subscribe((stateFormData) => { + if (!Object.is(stateFormData, this.formGroup.value) && this.formGroup) { + this.formGroup.setValue(stateFormData); + } + })); + } + + onBlur(event: DynamicFormControlEvent): void { + this.blur.emit(event); + } + + onFocus(event: DynamicFormControlEvent): void { + this.focus.emit(event); + } + + onChange(event: DynamicFormControlEvent): void { + const action: FormChangeAction = new FormChangeAction(this.formId, this.formBuilderService.getValueFromModel(this.formModel)); + + this.store.dispatch(action); + this.formGroup.markAsPristine(); + + if (this.emitChange) { + this.change.emit(event); + } + + const control: FormControl = event.control; + const fieldIndex: number = (event.context && event.context.index) ? event.context.index : 0; + if (control.valid) { + this.store.dispatch(new FormRemoveErrorAction(this.formId, event.model.id, fieldIndex)); + } + } + + /** + * Method called on submit. + * Emit a new submit Event whether the form is valid, mark fields with error otherwise + */ + onSubmit(): void { + if (this.getFormGroupValidStatus()) { + this.submit.emit(this.formService.getFormData(this.formId)); + } else { + this.formService.validateAllFormFields(this.formGroup); + } + } + + /** + * Method to reset form fields + */ + reset(): void { + this.formGroup.reset(); + } + + isItemReadOnly(arrayContext: DynamicFormArrayModel, index: number): boolean { + const context = arrayContext.groups[index]; + const model = context.group[0] as any; + return model.readOnly; + } + + removeItem($event, arrayContext: DynamicFormArrayModel, index: number): void { + const formArrayControl = this.formGroup.get(this.formBuilderService.getPath(arrayContext)) as FormArray; + this.removeArrayItem.emit(this.getEvent($event, arrayContext, index, 'remove')); + this.formBuilderService.removeFormArrayGroup(index, formArrayControl, arrayContext); + this.store.dispatch(new FormChangeAction(this.formId, this.formBuilderService.getValueFromModel(this.formModel))); + } + + insertItem($event, arrayContext: DynamicFormArrayModel, index: number): void { + const formArrayControl = this.formGroup.get(this.formBuilderService.getPath(arrayContext)) as FormArray; + this.formBuilderService.insertFormArrayGroup(index, formArrayControl, arrayContext); + this.addArrayItem.emit(this.getEvent($event, arrayContext, index, 'add')); + this.store.dispatch(new FormChangeAction(this.formId, this.formBuilderService.getValueFromModel(this.formModel))); + } + + protected getEvent($event: any, arrayContext: DynamicFormArrayModel, index: number, type: string): DynamicFormControlEvent { + const context = arrayContext.groups[index]; + const itemGroupModel = context.context; + let group = this.formGroup.get(itemGroupModel.id) as FormGroup; + if (isNull(group)) { + for (const key of Object.keys(this.formGroup.controls)) { + group = this.formGroup.controls[key].get(itemGroupModel.id) as FormGroup; + if (isNotNull(group)) { + break; + } + } + } + const model = context.group[0] as DynamicFormControlModel; + const control = group.controls[index] as FormControl; + return {$event, context, control, group, model, type}; + } +} diff --git a/src/app/shared/form/form.effects.ts b/src/app/shared/form/form.effects.ts new file mode 100644 index 0000000000..078f658300 --- /dev/null +++ b/src/app/shared/form/form.effects.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect } from '@ngrx/effects'; + +@Injectable() +export class FormEffects { + + constructor(private actions$: Actions) { + + } + +} diff --git a/src/app/shared/form/form.reducer.spec.ts b/src/app/shared/form/form.reducer.spec.ts new file mode 100644 index 0000000000..01e3e6b1ba --- /dev/null +++ b/src/app/shared/form/form.reducer.spec.ts @@ -0,0 +1,278 @@ +import { formReducer } from './form.reducer'; +import { + FormAddError, + FormChangeAction, + FormClearErrorsAction, + FormInitAction, + FormRemoveAction, + FormRemoveErrorAction, + FormStatusChangeAction +} from './form.actions'; + +describe('formReducer', () => { + + it('should set init state of the form', () => { + const state = { + testForm: { + data: { + author: null, + title: null, + date: null, + description: null + }, + valid: false, + errors: [] + } + }; + const formId = 'testForm'; + const formData = { + author: null, + title: null, + date: null, + description: null + }; + const valid = false; + const action = new FormInitAction(formId, formData, valid); + const newState = formReducer({}, action); + + expect(newState).toEqual(state); + }); + + it('should update state of the form when it\'s already present', () => { + const initState = { + testForm: { + data: { + author: null, + title: null, + date: null, + description: null + }, + valid: false, + errors: [] + } + }; + const formId = 'testForm'; + const formData = { + author: null, + title: 'title', + date: null, + description: null + }; + const state = { + testForm: { + data: { + author: null, + title: 'title', + date: null, + description: null + }, + valid: false, + errors: [] + } + }; + + const valid = false; + const action = new FormInitAction(formId, formData, valid); + const newState = formReducer(initState, action); + + expect(newState).toEqual(state); + }); + + it('should change form data on form change', () => { + const initState = { + testForm: { + data: { + author: null, + title: null, + date: null, + description: null + }, + valid: false, + errors: [] + } + }; + const state = { + testForm: { + data: { + author: null, + title: ['test'], + date: null, + description: null + }, + valid: false, + errors: [] + } + }; + const formId = 'testForm'; + const formData = { + author: null, + title: ['test'], + date: null, + description: null + }; + + const action = new FormChangeAction(formId, formData); + const newState = formReducer(initState, action); + + expect(newState).toEqual(state); + }); + + it('should change form status on form status change', () => { + const initState = { + testForm: { + data: { + author: null, + title: ['test'], + date: null, + description: null + }, + valid: false, + errors: [] + } + }; + const state = { + testForm: { + data: { + author: null, + title: ['test'], + date: null, + description: null + }, + valid: true, + errors: [] + } + }; + const formId = 'testForm'; + + const action = new FormStatusChangeAction(formId, true); + const newState = formReducer(initState, action); + + expect(newState).toEqual(state); + }); + + it('should add error to form state', () => { + const initState = { + testForm: { + data: { + author: null, + title: ['test'], + date: null, + description: null + }, + valid: true, + errors: [] + } + }; + + const expectedErrors = [ + { + fieldId: 'title', + fieldIndex: 0, + message: 'Not valid' + } + ]; + + const formId = 'testForm'; + const fieldId = 'title'; + const fieldIndex = 0; + const message = 'Not valid'; + + const action = new FormAddError(formId, fieldId, fieldIndex, message); + const newState = formReducer(initState, action); + + expect(newState.testForm.errors).toEqual(expectedErrors); + }); + + it('should remove errors from field', () => { + const initState = { + testForm: { + data: { + author: null, + title: ['test'], + date: null, + description: null + }, + valid: true, + errors: [ + { + fieldId: 'author', + fieldIndex: 0, + message: 'error.validation.required' + }, + { + fieldId: 'title', + fieldIndex: 0, + message: 'error.validation.required' + } + ] + } + }; + + const expectedErrors = [ + { + fieldId: 'title', + fieldIndex: 0, + message: 'error.validation.required' + } + ]; + + const formId = 'testForm'; + const fieldId = 'author'; + const fieldIndex = 0; + + const action = new FormRemoveErrorAction(formId, fieldId, fieldIndex); + const newState = formReducer(initState, action); + + expect(newState.testForm.errors).toEqual(expectedErrors); + }); + + it('should remove form state', () => { + const initState = { + testForm: { + data: { + author: null, + title: ['test'], + date: null, + description: null + }, + valid: true, + errors: [] + } + }; + + const formId = 'testForm'; + + const action = new FormRemoveAction(formId); + const newState = formReducer(initState, action); + + expect(newState).toEqual({}); + }); + + it('should clear form errors', () => { + const initState = { + testForm: { + data: { + author: null, + title: ['test'], + date: null, + description: null + }, + valid: true, + errors: [ + { + fieldId: 'author', + fieldIndex: 0, + message: 'error.validation.required' + } + ] + } + }; + + const formId = 'testForm'; + + const action = new FormClearErrorsAction(formId); + const newState = formReducer(initState, action); + + expect(newState.testForm.errors).toEqual([]); + }); +}); diff --git a/src/app/shared/form/form.reducer.ts b/src/app/shared/form/form.reducer.ts new file mode 100644 index 0000000000..1d44375c0d --- /dev/null +++ b/src/app/shared/form/form.reducer.ts @@ -0,0 +1,214 @@ +import { + FormAction, + FormActionTypes, + FormAddError, + FormChangeAction, FormClearErrorsAction, + FormInitAction, + FormRemoveAction, + FormRemoveErrorAction, + FormStatusChangeAction +} from './form.actions'; +import { hasValue } from '../empty.util'; +import { isEqual, uniqWith } from 'lodash'; + +export interface FormError { + message: string; + fieldId: string; + fieldIndex: number; +} + +export interface FormEntry { + data: any; + valid: boolean; + errors: FormError[]; +} + +export interface FormState { + [formId: string]: FormEntry; +} + +const initialState: FormState = Object.create(null); + +export function formReducer(state = initialState, action: FormAction): FormState { + switch (action.type) { + + case FormActionTypes.FORM_INIT: { + return initForm(state, action as FormInitAction); + } + + case FormActionTypes.FORM_CHANGE: { + return changeDataForm(state, action as FormChangeAction); + } + + case FormActionTypes.FORM_REMOVE: { + return removeForm(state, action as FormRemoveAction); + } + + case FormActionTypes.FORM_STATUS_CHANGE: { + return changeStatusForm(state, action as FormStatusChangeAction); + } + + case FormActionTypes.FORM_ADD_ERROR: { + return addFormErrors(state, action as FormAddError) + } + + case FormActionTypes.FORM_REMOVE_ERROR: { + return removeFormError(state, action as FormRemoveErrorAction) + } + + case FormActionTypes.FORM_CLEAR_ERRORS: { + return clearsFormErrors(state, action as FormClearErrorsAction) + } + + default: { + return state; + } + } +} + +function addFormErrors(state: FormState, action: FormAddError) { + const formId = action.payload.formId; + if (hasValue(state[formId])) { + const error: FormError = { + fieldId: action.payload.fieldId, + fieldIndex: action.payload.fieldIndex, + message: action.payload.errorMessage + }; + + return Object.assign({}, state, { + [formId]: { + data: state[formId].data, + valid: state[formId].valid, + errors: state[formId].errors ? uniqWith(state[formId].errors.concat(error), isEqual) : [].concat(error), + } + }); + } else { + return state; + } +} + +function removeFormError(state: FormState, action: FormRemoveErrorAction) { + const formId = action.payload.formId; + const fieldId = action.payload.fieldId; + const fieldIndex = action.payload.fieldIndex; + if (hasValue(state[formId])) { + const errors = state[formId].errors.filter((error) => error.fieldId !== fieldId || error.fieldIndex !== fieldIndex); + const newState = Object.assign({}, state); + newState[formId] = Object.assign({}, state[formId], {errors}); + return newState; + } else { + return state; + } +} + +function clearsFormErrors(state: FormState, action: FormClearErrorsAction) { + const formId = action.payload.formId; + if (hasValue(state[formId])) { + const errors = []; + const newState = Object.assign({}, state); + newState[formId] = Object.assign({}, state[formId], {errors}); + return newState; + } else { + return state; + } +} + +/** + * Init form state. + * + * @param state + * the current state + * @param action + * an FormInitAction + * @return FormState + * the new state, with the form initialized. + */ +function initForm(state: FormState, action: FormInitAction): FormState { + const formState = { + data: action.payload.formData, + valid: action.payload.valid, + errors: [] + }; + if (!hasValue(state[action.payload.formId])) { + return Object.assign({}, state, { + [action.payload.formId]: formState + }); + } else { + const newState = Object.assign({}, state); + newState[action.payload.formId] = Object.assign({}, newState[action.payload.formId], formState); + return newState; + } +} + +/** + * Set form data. + * + * @param state + * the current state + * @param action + * an FormChangeAction + * @return FormState + * the new state, with the data changed. + */ +function changeDataForm(state: FormState, action: FormChangeAction): FormState { + if (hasValue(state[action.payload.formId])) { + const newState = Object.assign({}, state); + newState[action.payload.formId] = Object.assign({}, newState[action.payload.formId], { + data: action.payload.formData, + valid: state[action.payload.formId].valid + } + ); + return newState; + } else { + return state; + } +} + +/** + * Set form status. + * + * @param state + * the current state + * @param action + * an FormStatusChangeAction + * @return FormState + * the new state, with the status changed. + */ +function changeStatusForm(state: FormState, action: FormStatusChangeAction): FormState { + if (!hasValue(state[action.payload.formId])) { + return Object.assign({}, state, { + [action.payload.formId]: { + data: state[action.payload.formId].data, + valid: action.payload.valid + } + }); + } else { + const newState = Object.assign({}, state); + newState[action.payload.formId] = Object.assign({}, newState[action.payload.formId], { + data: state[action.payload.formId].data, + valid: action.payload.valid + } + ); + return newState; + } +} + +/** + * Remove form state. + * + * @param state + * the current state + * @param action + * an FormRemoveAction + * @return FormState + * the new state, with the form initialized. + */ +function removeForm(state: FormState, action: FormRemoveAction): FormState { + if (hasValue(state[action.payload.formId])) { + const newState = Object.assign({}, state); + delete newState[action.payload.formId]; + return newState; + } else { + return state; + } +} diff --git a/src/app/shared/form/form.service.spec.ts b/src/app/shared/form/form.service.spec.ts new file mode 100644 index 0000000000..06125c9034 --- /dev/null +++ b/src/app/shared/form/form.service.spec.ts @@ -0,0 +1,215 @@ +import { Store, StoreModule } from '@ngrx/store'; +import { async, inject, TestBed } from '@angular/core/testing'; +import { FormGroup } from '@angular/forms'; + +import { + DynamicFormControlModel, DynamicFormGroupModel, + DynamicFormService, + DynamicFormValidationService, + DynamicInputModel +} from '@ng-dynamic-forms/core'; + +import { FormService } from './form.service'; +import { FormBuilderService } from './builder/form-builder.service'; +import { AppState } from '../../app.reducer'; +import { formReducer } from './form.reducer'; +import { GlobalConfig } from '../../../config/global-config.interface'; + +describe('FormService test suite', () => { + const config = { + form: { + validatorMap: { + required: 'required', + regex: 'pattern' + } + } + } as any; + const formId = 'testForm'; + let service: FormService; + let builderService: FormBuilderService; + let formGroup: FormGroup; + + const formModel: DynamicFormControlModel[] = [ + new DynamicInputModel({id: 'author', value: 'test'}), + new DynamicInputModel({ + id: 'title', + validators: { + required: null + }, + errorMessages: { + required: 'Title is required' + } + }), + new DynamicInputModel({id: 'date'}), + new DynamicInputModel({id: 'description'}), + new DynamicFormGroupModel({ + + id: 'addressLocation', + group: [ + new DynamicInputModel({ + + id: 'zipCode', + label: 'Zip Code', + placeholder: 'ZIP' + }), + new DynamicInputModel({ + + id: 'state', + label: 'State', + placeholder: 'State' + }), + new DynamicInputModel({ + + id: 'city', + label: 'City', + placeholder: 'City' + }) + ] + }), + ]; + + const formData = { + author: ['test'], + title: null, + date: null, + description: null, + addressLocation: { + zipCode: null, + state: null, + city: null + } + }; + const formState = { + testForm: { + data: formData, + valid: false, + errors: [] + } + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({formReducer}) + ], + providers: [ + DynamicFormService, + DynamicFormValidationService, + FormBuilderService, + ] + }).compileComponents(); + })); + + beforeEach(inject([Store, FormBuilderService], (store: Store, formBuilderService: FormBuilderService) => { + store + .subscribe((state) => { + state.forms = formState; + }); + builderService = formBuilderService; + formGroup = builderService.createFormGroup(formModel); + service = new FormService(config, formBuilderService, store); + })); + + it('should check whether form state is init', () => { + service.isFormInitialized(formId).subscribe((init) => { + expect(init).toBe(true); + }); + }); + + it('should return form status when isValid is called', () => { + service.isValid(formId).subscribe((status) => { + expect(status).toBe(false); + }); + }); + + it('should return form data when getFormData is called', () => { + service.getFormData(formId).subscribe((data) => { + expect(data).toBe(formData); + }); + }); + + it('should return form unique id', () => { + const formId1 = service.getUniqueId(formId); + const formId2 = service.getUniqueId(formId); + + expect(formId1).not.toEqual(formId2); + }); + + it('should validate all form fields', () => { + service.validateAllFormFields(formGroup); + + expect(formGroup.controls.author.touched).toBe(true); + expect(formGroup.controls.author.status).toBe('VALID'); + + expect(formGroup.controls.title.touched).toBe(true); + expect(formGroup.controls.title.status).toBe('INVALID'); + + expect(formGroup.controls.date.touched).toBe(true); + + expect(formGroup.controls.description.touched).toBe(true); + }); + + it('should add error to field', () => { + let control = builderService.getFormControlById('description', formGroup, formModel); + let model = builderService.findById('description', formModel); + let errorKeys: string[]; + + service.addErrorToField(control, model, 'Test error message'); + errorKeys = Object.keys(control.errors); + + expect(errorKeys.length).toBe(1); + + expect(control.hasError(errorKeys[0])).toBe(true); + + expect(formGroup.controls.description.touched).toBe(true); + + control = builderService.getFormControlById('title', formGroup, formModel); + model = builderService.findById('title', formModel); + service.addErrorToField(control, model, 'error.required'); + errorKeys = Object.keys(control.errors); + + expect(errorKeys.length).toBe(1); + + expect(control.hasError(errorKeys[0])).toBe(true); + + expect(formGroup.controls.description.touched).toBe(true); + }); + + it('should remove error from field', () => { + let control = builderService.getFormControlById('description', formGroup, formModel); + let model = builderService.findById('description', formModel); + let errorKeys: string[]; + + service.addErrorToField(control, model, 'Test error message'); + errorKeys = Object.keys(control.errors); + + service.removeErrorFromField(control, model, errorKeys[0]); + + expect(errorKeys.length).toBe(1); + + expect(control.hasError(errorKeys[0])).toBe(false); + + expect(formGroup.controls.description.touched).toBe(false); + + control = builderService.getFormControlById('title', formGroup, formModel); + model = builderService.findById('title', formModel); + + service.addErrorToField(control, model, 'error.required'); + + service.removeErrorFromField(control, model, 'error.required'); + + expect(errorKeys.length).toBe(1); + + expect(control.hasError(errorKeys[0])).toBe(false); + + expect(formGroup.controls.description.touched).toBe(false); + }); + + it('should reset form group', () => { + const control = builderService.getFormControlById('author', formGroup, formModel); + + service.resetForm(formGroup, formModel, formId); + + expect(control.value).toBeNull(); + }); +}); diff --git a/src/app/shared/form/form.service.ts b/src/app/shared/form/form.service.ts new file mode 100644 index 0000000000..d3f6c984ba --- /dev/null +++ b/src/app/shared/form/form.service.ts @@ -0,0 +1,136 @@ +import { Inject, Injectable } from '@angular/core'; +import { AbstractControl, FormControl, FormGroup } from '@angular/forms'; +import { Observable } from 'rxjs/Observable'; +import { Store } from '@ngrx/store'; + +import { AppState } from '../../app.reducer'; +import { formObjectFromIdSelector } from './selectors'; +import { FormBuilderService } from './builder/form-builder.service'; +import { DynamicFormControlModel } from '@ng-dynamic-forms/core'; +import { isEmpty, isNotUndefined } from '../empty.util'; +import { uniqueId } from 'lodash'; +import { FormChangeAction } from './form.actions'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; + +@Injectable() +export class FormService { + + constructor( + @Inject(GLOBAL_CONFIG) public config: GlobalConfig, + private formBuilderService: FormBuilderService, + private store: Store) { + } + + /** + * Method to retrieve form's status from state + */ + public isValid(formId: string): Observable { + return this.store.select(formObjectFromIdSelector(formId)) + .filter((state) => isNotUndefined(state)) + .map((state) => state.valid) + .distinctUntilChanged(); + } + + /** + * Method to retrieve form's data from state + */ + public getFormData(formId: string): Observable { + return this.store.select(formObjectFromIdSelector(formId)) + .filter((state) => isNotUndefined(state)) + .map((state) => state.data) + .distinctUntilChanged(); + } + + /** + * Method to retrieve form's errors from state + */ + public getFormErrors(formId: string): Observable { + return this.store.select(formObjectFromIdSelector(formId)) + .filter((state) => isNotUndefined(state)) + .map((state) => state.errors) + .distinctUntilChanged(); + } + + /** + * Method to retrieve form's data from state + */ + public isFormInitialized(formId: string): Observable { + return this.store.select(formObjectFromIdSelector(formId)) + .distinctUntilChanged() + .map((state) => isNotUndefined(state)); + } + + public getUniqueId(formId): string { + return uniqueId() + '_' + formId; + } + + /** + * Method to validate form's fields + */ + public validateAllFormFields(formGroup: FormGroup) { + Object.keys(formGroup.controls).forEach((field) => { + const control = formGroup.get(field); + if (control instanceof FormControl) { + control.markAsTouched({onlySelf: true}); + } else if (control instanceof FormGroup) { + this.validateAllFormFields(control); + } + }); + } + + public addErrorToField(field: AbstractControl, model: DynamicFormControlModel, message: string) { + const error = {}; // create the error object + const errorKey = this.getValidatorNameFromMap(message); + let errorMsg = message; + + // if form control model has not errorMessages object, create it + if (!model.errorMessages) { + model.errorMessages = {}; + } + + // check if error code is already present in the set of model's validators + if (isEmpty(model.errorMessages[errorKey])) { + // put the error message in the form control model + model.errorMessages[errorKey] = message; + } else { + // Use correct error messages from the model + errorMsg = model.errorMessages[errorKey]; + } + + if (!field.hasError(errorKey)) { + error[errorKey] = true; + // add the error in the form control + field.setErrors(error); + } + + field.markAsTouched(); + } + + public removeErrorFromField(field: AbstractControl, model: DynamicFormControlModel, messageKey: string) { + const error = {}; + const errorKey = this.getValidatorNameFromMap(messageKey); + + if (field.hasError(errorKey)) { + error[errorKey] = null; + field.setErrors(error); + } + + field.markAsUntouched(); + } + + public resetForm(formGroup: FormGroup, groupModel: DynamicFormControlModel[], formId: string) { + this.formBuilderService.clearAllModelsValue(groupModel); + formGroup.reset(); + this.store.dispatch(new FormChangeAction(formId, formGroup.value)); + } + + private getValidatorNameFromMap(validator): string { + if (validator.includes('.')) { + const splitArray = validator.split('.'); + if (splitArray && splitArray.length > 0) { + validator = this.getValidatorNameFromMap(splitArray[splitArray.length - 1]); + } + } + return (this.config.form.validatorMap.hasOwnProperty(validator)) ? this.config.form.validatorMap[validator] : validator; + } +} diff --git a/src/app/shared/form/selectors.ts b/src/app/shared/form/selectors.ts new file mode 100644 index 0000000000..aeefb5f984 --- /dev/null +++ b/src/app/shared/form/selectors.ts @@ -0,0 +1,10 @@ +import { createSelector, MemoizedSelector } from '@ngrx/store'; + +import { AppState } from '../../app.reducer'; +import { FormEntry, FormState } from './form.reducer'; + +export const formStateSelector = (state: AppState) => state.forms; + +export function formObjectFromIdSelector(formId: string): MemoizedSelector { + return createSelector(formStateSelector, (forms: FormState) => forms[formId]); +} diff --git a/src/app/shared/notifications/models/notification-type.ts b/src/app/shared/notifications/models/notification-type.ts index 8ef5d790b5..935129943e 100644 --- a/src/app/shared/notifications/models/notification-type.ts +++ b/src/app/shared/notifications/models/notification-type.ts @@ -2,6 +2,5 @@ export enum NotificationType { Success = 'alert-success', Error = 'alert-danger', Info = 'alert-info', - Warning = 'alert-warning', - // Bare = 'bare' + Warning = 'alert-warning' } diff --git a/src/app/shared/number-picker/number-picker.component.html b/src/app/shared/number-picker/number-picker.component.html new file mode 100644 index 0000000000..e2007f0f28 --- /dev/null +++ b/src/app/shared/number-picker/number-picker.component.html @@ -0,0 +1,36 @@ +
+ + + +
diff --git a/src/app/shared/number-picker/number-picker.component.scss b/src/app/shared/number-picker/number-picker.component.scss new file mode 100644 index 0000000000..0f7c4067ac --- /dev/null +++ b/src/app/shared/number-picker/number-picker.component.scss @@ -0,0 +1,25 @@ +:host { + outline: none; +} + +.chevron::before { + border-style: solid; + border-width: 0.29em 0.29em 0 0; + content: ''; + display: inline-block; + height: 0.69em; + left: 0.05em; + position: relative; + top: 0.15em; + transform: rotate(-45deg); + vertical-align: middle; + width: 0.71em; +} +.chevron.bottom:before { + top: -.3em; + transform: rotate(135deg); +} + +input { + max-width: 80px !important; +} diff --git a/src/app/shared/number-picker/number-picker.component.spec.ts b/src/app/shared/number-picker/number-picker.component.spec.ts new file mode 100644 index 0000000000..3f6ae441f8 --- /dev/null +++ b/src/app/shared/number-picker/number-picker.component.spec.ts @@ -0,0 +1,163 @@ +// Load the implementations that should be tested +import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed, } from '@angular/core/testing'; + +import { UploaderService } from '../uploader/uploader.service'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { By } from '@angular/platform-browser'; +import { NumberPickerComponent } from './number-picker.component'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { createTestComponent } from '../testing/utils'; + +describe('NumberPickerComponent test suite', () => { + + let testComp: TestComponent; + let numberPickerComp: NumberPickerComponent; + let testFixture: ComponentFixture; + let numberPickerFixture: ComponentFixture; + let html; + + // async beforeEach + beforeEach(async(() => { + + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + NgbModule.forRoot() + ], + declarations: [ + NumberPickerComponent, + TestComponent, + ], // declare the test component + providers: [ + ChangeDetectorRef, + NumberPickerComponent, + UploaderService + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + + })); + + // synchronous beforeEach + beforeEach(() => { + html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + it('should create NumberPickerComponent', inject([NumberPickerComponent], (app: NumberPickerComponent) => { + expect(app).toBeDefined(); + })); + + beforeEach(() => { + numberPickerFixture = TestBed.createComponent(NumberPickerComponent); + numberPickerComp = numberPickerFixture.componentInstance; // NumberPickerComponent test instance + numberPickerFixture.detectChanges(); + }); + + afterEach(() => { + numberPickerFixture.destroy(); + numberPickerComp = null; + }); + + it('should use default value when component\'s property is not passed', () => { + + expect(numberPickerComp.min).toBe(0); + expect(numberPickerComp.max).toBe(100); + expect(numberPickerComp.size).toBe(1); + expect(numberPickerComp.step).toBe(1); + }); + + it('should increase value', () => { + numberPickerComp.startValue = 0; + numberPickerComp.toggleUp(); + + expect(numberPickerComp.value).toBe(0); + + numberPickerComp.toggleUp(); + + expect(numberPickerComp.value).toBe(1); + }); + + it('should set min value when the value exceeds the max', () => { + numberPickerComp.value = 100; + numberPickerComp.toggleUp(); + + expect(numberPickerComp.value).toBe(0); + + }); + + it('should decrease value', () => { + numberPickerComp.startValue = 2; + numberPickerComp.toggleDown(); + + expect(numberPickerComp.value).toBe(2); + + numberPickerComp.toggleDown(); + + expect(numberPickerComp.value).toBe(1); + }); + + it('should set max value when the value is less than the min', () => { + numberPickerComp.value = 0; + numberPickerComp.toggleDown(); + + expect(numberPickerComp.value).toBe(100); + + }); + + it('should update value on input type', () => { + const de = numberPickerFixture.debugElement.query(By.css('input.form-control')); + const inputEl = de.nativeElement; + + inputEl.value = 99; + inputEl.dispatchEvent(new Event('change')); + + expect(numberPickerComp.value).toBe(99); + }); + + it('should not update value when input value is invalid', () => { + const de = numberPickerFixture.debugElement.query(By.css('input.form-control')); + const inputEl = de.nativeElement; + + inputEl.value = 101; + inputEl.dispatchEvent(new Event('change')); + + expect(numberPickerComp.value).toBe(undefined); + }); + +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + public disabled = false; + public max = 100; + public min = 0; + public initValue = 0; + public showErrorMessages = false; + public size = 4; + public value; + +} diff --git a/src/app/shared/number-picker/number-picker.component.ts b/src/app/shared/number-picker/number-picker.component.ts new file mode 100644 index 0000000000..0bc47e9438 --- /dev/null +++ b/src/app/shared/number-picker/number-picker.component.ts @@ -0,0 +1,144 @@ +import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, SimpleChanges, } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { isEmpty } from '../empty.util'; + +@Component({ + selector: 'ds-number-picker', + styleUrls: ['./number-picker.component.scss'], + templateUrl: './number-picker.component.html', + providers: [ + {provide: NG_VALUE_ACCESSOR, useExisting: NumberPickerComponent, multi: true} + ], +}) + +export class NumberPickerComponent implements OnInit, ControlValueAccessor { + + @Input() step: number; + @Input() min: number; + @Input() max: number; + @Input() size: number; + @Input() placeholder: string; + @Input() name: string; + @Input() disabled: boolean; + @Input() invalid: boolean; + @Input() value: number; + + @Output() selected = new EventEmitter(); + @Output() remove = new EventEmitter(); + @Output() blur = new EventEmitter(); + @Output() change = new EventEmitter(); + @Output() focus = new EventEmitter(); + + startValue: number; + + constructor(private fb: FormBuilder, private cd: ChangeDetectorRef) { + } + + ngOnInit() { + // this.startValue = this.value; + this.step = this.step || 1; + this.min = this.min || 0; + this.max = this.max || 100; + this.size = this.size || 1; + this.disabled = this.disabled || false; + this.invalid = this.invalid || false; + this.cd.detectChanges(); + } + + ngOnChanges(changes: SimpleChanges) { + if (this.value) { + if (changes.max) { + // When the user select a month with < # of days + this.value = this.value > this.max ? this.max : this.value; + } + + } else if (changes.value && changes.value.currentValue === null) { + // When the user delete the inserted value + this.value = null; + } else if (changes.invalid) { + this.invalid = changes.invalid.currentValue; + } + } + + private changeValue(reverse: boolean = false) { + + // First after init + if (isEmpty(this.value)) { + this.value = this.startValue; + } else { + this.startValue = this.value; + + let newValue = this.value; + if (reverse) { + newValue -= this.step; + } else { + newValue += this.step; + } + + if (newValue >= this.min && newValue <= this.max) { + this.value = newValue; + } else { + if (newValue > this.max) { + this.value = this.min; + } else { + this.value = this.max; + } + } + } + + this.emitChange(); + } + + toggleDown() { + this.changeValue(true); + } + + toggleUp() { + this.changeValue(); + } + + update(event) { + try { + const i = Number.parseInt(event.target.value); + + if (i >= this.min && i <= this.max) { + this.value = i; + this.emitChange(); + } else if (event.target.value === null || event.target.value === '') { + this.value = null; + this.emitChange(); + } else { + this.value = undefined; + } + } catch (e) { + this.value = undefined; + } + } + + onBlur(event) { + this.blur.emit(event); + } + + onFocus(event) { + if (this.value) { + this.startValue = this.value; + } + this.focus.emit(event); + } + + writeValue(value) { + this.startValue = value || this.min; + } + + registerOnChange(fn) { + return + } + + registerOnTouched(fn) { + return + } + + emitChange() { + this.change.emit({field: this.name, value: this.value}); + } +} diff --git a/src/app/shared/object.util.spec.ts b/src/app/shared/object.util.spec.ts new file mode 100644 index 0000000000..c910e59e97 --- /dev/null +++ b/src/app/shared/object.util.spec.ts @@ -0,0 +1,87 @@ +import { deleteProperty, difference, hasOnlyEmptyProperties } from './object.util'; + +describe('Object Utils', () => { + let object: any = {}; + let anotherObject: any = {}; + let objectExpected: any = {}; + + describe('deleteProperty', () => { + it('should return object without property \'a\'', () => { + object = {a: 'a', b: 'b'}; + objectExpected = {b: 'b'}; + expect(deleteProperty(object, 'a')).toEqual(objectExpected); + }); + + it('should return same object', () => { + object = {a: 'a', b: 'b'}; + expect(deleteProperty(object, 'c')).toEqual(object); + }); + + }); + + describe('hasOnlyEmptyProperties', () => { + + it('should return true when object is empty', () => { + object = {}; + expect(hasOnlyEmptyProperties(object)).toBe(true); + }); + + it('should return true when object has a null property', () => { + object = {a: null}; + expect(hasOnlyEmptyProperties(object)).toBe(true); + }); + + it('should return true when object property has an empty array as value', () => { + object = {a: []}; + expect(hasOnlyEmptyProperties(object)).toBe(true); + }); + + it('should return true when object property has an empty object as value', () => { + object = {a: {}}; + expect(hasOnlyEmptyProperties(object)).toBe(true); + }); + + it('should return false when object is not empty', () => { + object = {a: 'a', b: 'b'}; + expect(hasOnlyEmptyProperties(object)).toBe(false); + }); + + it('should return false when object has at least a valued property', () => { + object = {a: [], b: 'b'}; + expect(hasOnlyEmptyProperties(object)).toBe(false); + }); + + }); + + describe('difference', () => { + + it('should return an empty object', () => { + object = {}; + anotherObject = {}; + objectExpected = {}; + expect(difference(object, anotherObject)).toEqual(objectExpected); + }); + + it('should return object properties that are not included in the base object', () => { + object = {a: 'a', b: 'b'}; + anotherObject = {a: 'a'}; + objectExpected = {b: 'b'}; + expect(difference(object, anotherObject)).toEqual(objectExpected); + }); + + it('should not return object properties that are included only in the base object', () => { + object = {a: 'a'}; + anotherObject = {a: 'a', b: 'b'}; + objectExpected = {}; + expect(difference(object, anotherObject)).toEqual(objectExpected); + }); + + it('should not return empty object properties that are not included in the base object', () => { + object = {a: 'a', b: {}}; + anotherObject = {a: 'a'}; + objectExpected = {}; + expect(difference(object, anotherObject)).toEqual(objectExpected); + }); + + }); +}); diff --git a/src/app/shared/object.util.ts b/src/app/shared/object.util.ts new file mode 100644 index 0000000000..60ed71096a --- /dev/null +++ b/src/app/shared/object.util.ts @@ -0,0 +1,58 @@ +import { isNotEmpty } from './empty.util'; +import { isEqual, isObject, transform } from 'lodash'; + +/** + * Returns passed object without specified property + */ +export function deleteProperty(object: object, key: string): object { + const {[key]: deletedKey, ...otherKeys} = object; + return otherKeys; +} + +/** + * Returns true if the passed object is empty or has only empty property. + * hasOnlyEmptyProperties({}); // true + * hasOnlyEmptyProperties({a: null}); // true + * hasOnlyEmptyProperties({a: []}); // true + * hasOnlyEmptyProperties({a: [], b: {}); // true + * hasOnlyEmptyProperties({a: 'a', b: 'b'}); // false + * hasOnlyEmptyProperties({a: [], b: 'b'}); // false + */ +export function hasOnlyEmptyProperties(obj: object): boolean { + const objectType = typeof obj; + if (objectType === 'object') { + if (Object.keys(obj).length === 0) { + return true; + } else { + let result = true; + for (const key in obj) { + if (isNotEmpty(obj[key])) { + result = false; + break; + } + } + return result; + } + } +} + +/** + * Returns diff from the base object. + * difference({}, {}); // {} + * difference({a: 'a', b: 'b'}, {a: 'a'}); // {b: 'b'} + * difference({a: 'a', b: {}}, {a: 'a'}); // {} + * difference({a: 'a'}, {a: 'a', b: 'b'}); // {} + */ +export function difference(object: object, base: object) { + const changes = (o, b) => { + return transform(o, (result, value, key) => { + if (!isEqual(value, b[key]) && isNotEmpty(value)) { + const resultValue = (isObject(value) && isObject(b[key])) ? changes(value, b[key]) : value; + if (!hasOnlyEmptyProperties(resultValue)) { + result[key] = resultValue; + } + } + }); + }; + return changes(object, base); +} diff --git a/src/app/shared/pagination/pagination.component.spec.ts b/src/app/shared/pagination/pagination.component.spec.ts index 48767cf582..1bd97086d8 100644 --- a/src/app/shared/pagination/pagination.component.spec.ts +++ b/src/app/shared/pagination/pagination.component.spec.ts @@ -44,16 +44,7 @@ import { EnumKeysPipe } from '../utils/enum-keys-pipe'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { GLOBAL_CONFIG, ENV_CONFIG } from '../../../config'; - -function createTestComponent(html: string, type: { new(...args: any[]): T }): ComponentFixture { - TestBed.overrideComponent(type, { - set: { template: html } - }); - const fixture = TestBed.createComponent(type); - - fixture.detectChanges(); - return fixture as ComponentFixture; -} +import { createTestComponent } from '../testing/utils'; function expectPages(fixture: ComponentFixture, pagesDef: string[]): void { const de = fixture.debugElement.query(By.css('.pagination')); @@ -300,7 +291,7 @@ describe('Pagination component', () => { it('should get parameters from route', () => { - activatedRouteStub = testFixture.debugElement.injector.get(ActivatedRoute) as any;; + activatedRouteStub = testFixture.debugElement.injector.get(ActivatedRoute) as any; activatedRouteStub.testParams = { pageId: 'test', page: 2, diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 8e3d2149d9..57ba7dec4d 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -3,15 +3,20 @@ import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbDatepickerModule, NgbModule, NgbTimepickerModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; import { NgxPaginationModule } from 'ngx-pagination'; +import { FileUploadModule } from 'ng2-file-upload'; + +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; + import { EnumKeysPipe } from './utils/enum-keys-pipe'; import { FileSizePipe } from './utils/file-size-pipe'; import { SafeUrlPipe } from './utils/safe-url-pipe'; +import { ConsolePipe } from './utils/console.pipe'; import { CollectionListElementComponent } from './object-list/collection-list-element/collection-list-element.component'; import { CommunityListElementComponent } from './object-list/community-list-element/community-list-element.component'; @@ -32,7 +37,6 @@ import { ComcolPageHeaderComponent } from './comcol-page-header/comcol-page-head import { ComcolPageLogoComponent } from './comcol-page-logo/comcol-page-logo.component'; import { ErrorComponent } from './error/error.component'; import { LoadingComponent } from './loading/loading.component'; - import { PaginationComponent } from './pagination/pagination.component'; import { ThumbnailComponent } from '../thumbnail/thumbnail.component'; import { SearchFormComponent } from './search-form/search-form.component'; @@ -43,6 +47,14 @@ import { VarDirective } from './utils/var.directive'; import { LogInComponent } from './log-in/log-in.component'; import { AuthNavMenuComponent } from './auth-nav-menu/auth-nav-menu.component'; import { LogOutComponent } from './log-out/log-out.component'; +import { FormComponent } from './form/form.component'; +import { DsDynamicTypeaheadComponent } from './form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component'; +import { DsDynamicScrollableDropdownComponent } from './form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; +import { DsDynamicFormControlComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component'; +import { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form.component'; +import { DynamicFormsCoreModule } from '@ng-dynamic-forms/core'; +import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; +import { TextMaskModule } from 'angular2-text-mask'; import { NotificationComponent } from './notifications/notification/notification.component'; import { NotificationsBoardComponent } from './notifications/notifications-board/notifications-board.component'; import { DragClickDirective } from './utils/drag-click.directive'; @@ -50,17 +62,35 @@ import { TruncatePipe } from './utils/truncate.pipe'; import { TruncatableComponent } from './truncatable/truncatable.component'; import { TruncatableService } from './truncatable/truncatable.service'; import { TruncatablePartComponent } from './truncatable/truncatable-part/truncatable-part.component'; +import { UploaderComponent } from './uploader/uploader.component'; +import { ChipsComponent } from './chips/chips.component'; +import { DsDynamicTagComponent } from './form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component'; +import { DsDynamicListComponent } from './form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component'; +import { DsDynamicGroupComponent } from './form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.components'; +import { SortablejsModule } from 'angular-sortablejs'; +import { NumberPickerComponent } from './number-picker/number-picker.component'; +import { DsDatePickerComponent } from './form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component'; +import { DsDynamicLookupComponent } from './form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component'; import { MockAdminGuard } from './mocks/mock-admin-guard.service'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here CommonModule, + SortablejsModule, + DynamicFormsCoreModule, + DynamicFormsNGBootstrapUIModule, + FileUploadModule, FormsModule, + InfiniteScrollModule, NgbModule, + NgbDatepickerModule, + NgbTimepickerModule, + NgbTypeaheadModule, NgxPaginationModule, ReactiveFormsModule, RouterModule, - TranslateModule + TranslateModule, + TextMaskModule ]; const PIPES = [ @@ -68,19 +98,32 @@ const PIPES = [ EnumKeysPipe, FileSizePipe, SafeUrlPipe, - TruncatePipe + TruncatePipe, + ConsolePipe ]; const COMPONENTS = [ // put shared components here AuthNavMenuComponent, + ChipsComponent, ComcolPageContentComponent, ComcolPageHeaderComponent, ComcolPageLogoComponent, + DsDynamicFormComponent, + DsDynamicFormControlComponent, + DsDynamicListComponent, + DsDynamicLookupComponent, + DsDynamicScrollableDropdownComponent, + DsDynamicTagComponent, + DsDynamicTypeaheadComponent, + DsDynamicGroupComponent, + DsDatePickerComponent, ErrorComponent, + FormComponent, LoadingComponent, LogInComponent, LogOutComponent, + NumberPickerComponent, ObjectListComponent, AbstractListableElementComponent, WrapperListElementComponent, @@ -91,6 +134,7 @@ const COMPONENTS = [ SearchFormComponent, ThumbnailComponent, GridThumbnailComponent, + UploaderComponent, WrapperListElementComponent, ViewModeSwitchComponent, TruncatableComponent, diff --git a/src/app/shared/testing/authority-service-stub.ts b/src/app/shared/testing/authority-service-stub.ts new file mode 100644 index 0000000000..0bc8179fe3 --- /dev/null +++ b/src/app/shared/testing/authority-service-stub.ts @@ -0,0 +1,21 @@ +import { Observable } from 'rxjs/Observable'; +import { IntegrationSearchOptions } from '../../core/integration/models/integration-options.model'; +import { IntegrationData } from '../../core/integration/integration-data'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { AuthorityValueModel } from '../../core/integration/models/authority-value.model'; + +export class AuthorityServiceStub { + + private _payload = [ + Object.assign(new AuthorityValueModel(),{id: 1, display: 'one', value: 1}), + Object.assign(new AuthorityValueModel(),{id: 2, display: 'two', value: 2}), + ]; + + setNewPayload(payload) { + this._payload = payload; + } + + getEntriesByName(options: IntegrationSearchOptions) { + return Observable.of(new IntegrationData(new PageInfo(), this._payload)); + } +} diff --git a/src/app/shared/testing/mock-store.ts b/src/app/shared/testing/mock-store.ts index c619b5aa77..a3bca3c1b5 100644 --- a/src/app/shared/testing/mock-store.ts +++ b/src/app/shared/testing/mock-store.ts @@ -9,12 +9,13 @@ export class MockStore extends BehaviorSubject { } dispatch = (action: Action): void => { - console.info(); - } + // console.info(action); + }; select = (pathOrMapFn: any): Observable => { - return Observable.of(this.getValue()); - } + return this.asObservable() + .map((value) => pathOrMapFn.projector(value)) + }; nextState(_newState: T) { this.next(_newState); diff --git a/src/app/shared/testing/utils.ts b/src/app/shared/testing/utils.ts new file mode 100644 index 0000000000..9343ae50fd --- /dev/null +++ b/src/app/shared/testing/utils.ts @@ -0,0 +1,32 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +/** + * Returns true if a Native Element has a specified css class. + * + * @param element + * the Native Element + * @param className + * the class name to find + */ +export const hasClass = (element: any, className: string): boolean => { + const classes = element.getAttribute('class'); + return classes.split(' ').indexOf(className) !== -1; +}; + +/** + * Creates an instance of a component and returns test fixture. + * + * @param html + * the component's template as html + * @param type + * the type of the component to instantiate + */ +export const createTestComponent = (html: string, type: { new(...args: any[]): T }): ComponentFixture => { + TestBed.overrideComponent(type, { + set: {template: html} + }); + const fixture = TestBed.createComponent(type); + + fixture.detectChanges(); + return fixture as ComponentFixture; +}; diff --git a/src/app/shared/uploader/uploader-options.model.ts b/src/app/shared/uploader/uploader-options.model.ts new file mode 100644 index 0000000000..0bd6412b17 --- /dev/null +++ b/src/app/shared/uploader/uploader-options.model.ts @@ -0,0 +1,13 @@ + +export class UploaderOptions { + /** + * URL of the REST endpoint for file upload. + */ + url: string; + + authToken: string; + + disableMultipart = false; + + itemAlias: string; +} diff --git a/src/app/shared/uploader/uploader.component.html b/src/app/shared/uploader/uploader.component.html new file mode 100644 index 0000000000..f01b1f7a78 --- /dev/null +++ b/src/app/shared/uploader/uploader.component.html @@ -0,0 +1,51 @@ +
+
+
+
+

{{dropOverDocumentMsg | translate}}

+
+
+
+
+
+

+ {{dropMsg | translate}} {{'uploader.or' | translate}} + + +

+
+
+
+ {{'uploader.queue-lenght' | translate}}: {{ uploader?.queue?.length }} | {{ uploader?.queue[0]?.file.name }} +
+ +
+ {{ uploader.progress }}% + {{'uploader.processing' | translate}}... +
+
+
+
+
+
+
+
+
diff --git a/src/app/shared/uploader/uploader.component.scss b/src/app/shared/uploader/uploader.component.scss new file mode 100644 index 0000000000..7e0f6fdd23 --- /dev/null +++ b/src/app/shared/uploader/uploader.component.scss @@ -0,0 +1,40 @@ +@import '../../../styles/_variables.scss'; + +.ds-base-drop-zone { + border: 2px dashed $gray-600; +} + +/* Default class applied to drop zones on over */ +.ds-base-drop-zone-file-over { + border: 2px dashed map-get($theme-colors, primary); +} + +.ds-base-drop-zone p { + height: 42px; +} + +.ds-document-drop-zone { + top: 0; + left: 0; + z-index: -1; +} + +.ds-document-drop-zone-active { + z-index: $drop-zone-area-z-index !important; +} + +.ds-document-drop-zone-inner { + background-color: rgba($white, 0.7); + z-index: $drop-zone-area-inner-z-index; + top: 0; + left: 0; +} + +.ds-document-drop-zone-inner-content { + border: 4px dashed map-get($theme-colors, primary); + z-index: $drop-zone-area-inner-z-index; +} + +.ds-document-drop-zone-inner-content p { + font-size: ($font-size-lg * 2.5); +} diff --git a/src/app/shared/uploader/uploader.component.spec.ts b/src/app/shared/uploader/uploader.component.spec.ts new file mode 100644 index 0000000000..a36bd7241b --- /dev/null +++ b/src/app/shared/uploader/uploader.component.spec.ts @@ -0,0 +1,82 @@ +// Load the implementations that should be tested +import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed, } from '@angular/core/testing'; + +import { ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; + +import { UploaderService } from './uploader.service'; +import { UploaderOptions } from './uploader-options.model'; +import { UploaderComponent } from './uploader.component'; +import { FileUploadModule } from 'ng2-file-upload'; +import { TranslateModule } from '@ngx-translate/core'; +import { createTestComponent } from '../testing/utils'; + +describe('Chips component', () => { + + let testComp: TestComponent; + let testFixture: ComponentFixture; + let html; + + // async beforeEach + beforeEach(async(() => { + + TestBed.configureTestingModule({ + imports: [ + FileUploadModule, + TranslateModule.forRoot() + ], + declarations: [ + UploaderComponent, + TestComponent, + ], // declare the test component + providers: [ + ChangeDetectorRef, + ScrollToService, + UploaderComponent, + UploaderService + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + + })); + + // synchronous beforeEach + beforeEach(() => { + html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + it('should create Uploader Component', inject([UploaderComponent], (app: UploaderComponent) => { + + expect(app).toBeDefined(); + })); + +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + public uploadFilesOptions: UploaderOptions = { + url: 'http://test', + authToken: null, + disableMultipart: false, + itemAlias: null + }; + + /* tslint:disable:no-empty */ + public onBeforeUpload = () => { + }; + + onCompleteItem(event) { + } + + /* tslint:enable:no-empty */ +} diff --git a/src/app/shared/uploader/uploader.component.ts b/src/app/shared/uploader/uploader.component.ts new file mode 100644 index 0000000000..476ba510e0 --- /dev/null +++ b/src/app/shared/uploader/uploader.component.ts @@ -0,0 +1,168 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + HostListener, + Input, + Output, + ViewEncapsulation, +} from '@angular/core' + +import { FileUploader } from 'ng2-file-upload'; +import { Observable } from 'rxjs/Observable'; +import { uniqueId } from 'lodash'; +import { ScrollToConfigOptions, ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; + +import { UploaderOptions } from './uploader-options.model'; +import { isNotEmpty, isUndefined } from '../empty.util'; +import { UploaderService } from './uploader.service'; + +@Component({ + selector: 'ds-uploader', + templateUrl: 'uploader.component.html', + styleUrls: ['uploader.component.scss'], + changeDetection: ChangeDetectionStrategy.Default, + encapsulation: ViewEncapsulation.Emulated +}) + +export class UploaderComponent { + + /** + * The message to show when drag files on the drop zone + */ + @Input() dropMsg: string; + + /** + * The message to show when drag files on the window document + */ + @Input() dropOverDocumentMsg: string; + + /** + * The message to show when drag files on the window document + */ + @Input() enableDragOverDocument: boolean; + + /** + * The function to call before an upload + */ + @Input() onBeforeUpload: () => void; + + /** + * Configuration for the ng2-file-upload component. + */ + @Input() uploadFilesOptions: UploaderOptions; + + /** + * The function to call when upload is completed + */ + @Output() onCompleteItem: EventEmitter = new EventEmitter(); + + public uploader: FileUploader; + public uploaderId: string; + public isOverBaseDropZone = Observable.of(false); + public isOverDocumentDropZone = Observable.of(false); + + @HostListener('window:dragover', ['$event']) + onDragOver(event: any) { + + if (this.enableDragOverDocument && this.uploaderService.isAllowedDragOverPage()) { + // Show drop area on the page + event.preventDefault(); + if ((event.target as any).tagName !== 'HTML') { + this.isOverDocumentDropZone = Observable.of(true); + } + } + } + + constructor(private cdr: ChangeDetectorRef, private scrollToService: ScrollToService, private uploaderService: UploaderService) { + } + + /** + * Method provided by Angular. Invoked after the constructor. + */ + ngOnInit() { + this.uploaderId = 'ds-drag-and-drop-uploader' + uniqueId(); + this.checkConfig(this.uploadFilesOptions); + this.uploader = new FileUploader({ + url: this.uploadFilesOptions.url, + authToken: this.uploadFilesOptions.authToken, + disableMultipart: this.uploadFilesOptions.disableMultipart, + itemAlias: this.uploadFilesOptions.itemAlias, + removeAfterUpload: true, + autoUpload: true + }); + + if (isUndefined(this.enableDragOverDocument)) { + this.enableDragOverDocument = false; + } + if (isUndefined(this.dropMsg)) { + this.dropMsg = 'uploader.drag-message'; + } + if (isUndefined(this.dropOverDocumentMsg)) { + this.dropOverDocumentMsg = 'uploader.drag-message'; + } + } + + ngAfterViewInit() { + // Maybe to remove: needed to avoid CORS issue with our temp upload server + this.uploader.onAfterAddingFile = ((item) => { + item.withCredentials = false; + }); + this.uploader.onBeforeUploadItem = () => { + this.onBeforeUpload(); + this.isOverDocumentDropZone = Observable.of(false); + + // Move page target to the uploader + const config: ScrollToConfigOptions = { + target: this.uploaderId + }; + this.scrollToService.scrollTo(config); + }; + this.uploader.onCompleteItem = (item: any, response: any, status: any, headers: any) => { + if (isNotEmpty(response)) { + const responsePath = JSON.parse(response); + this.onCompleteItem.emit(responsePath); + } + }; + this.uploader.onProgressAll = () => this.onProgress(); + this.uploader.onProgressItem = () => this.onProgress(); + } + + /** + * Called when files are dragged on the base drop area. + */ + public fileOverBase(isOver: boolean): void { + this.isOverBaseDropZone = Observable.of(isOver); + } + + /** + * Called when files are dragged on the window document drop area. + */ + public fileOverDocument(isOver: boolean) { + if (!isOver) { + this.isOverDocumentDropZone = Observable.of(isOver); + } + } + + private onProgress() { + this.cdr.detectChanges(); + } + + /** + * Ensure options passed contains the required properties. + * + * @param fileUploadOptions + * The upload-files options object. + */ + private checkConfig(fileUploadOptions: any) { + const required = ['url', 'authToken', 'disableMultipart', 'itemAlias']; + const missing = required.filter((prop) => { + return !((prop in fileUploadOptions) && fileUploadOptions[prop] !== ''); + }); + if (0 < missing.length) { + throw new Error('UploadFiles: Argument is missing the following required properties: ' + missing.join(', ')); + } + } + +} diff --git a/src/app/shared/uploader/uploader.service.ts b/src/app/shared/uploader/uploader.service.ts new file mode 100644 index 0000000000..548de34f9c --- /dev/null +++ b/src/app/shared/uploader/uploader.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; + +@Injectable() +export class UploaderService { + private _overrideDragOverPage = false; + + public overrideDragOverPage() { + this._overrideDragOverPage = true; + } + + public allowDragOverPage() { + this._overrideDragOverPage = false; + } + + public isAllowedDragOverPage(): boolean { + return !this._overrideDragOverPage; + } +} diff --git a/src/app/shared/utils/console.pipe.ts b/src/app/shared/utils/console.pipe.ts new file mode 100644 index 0000000000..fc672a84ae --- /dev/null +++ b/src/app/shared/utils/console.pipe.ts @@ -0,0 +1,11 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'dsConsole' +}) +export class ConsolePipe implements PipeTransform { + transform(value: any): string { + console.log(value); + return ''; + } +} diff --git a/src/config/form-config.interfaces.ts b/src/config/form-config.interfaces.ts new file mode 100644 index 0000000000..e13fdc1c60 --- /dev/null +++ b/src/config/form-config.interfaces.ts @@ -0,0 +1,9 @@ +import { Config } from './config.interface'; + +export interface ValidatorMap { + [validator: string]: string; +} + +export interface FormConfig extends Config { + validatorMap: ValidatorMap; +} diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index 7c05b78fa5..b623a4bf8c 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -3,12 +3,14 @@ import { ServerConfig } from './server-config.interface'; import { CacheConfig } from './cache-config.interface'; import { UniversalConfig } from './universal-config.interface'; import { INotificationBoardOptions } from './notifications-config.interfaces'; +import { FormConfig } from './form-config.interfaces'; export interface GlobalConfig extends Config { ui: ServerConfig; rest: ServerConfig; production: boolean; cache: CacheConfig; + form: FormConfig; notifications: INotificationBoardOptions; universal: UniversalConfig; gaTrackingId: string; diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index d04e9e8c83..f378c2b7c9 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -3,6 +3,9 @@ $content-spacing: $spacer * 1.5; $button-height: $input-btn-padding-y * 2 + $input-btn-line-height + calculateRem($input-btn-border-width*2); $card-height-percentage:98%; $card-thumbnail-height:240px; +$dropdown-menu-max-height: 200px; +$drop-zone-area-z-index: 1025; +$drop-zone-area-inner-z-index: 1021; $login-logo-height:72px; $login-logo-width:72px; diff --git a/webpack/webpack.server.js b/webpack/webpack.server.js index 20f188dc79..ce0c52602a 100644 --- a/webpack/webpack.server.js +++ b/webpack/webpack.server.js @@ -17,6 +17,10 @@ module.exports = { whitelist: [ /@angular/, /@ng/, + /angular2-text-mask/, + /ng2-file-upload/, + /angular-sortablejs/, + /sortablejs/, /ngx/] })], } diff --git a/yarn.lock b/yarn.lock index 28091288cb..3207959415 100644 --- a/yarn.lock +++ b/yarn.lock @@ -81,6 +81,14 @@ version "1.0.0" resolved "https://registry.yarnpkg.com/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-1.0.0.tgz#8f2ae70db2fe1dcbf5e0acb49dc2b1bbba2be8d2" +"@ng-dynamic-forms/core@5.4.7": + version "5.4.7" + resolved "https://registry.yarnpkg.com/@ng-dynamic-forms/core/-/core-5.4.7.tgz#203dffe4bb31a3599e906990ad9dc2b35714e37a" + +"@ng-dynamic-forms/ui-ng-bootstrap@5.4.7": + version "5.4.7" + resolved "https://registry.yarnpkg.com/@ng-dynamic-forms/ui-ng-bootstrap/-/ui-ng-bootstrap-5.4.7.tgz#66d037a226da96fe84c4dbac98e4dba859c551f8" + "@ngrx/effects@^5.1.0": version "5.1.0" resolved "https://registry.yarnpkg.com/@ngrx/effects/-/effects-5.1.0.tgz#cef84576b2d0333f19188aedfe156fd301bff70a" @@ -110,9 +118,9 @@ tree-kill "^1.0.0" webpack-sources "^1.1.0" -"@nguniversal/express-engine@5.0.0-beta.5": - version "5.0.0-beta.5" - resolved "https://registry.yarnpkg.com/@nguniversal/express-engine/-/express-engine-5.0.0-beta.5.tgz#3cbc0fb7f928012d61240681631581fb0ad327f5" +"@nguniversal/express-engine@5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@nguniversal/express-engine/-/express-engine-5.0.0.tgz#e218fa9fbc7b47379bc106f69ada274e05631243" "@ngx-translate/core@9.1.1": version "9.1.1" @@ -122,6 +130,10 @@ version "2.0.1" resolved "https://registry.yarnpkg.com/@ngx-translate/http-loader/-/http-loader-2.0.1.tgz#aa67788e64bfa8652691a77b022b3b4031209113" +"@nicky-lenaers/ngx-scroll-to@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@nicky-lenaers/ngx-scroll-to/-/ngx-scroll-to-0.6.0.tgz#6d2922f5765a472e3c86499d9e53df5ca210e637" + "@types/acorn@^4.0.3": version "4.0.3" resolved "https://registry.yarnpkg.com/@types/acorn/-/acorn-4.0.3.tgz#d1f3e738dde52536f9aad3d3380d14e448820afd" @@ -425,12 +437,22 @@ angular-idle-preload@2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/angular-idle-preload/-/angular-idle-preload-2.0.4.tgz#7b177c0f52918c090e5c345480b922297cd59a0d" +angular-sortablejs@^2.5.0: + version "2.5.2" + resolved "https://registry.yarnpkg.com/angular-sortablejs/-/angular-sortablejs-2.5.2.tgz#ffd651e47cc93a191db4c023f617db3789fd9af5" + angular2-template-loader@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/angular2-template-loader/-/angular2-template-loader-0.6.2.tgz#c0d44e90fff0fac95e8b23f043acda7fd1c51d7c" dependencies: loader-utils "^0.2.15" +angular2-text-mask@8.0.4: + version "8.0.4" + resolved "https://registry.yarnpkg.com/angular2-text-mask/-/angular2-text-mask-8.0.4.tgz#07e485746cfb9f27e710b27b2785eac4cc4871fc" + dependencies: + text-mask-core "^5.0.0" + angulartics2@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/angulartics2/-/angulartics2-5.2.0.tgz#5bac82d4b6acf798b7db906488861e70b49fe04c" @@ -5599,12 +5621,20 @@ netmask@~1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/netmask/-/netmask-1.0.6.tgz#20297e89d86f6f6400f250d9f4f6b4c1945fcd35" +ng2-file-upload@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ng2-file-upload/-/ng2-file-upload-1.2.1.tgz#5563c5dfd6f43fbfbe815c206e343464a0a6a197" + ngrx-store-freeze@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/ngrx-store-freeze/-/ngrx-store-freeze-0.2.1.tgz#04fb29db33cafda0f2d6ea32adeaac4891b1b27b" dependencies: deep-freeze-strict "^1.1.1" +ngx-infinite-scroll@0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/ngx-infinite-scroll/-/ngx-infinite-scroll-0.8.2.tgz#9cc615c01fbb6307599453c9d9cfb5c1db4fd3e8" + ngx-pagination@3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/ngx-pagination/-/ngx-pagination-3.0.3.tgz#314145263613738d8c544da36cd8dacc5aa89a6f" @@ -8163,6 +8193,10 @@ sort-keys@^1.0.0: dependencies: is-plain-obj "^1.0.0" +sortablejs@1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.7.0.tgz#80a2b2370abd568e1cec8c271131ef30a904fa28" + source-list-map@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.0.tgz#aaa47403f7b245a92fbc97ea08f250d6087ed085" @@ -8602,6 +8636,14 @@ term-size@^1.2.0: dependencies: execa "^0.7.0" +text-mask-core@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/text-mask-core/-/text-mask-core-5.0.1.tgz#86db742bdfe3b4c383bb51a3b4ca342c86110639" + +text-mask-core@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/text-mask-core/-/text-mask-core-5.1.1.tgz#a7f65634e11236818fd36a92668e17bf9368f357" + throttleit@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c"