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
revert-9332-feature/37472-dynamic-forms-v2-flat-resources_links-model
Aleix Suau 3 years ago committed by GitHub
parent 7bbdcd8d1e
commit 7d005abe2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 94
      frontend/src/app/core/services/forms/forms.service.ts
  2. 14
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-form/dynamic-form.component.ts
  3. 6
      frontend/src/app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service.spec.ts
  4. 51
      frontend/src/app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service.ts
  5. 2
      frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.spec.ts
  6. 15
      frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.ts
  7. 27
      frontend/src/app/modules/invite-user-modal/principal/principal.component.ts

@ -13,8 +13,8 @@ export class FormsService {
private _httpClient:HttpClient, private _httpClient:HttpClient,
) { } ) { }
submit$(form:FormGroup, resourceEndpoint:string, resourceId?:string, formHttpMethod?: 'post' | 'patch'):Observable<any> { submit$(form:FormGroup, resourceEndpoint:string, resourceId?:string, formHttpMethod?: 'post' | 'patch', formSchema?:IOPFormSchema):Observable<any> {
const modelToSubmit = this.formatModelToSubmit(form.getRawValue()); const modelToSubmit = this.formatModelToSubmit(form.getRawValue(), formSchema);
const httpMethod = resourceId ? 'patch' : (formHttpMethod || 'post'); const httpMethod = resourceId ? 'patch' : (formHttpMethod || 'post');
const url = resourceId ? `${resourceEndpoint}/${resourceId}` : resourceEndpoint; const url = resourceId ? `${resourceEndpoint}/${resourceId}` : resourceEndpoint;
@ -38,8 +38,8 @@ export class FormsService {
); );
} }
validateForm$(form:FormGroup, resourceEndpoint:string):Observable<any> { validateForm$(form:FormGroup, resourceEndpoint:string, formSchema?:IOPFormSchema):Observable<any> {
const modelToSubmit = this.formatModelToSubmit(form.value); const modelToSubmit = this.formatModelToSubmit(form.value, formSchema);
return this._httpClient return this._httpClient
.post( .post(
@ -56,8 +56,8 @@ export class FormsService {
); );
} }
getFormBackendValidationError$(formValue: {[key:string]: any}, resourceEndpoint:string, limitValidationToKeys?:string | string[]) { getFormBackendValidationError$(formValue: {[key:string]: any}, resourceEndpoint:string, limitValidationToKeys?:string | string[], formSchema?:IOPFormSchema) {
const modelToSubmit = this.formatModelToSubmit(formValue); const modelToSubmit = this.formatModelToSubmit(formValue, formSchema);
return this._httpClient return this._httpClient
.post( .post(
@ -76,29 +76,65 @@ export class FormsService {
); );
} }
private formatModelToSubmit(formModel:IOPFormModel):IOPFormModel { /** HAL resources formatting
const resources = formModel?._links || {}; * 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 const formattedResourcesModel = Object
.keys(resources) .keys(resourcesModel)
.reduce((result, resourceKey) => { .reduce((result, resourceKey) => {
const resource = resources[resourceKey]; const resourceModel = resourcesModel[resourceKey];
// Form.payload resources have a HalLinkSource interface while // Form.payload resources have a HalLinkSource interface while
// API resource options have a IAllowedValue interface // API resource options have a IAllowedValue interface
const resourceValue = Array.isArray(resource) ? const formattedResourceModel = Array.isArray(resourceModel) ?
resource.map(resourceElement => ({ href: resourceElement?.href || resourceElement?._links?.self?.href })) : resourceModel.map(resourceElement => ({ href: resourceElement?.href || resourceElement?._links?.self?.href || null })) :
{ href: resource?.href || resource?._links?.self?.href }; { href: resourceModel?.href || resourceModel?._links?.self?.href || null };
return { return {
...result, ...result,
[resourceKey]: resourceValue, [resourceKey]: formattedResourceModel,
}; };
}, {}); }, {});
return { return {
...formModel, ...mainModel,
_links: formattedResources, _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 { private handleBackendFormValidationErrors(error:HttpErrorResponse, form:FormGroup):void {
@ -146,4 +182,26 @@ export class FormsService {
return formattedErrors; 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);
}
} }

@ -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 { DynamicFieldsService } from "core-app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service";
import { FormGroup } from "@angular/forms"; import { FormGroup } from "@angular/forms";
import { UntilDestroyedMixin } from "core-app/helpers/angular/until-destroyed.mixin"; 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"; import { HttpErrorResponse } from "@angular/common/http";
/** /**
@ -143,7 +142,7 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements OnChang
@Input() set model(payload:IOPFormModel) { @Input() set model(payload:IOPFormModel) {
if (!this.innerModel && !payload) { return; } if (!this.innerModel && !payload) { return; }
const formattedModel = this._dynamicFieldsService.getFormattedFieldsModel(payload); const formattedModel = this._dynamicFormService.formatModelToEdit(payload);
this.form.patchValue(formattedModel); this.form.patchValue(formattedModel);
} }
@ -192,7 +191,6 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements OnChang
private _I18n:I18nService, private _I18n:I18nService,
private _pathHelperService:PathHelperService, private _pathHelperService:PathHelperService,
private _notificationsService:NotificationsService, private _notificationsService:NotificationsService,
private _formsService:FormsService,
private _changeDetectorRef:ChangeDetectorRef, private _changeDetectorRef:ChangeDetectorRef,
) { ) {
super(); super();
@ -260,7 +258,7 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements OnChang
throw new Error(this.noPathToSubmitToError); throw new Error(this.noPathToSubmitToError);
} }
return this._formsService.validateForm$(this.form, this.formEndpoint); return this._dynamicFormService.validateForm$(this.form, this.formEndpoint);
} }
private initializeDynamicForm( private initializeDynamicForm(
@ -281,7 +279,7 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements OnChang
} }
if (settings) { if (settings) {
this.setupDynamicFormFromSettings(); this.setupDynamicFormFromSettings(settings);
} else { } else {
this.setupDynamicFormFromBackend(this.formEndpoint, resourceId, payload); this.setupDynamicFormFromBackend(this.formEndpoint, resourceId, payload);
} }
@ -313,11 +311,11 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements OnChang
.subscribe(dynamicFormSettings => this.setupDynamicForm(dynamicFormSettings)); .subscribe(dynamicFormSettings => this.setupDynamicForm(dynamicFormSettings));
} }
private setupDynamicFormFromSettings() { private setupDynamicFormFromSettings(settings:IOPFormSettings) {
const formattedSettings:IOPFormSettingsResource = { const formattedSettings:IOPFormSettingsResource = {
_embedded: { _embedded: {
payload: this.settings!.payload, payload: settings?.payload,
schema: this.settings!.schema, schema: settings?.schema,
}, },
}; };
const dynamicFormSettings = this._dynamicFormService.getSettings(formattedSettings); const dynamicFormSettings = this._dynamicFormService.getSettings(formattedSettings);

@ -78,7 +78,7 @@ describe('DynamicFieldsService', () => {
expect(fieldsSchemas.length).toBe(2, 'should return only writable field schemas'); 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[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))', () => { it('should format the form model (add the name property to resources (_links: single and multiple))', () => {
@ -143,8 +143,8 @@ describe('DynamicFieldsService', () => {
// @ts-ignore // @ts-ignore
const formModel = service.getModel(formPayload); const formModel = service.getModel(formPayload);
const titleName = formModel.title; const titleName = formModel.title;
const parentProjectName = !Array.isArray(formModel._links!.parent) && formModel._links!.parent!.name; const parentProjectName = !Array.isArray(formModel!.parent) && formModel!.parent!.name;
const childrenProjectsNames = Array.isArray(formModel._links!.children) && formModel._links!.children!.map((childProject: IOPFieldModel) => childProject.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(titleName).toBe('Project 1', 'should add the payload value on primitives');
expect(parentProjectName).toEqual('Parent project', 'should add a name property on resources'); expect(parentProjectName).toEqual('Parent project', 'should add a name property on resources');

@ -10,6 +10,7 @@ import { map } from "rxjs/operators";
import { HttpClient } from "@angular/common/http"; import { HttpClient } from "@angular/common/http";
import { I18nService } from "core-app/modules/common/i18n/i18n.service"; import { I18nService } from "core-app/modules/common/i18n/i18n.service";
import { HalLink } from "core-app/modules/hal/hal-link/hal-link"; import { HalLink } from "core-app/modules/hal/hal-link/hal-link";
import { FormsService } from "core-app/core/services/forms/forms.service";
@Injectable() @Injectable()
@ -119,6 +120,7 @@ export class DynamicFieldsService {
constructor( constructor(
private httpClient:HttpClient, private httpClient:HttpClient,
private I18n:I18nService, private I18n:I18nService,
private formsService:FormsService,
) { ) {
} }
@ -137,28 +139,7 @@ export class DynamicFieldsService {
} }
getModel(formPayload:IOPFormModel):IOPFormModel { getModel(formPayload:IOPFormModel):IOPFormModel {
return this.getFormattedFieldsModel(formPayload); return this.formsService.formatModelToEdit(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;
} }
getFormlyFormWithFieldGroups(fieldGroups:IDynamicFieldGroupConfig[] = [], formFields:IOPFormlyFieldSettings[] = []):IOPFormlyFieldSettings[] { getFormlyFormWithFieldGroups(fieldGroups:IDynamicFieldGroupConfig[] = [], formFields:IOPFormlyFieldSettings[] = []):IOPFormlyFieldSettings[] {
@ -183,7 +164,6 @@ export class DynamicFieldsService {
private getAttributeKey(fieldSchema:IOPFieldSchema, key:string):string { private getAttributeKey(fieldSchema:IOPFieldSchema, key:string):string {
switch (fieldSchema.location) { switch (fieldSchema.location) {
case "_links":
case "_meta": case "_meta":
return `${fieldSchema.location}.${key}`; return `${fieldSchema.location}.${key}`;
default: default:
@ -195,27 +175,6 @@ export class DynamicFieldsService {
return !!schemaValue?.type; 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 { private getFormlyFieldConfig(fieldSchema:IOPFieldSchemaWithKey, formPayload:IOPFormModel):IOPFormlyFieldSettings|null {
const { key, name: label, required, hasDefault, minLength, maxLength } = fieldSchema; const { key, name: label, required, hasDefault, minLength, maxLength } = fieldSchema;
const fieldTypeConfigSearch = this.getFieldTypeConfig(fieldSchema); const fieldTypeConfigSearch = this.getFieldTypeConfig(fieldSchema);
@ -427,9 +386,5 @@ export class DynamicFieldsService {
private isMultiSelectField(field:IOPFieldSchemaWithKey) { private isMultiSelectField(field:IOPFieldSchemaWithKey) {
return field?.type?.startsWith('[]'); return field?.type?.startsWith('[]');
} }
private isValue(value:any) {
return ![null, undefined, ''].includes(value);
}
} }

@ -168,6 +168,6 @@ describe('DynamicFormService', () => {
.submit$(dynamicForm, testFormUrl) .submit$(dynamicForm, testFormUrl)
.subscribe(); .subscribe();
expect(formsService.submit$).toHaveBeenCalledWith(dynamicForm, testFormUrl, undefined, undefined); expect(formsService.submit$).toHaveBeenCalledWith(dynamicForm, testFormUrl, undefined, undefined, undefined);
}); });
}); });

@ -14,6 +14,7 @@ import { FormsService } from "core-app/core/services/forms/forms.service";
@Injectable() @Injectable()
export class DynamicFormService { export class DynamicFormService {
dynamicForm:FormlyForm; dynamicForm:FormlyForm;
formSchema:IOPFormSchema;
constructor( constructor(
private _httpClient:HttpClient, private _httpClient:HttpClient,
@ -45,18 +46,26 @@ export class DynamicFormService {
} }
getSettings(formConfig:IOPFormSettingsResource):IOPDynamicFormSettings { getSettings(formConfig:IOPFormSettingsResource):IOPDynamicFormSettings {
const formSchema = formConfig._embedded?.schema; this.formSchema = formConfig._embedded?.schema;
const formPayload = formConfig._embedded?.payload; const formPayload = formConfig._embedded?.payload;
const dynamicForm = { const dynamicForm = {
form: new FormGroup({}), form: new FormGroup({}),
fields: this._dynamicFieldsService.getConfig(formSchema, formPayload), fields: this._dynamicFieldsService.getConfig(this.formSchema, formPayload),
model: this._dynamicFieldsService.getModel(formPayload), model: this._dynamicFieldsService.getModel(formPayload),
}; };
return dynamicForm; 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') { 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);
} }
} }

@ -182,17 +182,26 @@ 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. // 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 // 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. // when going back to this step after having completed it once.
const links = this.customFields!._links || {}; const fieldsSchema = this.userDynamicFieldConfig.schema || {};
const customFields = { const customFields = Object.keys(fieldsSchema)
...this.customFields!, .reduce((result, fieldKey) => {
_links: Object.keys(links).reduce((cfs, name) => ({ let fieldSchema = fieldsSchema[fieldKey];
...cfs, let fieldValue = this.customFields[fieldKey];
[name]: Array.isArray(links[name])
? links[name].map((opt: any) => opt._links ? opt._links.self : opt) if (fieldSchema.location === '_links') {
: (links[name]._links ? links[name]._links.self : links[name]) 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({ this.save.emit({
principalData: { principalData: {
customFields, customFields,

Loading…
Cancel
Save