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. 92
      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. 29
      frontend/src/app/modules/invite-user-modal/principal/principal.component.ts

@ -13,8 +13,8 @@ export class FormsService {
private _httpClient:HttpClient,
) { }
submit$(form:FormGroup, resourceEndpoint:string, resourceId?:string, formHttpMethod?: 'post' | 'patch'):Observable<any> {
const modelToSubmit = this.formatModelToSubmit(form.getRawValue());
submit$(form:FormGroup, resourceEndpoint:string, resourceId?:string, formHttpMethod?: 'post' | 'patch', formSchema?:IOPFormSchema):Observable<any> {
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<any> {
const modelToSubmit = this.formatModelToSubmit(form.value);
validateForm$(form:FormGroup, resourceEndpoint:string, formSchema?:IOPFormSchema):Observable<any> {
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);
}
}

@ -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);

@ -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');

@ -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);
}
}

@ -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);
});
});

@ -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);
}
}

@ -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: {

Loading…
Cancel
Save