From 7d005abe2d7b27349f93edfb74357460848a1a8f Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Thu, 10 Jun 2021 09:56:38 +0200 Subject: [PATCH] Feature/37472 dynamic forms v2 flat resources links model (#9332) * Flat resources/_links model * Refactor: moving model formatting to FormsService * unit tests fixes * ValidateForm$ with schema * Adapt PrincipalComponent to flat form model * Default schema ti empty object * Avoid circular dependency --- .../app/core/services/forms/forms.service.ts | 92 +++++++++++++++---- .../dynamic-form/dynamic-form.component.ts | 14 ++- .../dynamic-fields.service.spec.ts | 6 +- .../dynamic-fields/dynamic-fields.service.ts | 51 +--------- .../dynamic-form/dynamic-form.service.spec.ts | 2 +- .../dynamic-form/dynamic-form.service.ts | 15 ++- .../principal/principal.component.ts | 29 ++++-- 7 files changed, 119 insertions(+), 90 deletions(-) diff --git a/frontend/src/app/core/services/forms/forms.service.ts b/frontend/src/app/core/services/forms/forms.service.ts index 3cd79bcfc2..2d39053fee 100644 --- a/frontend/src/app/core/services/forms/forms.service.ts +++ b/frontend/src/app/core/services/forms/forms.service.ts @@ -13,8 +13,8 @@ export class FormsService { private _httpClient:HttpClient, ) { } - submit$(form:FormGroup, resourceEndpoint:string, resourceId?:string, formHttpMethod?: 'post' | 'patch'):Observable { - const modelToSubmit = this.formatModelToSubmit(form.getRawValue()); + submit$(form:FormGroup, resourceEndpoint:string, resourceId?:string, formHttpMethod?: 'post' | 'patch', formSchema?:IOPFormSchema):Observable { + const modelToSubmit = this.formatModelToSubmit(form.getRawValue(), formSchema); const httpMethod = resourceId ? 'patch' : (formHttpMethod || 'post'); const url = resourceId ? `${resourceEndpoint}/${resourceId}` : resourceEndpoint; @@ -38,8 +38,8 @@ export class FormsService { ); } - validateForm$(form:FormGroup, resourceEndpoint:string):Observable { - const modelToSubmit = this.formatModelToSubmit(form.value); + validateForm$(form:FormGroup, resourceEndpoint:string, formSchema?:IOPFormSchema):Observable { + const modelToSubmit = this.formatModelToSubmit(form.value, formSchema); return this._httpClient .post( @@ -56,8 +56,8 @@ export class FormsService { ); } - getFormBackendValidationError$(formValue: {[key:string]: any}, resourceEndpoint:string, limitValidationToKeys?:string | string[]) { - const modelToSubmit = this.formatModelToSubmit(formValue); + getFormBackendValidationError$(formValue: {[key:string]: any}, resourceEndpoint:string, limitValidationToKeys?:string | string[], formSchema?:IOPFormSchema) { + const modelToSubmit = this.formatModelToSubmit(formValue, formSchema); return this._httpClient .post( @@ -76,31 +76,67 @@ export class FormsService { ); } - private formatModelToSubmit(formModel:IOPFormModel):IOPFormModel { - const resources = formModel?._links || {}; + /** HAL resources formatting + * The backend form model/payload contains HAL resources nested in the '_links' property. + * In order to simplify its use, the model is flatted and HAL resources are placed at + * the first level of the model with the 'formatModelToEdit' method. + * 'formatModelToSubmit' places HAL resources model back to the '_links' property and formats them + * in the shape of '{href:hrefValue}' in order to fit the backend expectations. + * */ + private formatModelToSubmit(formModel:IOPFormModel, formSchema:IOPFormSchema = {}):IOPFormModel { + let {_links:linksModel, ...mainModel} = formModel; + const resourcesModel = linksModel || Object.keys(formSchema) + .filter(formSchemaKey => !!formSchema[formSchemaKey]?.type && formSchema[formSchemaKey]?.location === '_links') + .reduce((result, formSchemaKey) => { + const {[formSchemaKey]:keyToRemove, ...mainModelWithoutResource} = mainModel; + mainModel = mainModelWithoutResource; + + return {...result, [formSchemaKey]: formModel[formSchemaKey]}; + }, {}); - const formattedResources = Object - .keys(resources) + const formattedResourcesModel = Object + .keys(resourcesModel) .reduce((result, resourceKey) => { - const resource = resources[resourceKey]; + const resourceModel = resourcesModel[resourceKey]; // Form.payload resources have a HalLinkSource interface while // API resource options have a IAllowedValue interface - const resourceValue = Array.isArray(resource) ? - resource.map(resourceElement => ({ href: resourceElement?.href || resourceElement?._links?.self?.href })) : - { href: resource?.href || resource?._links?.self?.href }; + const formattedResourceModel = Array.isArray(resourceModel) ? + resourceModel.map(resourceElement => ({ href: resourceElement?.href || resourceElement?._links?.self?.href || null })) : + { href: resourceModel?.href || resourceModel?._links?.self?.href || null }; return { ...result, - [resourceKey]: resourceValue, + [resourceKey]: formattedResourceModel, }; }, {}); return { - ...formModel, - _links: formattedResources, + ...mainModel, + _links: formattedResourcesModel, } } + /** HAL resources formatting + * The backend form model/payload contains HAL resources nested in the '_links' property. + * In order to simplify its use, the model is flatted and HAL resources are placed at + * the first level of the model. 'NonValue' values are also removed from the model so + * default values from the DynamicForm are set. + */ + formatModelToEdit(formModel:IOPFormModel = {}):IOPFormModel { + const { _links: resourcesModel, _meta: metaModel, ...otherElements } = formModel; + const otherElementsModel = Object.keys(otherElements) + .filter(key => this.isValue(otherElements[key])) + .reduce((model, key) => ({...model, [key]:otherElements[key]}), {}); + + const model = { + ...otherElementsModel, + _meta: metaModel, + ...this.getFormattedResourcesModel(resourcesModel), + }; + + return model; + } + private handleBackendFormValidationErrors(error:HttpErrorResponse, form:FormGroup):void { const errors:IOPFormError[] = error?.error?._embedded?.errors ? error?.error?._embedded?.errors : [error.error]; @@ -146,4 +182,26 @@ export class FormsService { return formattedErrors; } + + private getFormattedResourcesModel(resourcesModel:IOPFormModel['_links'] = {}):IOPFormModel['_links'] { + return Object.keys(resourcesModel).reduce((result, resourceKey) => { + const resource = resourcesModel[resourceKey]; + // ng-select needs a 'name' in order to show the label + // We need to add it in case of the form payload (HalLinkSource) + const resourceModel = Array.isArray(resource) ? + resource.map(resourceElement => ({...resourceElement, name: resourceElement?.name || resourceElement?.title})) : + {...resource, name: resource?.name || resource?.title}; + + result = { + ...result, + ...this.isValue(resourceModel) && {[resourceKey]: resourceModel}, + }; + + return result; + }, {}); + } + + private isValue(value:any) { + return ![null, undefined, ''].includes(value); + } } diff --git a/frontend/src/app/modules/common/dynamic-forms/components/dynamic-form/dynamic-form.component.ts b/frontend/src/app/modules/common/dynamic-forms/components/dynamic-form/dynamic-form.component.ts index 95155ebb2e..865504e884 100644 --- a/frontend/src/app/modules/common/dynamic-forms/components/dynamic-form/dynamic-form.component.ts +++ b/frontend/src/app/modules/common/dynamic-forms/components/dynamic-form/dynamic-form.component.ts @@ -19,7 +19,6 @@ import { NotificationsService } from "core-app/modules/common/notifications/noti import { DynamicFieldsService } from "core-app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service"; import { FormGroup } from "@angular/forms"; import { UntilDestroyedMixin } from "core-app/helpers/angular/until-destroyed.mixin"; -import { FormsService } from "core-app/core/services/forms/forms.service"; import { HttpErrorResponse } from "@angular/common/http"; /** @@ -143,7 +142,7 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements OnChang @Input() set model(payload:IOPFormModel) { if (!this.innerModel && !payload) { return; } - const formattedModel = this._dynamicFieldsService.getFormattedFieldsModel(payload); + const formattedModel = this._dynamicFormService.formatModelToEdit(payload); this.form.patchValue(formattedModel); } @@ -192,7 +191,6 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements OnChang private _I18n:I18nService, private _pathHelperService:PathHelperService, private _notificationsService:NotificationsService, - private _formsService:FormsService, private _changeDetectorRef:ChangeDetectorRef, ) { super(); @@ -260,7 +258,7 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements OnChang throw new Error(this.noPathToSubmitToError); } - return this._formsService.validateForm$(this.form, this.formEndpoint); + return this._dynamicFormService.validateForm$(this.form, this.formEndpoint); } private initializeDynamicForm( @@ -281,7 +279,7 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements OnChang } if (settings) { - this.setupDynamicFormFromSettings(); + this.setupDynamicFormFromSettings(settings); } else { this.setupDynamicFormFromBackend(this.formEndpoint, resourceId, payload); } @@ -313,11 +311,11 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements OnChang .subscribe(dynamicFormSettings => this.setupDynamicForm(dynamicFormSettings)); } - private setupDynamicFormFromSettings() { + private setupDynamicFormFromSettings(settings:IOPFormSettings) { const formattedSettings:IOPFormSettingsResource = { _embedded: { - payload: this.settings!.payload, - schema: this.settings!.schema, + payload: settings?.payload, + schema: settings?.schema, }, }; const dynamicFormSettings = this._dynamicFormService.getSettings(formattedSettings); diff --git a/frontend/src/app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service.spec.ts b/frontend/src/app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service.spec.ts index 11bb7d8825..ba95c81204 100644 --- a/frontend/src/app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service.spec.ts +++ b/frontend/src/app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service.spec.ts @@ -78,7 +78,7 @@ describe('DynamicFieldsService', () => { expect(fieldsSchemas.length).toBe(2, 'should return only writable field schemas'); expect(fieldsSchemas[0].key).toBe('name', 'should place the correct key on primitives'); - expect(fieldsSchemas[1].key).toBe('_links.parent', 'should place the correct key on resources'); + expect(fieldsSchemas[1].key).toBe('parent', 'should place the correct key on resources'); }); it('should format the form model (add the name property to resources (_links: single and multiple))', () => { @@ -143,8 +143,8 @@ describe('DynamicFieldsService', () => { // @ts-ignore const formModel = service.getModel(formPayload); const titleName = formModel.title; - const parentProjectName = !Array.isArray(formModel._links!.parent) && formModel._links!.parent!.name; - const childrenProjectsNames = Array.isArray(formModel._links!.children) && formModel._links!.children!.map((childProject: IOPFieldModel) => childProject.name); + const parentProjectName = !Array.isArray(formModel!.parent) && formModel!.parent!.name; + const childrenProjectsNames = Array.isArray(formModel!.children) && formModel!.children!.map((childProject: IOPFieldModel) => childProject.name); expect(titleName).toBe('Project 1', 'should add the payload value on primitives'); expect(parentProjectName).toEqual('Parent project', 'should add a name property on resources'); diff --git a/frontend/src/app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service.ts b/frontend/src/app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service.ts index 1041ea9c82..0c4dffba87 100644 --- a/frontend/src/app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service.ts +++ b/frontend/src/app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service.ts @@ -10,6 +10,7 @@ import { map } from "rxjs/operators"; import { HttpClient } from "@angular/common/http"; import { I18nService } from "core-app/modules/common/i18n/i18n.service"; import { HalLink } from "core-app/modules/hal/hal-link/hal-link"; +import { FormsService } from "core-app/core/services/forms/forms.service"; @Injectable() @@ -119,6 +120,7 @@ export class DynamicFieldsService { constructor( private httpClient:HttpClient, private I18n:I18nService, + private formsService:FormsService, ) { } @@ -137,28 +139,7 @@ export class DynamicFieldsService { } getModel(formPayload:IOPFormModel):IOPFormModel { - return this.getFormattedFieldsModel(formPayload); - } - - getFormattedFieldsModel(formModel:IOPFormModel = {}):IOPFormModel { - const { _links: resourcesModel, _meta: metaModel, ...otherElements } = formModel; - const otherElementsModel = Object.keys(otherElements).reduce((model, key) => { - const elementValue = otherElements[key]; - - if (this.isValue(elementValue)) { - model = { ...model, [key]: elementValue } - } - - return model; - }, {}) - - const model = { - ...otherElementsModel, - _meta: metaModel, - _links: this.getFormattedResourcesModel(resourcesModel), - }; - - return model; + return this.formsService.formatModelToEdit(formPayload); } getFormlyFormWithFieldGroups(fieldGroups:IDynamicFieldGroupConfig[] = [], formFields:IOPFormlyFieldSettings[] = []):IOPFormlyFieldSettings[] { @@ -183,7 +164,6 @@ export class DynamicFieldsService { private getAttributeKey(fieldSchema:IOPFieldSchema, key:string):string { switch (fieldSchema.location) { - case "_links": case "_meta": return `${fieldSchema.location}.${key}`; default: @@ -195,27 +175,6 @@ export class DynamicFieldsService { return !!schemaValue?.type; } - private getFormattedResourcesModel(resourcesModel:IOPFormModel['_links'] = {}):IOPFormModel['_links'] { - return Object.keys(resourcesModel).reduce((result, resourceKey) => { - const resource = resourcesModel[resourceKey]; - // ng-select needs a 'name' in order to show the label - // We need to add it in case of the form payload (HalLinkSource) - const resourceModel = Array.isArray(resource) ? - resource.map(resourceElement => resourceElement?.href && { - ...resourceElement, - name: resourceElement?.name || resourceElement?.title - }) : - resource?.href && { ...resource, name: resource?.name || resource?.title }; - - result = { - ...result, - ...this.isValue(resourceModel) && { [resourceKey]: resourceModel }, - }; - - return result; - }, {}); - } - private getFormlyFieldConfig(fieldSchema:IOPFieldSchemaWithKey, formPayload:IOPFormModel):IOPFormlyFieldSettings|null { const { key, name: label, required, hasDefault, minLength, maxLength } = fieldSchema; const fieldTypeConfigSearch = this.getFieldTypeConfig(fieldSchema); @@ -427,9 +386,5 @@ export class DynamicFieldsService { private isMultiSelectField(field:IOPFieldSchemaWithKey) { return field?.type?.startsWith('[]'); } - - private isValue(value:any) { - return ![null, undefined, ''].includes(value); - } } diff --git a/frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.spec.ts b/frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.spec.ts index 3478934b8a..4d22d48fca 100644 --- a/frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.spec.ts +++ b/frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.spec.ts @@ -168,6 +168,6 @@ describe('DynamicFormService', () => { .submit$(dynamicForm, testFormUrl) .subscribe(); - expect(formsService.submit$).toHaveBeenCalledWith(dynamicForm, testFormUrl, undefined, undefined); + expect(formsService.submit$).toHaveBeenCalledWith(dynamicForm, testFormUrl, undefined, undefined, undefined); }); }); diff --git a/frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.ts b/frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.ts index ba04b92b87..1ae8b64348 100644 --- a/frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.ts +++ b/frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.ts @@ -14,6 +14,7 @@ import { FormsService } from "core-app/core/services/forms/forms.service"; @Injectable() export class DynamicFormService { dynamicForm:FormlyForm; + formSchema:IOPFormSchema; constructor( private _httpClient:HttpClient, @@ -45,18 +46,26 @@ export class DynamicFormService { } getSettings(formConfig:IOPFormSettingsResource):IOPDynamicFormSettings { - const formSchema = formConfig._embedded?.schema; + this.formSchema = formConfig._embedded?.schema; const formPayload = formConfig._embedded?.payload; const dynamicForm = { form: new FormGroup({}), - fields: this._dynamicFieldsService.getConfig(formSchema, formPayload), + fields: this._dynamicFieldsService.getConfig(this.formSchema, formPayload), model: this._dynamicFieldsService.getModel(formPayload), }; return dynamicForm; } + formatModelToEdit(formModel:IOPFormModel):IOPFormModel { + return this._formsService.formatModelToEdit(formModel); + } + + validateForm$(form:FormGroup, resourceEndpoint:string) { + return this._formsService.validateForm$(form, resourceEndpoint, this.formSchema); + }; + submit$(form:FormGroup, resourceEndpoint:string, resourceId?:string, formHttpMethod?: 'post' | 'patch') { - return this._formsService.submit$(form, resourceEndpoint, resourceId, formHttpMethod); + return this._formsService.submit$(form, resourceEndpoint, resourceId, formHttpMethod, this.formSchema); } } \ No newline at end of file diff --git a/frontend/src/app/modules/invite-user-modal/principal/principal.component.ts b/frontend/src/app/modules/invite-user-modal/principal/principal.component.ts index e3eb4f9164..48abd99e08 100644 --- a/frontend/src/app/modules/invite-user-modal/principal/principal.component.ts +++ b/frontend/src/app/modules/invite-user-modal/principal/principal.component.ts @@ -182,16 +182,25 @@ export class PrincipalComponent implements OnInit { // The code below transforms the model value as it comes from the dynamic form to the value accepted by the API. // This is not just necessary for submit, but also so that we can reseed the initial values to the payload // when going back to this step after having completed it once. - const links = this.customFields!._links || {}; - const customFields = { - ...this.customFields!, - _links: Object.keys(links).reduce((cfs, name) => ({ - ...cfs, - [name]: Array.isArray(links[name]) - ? links[name].map((opt: any) => opt._links ? opt._links.self : opt) - : (links[name]._links ? links[name]._links.self : links[name]) - }), {}), - }; + const fieldsSchema = this.userDynamicFieldConfig.schema || {}; + const customFields = Object.keys(fieldsSchema) + .reduce((result, fieldKey) => { + let fieldSchema = fieldsSchema[fieldKey]; + let fieldValue = this.customFields[fieldKey]; + + if (fieldSchema.location === '_links') { + fieldValue = Array.isArray(fieldValue) + ? fieldValue.map((opt: any) => opt._links ? opt._links.self : opt) + : (fieldValue._links ? fieldValue._links.self : fieldValue) + } + + result = { + ...result, + [fieldKey]: fieldValue, + }; + + return result; + }, {}); this.save.emit({ principalData: {