src/app/core/common-components/entity-form/entity-form.service.ts
This service provides helper functions for creating tables or forms for an entity as well as saving new changes correctly to the entity.
Methods |
|
constructor(fb: FormBuilder, entityMapper: EntityMapperService, entitySchemaService: EntitySchemaService, dynamicValidator: DynamicValidatorsService, ability: EntityAbility, unsavedChanges: UnsavedChangesService, defaultValueService: DefaultValueService, router: Router)
|
|||||||||||||||||||||||||||
Parameters :
|
Public Async createEntityForm | |||||||||||||||||||||||||
createEntityForm(formFields: ColumnConfig[], entity: T, forTable, withPermissionCheck)
|
|||||||||||||||||||||||||
Type parameters :
|
|||||||||||||||||||||||||
Creates a form with the formFields and the existing values from the entity. Missing fields in the formFields are filled with schema information.
Parameters :
Returns :
Promise<EntityForm<T>>
|
Public extendFormFieldConfig | ||||||||||||||||
extendFormFieldConfig(formField: ColumnConfig, entityType: EntityConstructor, forTable)
|
||||||||||||||||
Uses schema information to fill missing fields in the FormFieldConfig.
Parameters :
Returns :
FormFieldConfig
|
resetForm | |||||||||
resetForm(entityForm: EntityForm<E>, entity: E)
|
|||||||||
Type parameters :
|
|||||||||
Parameters :
Returns :
void
|
Public Async saveChanges | ||||||||||||
saveChanges(entityForm: EntityForm<T>, entity: T)
|
||||||||||||
Type parameters :
|
||||||||||||
This function applies the changes of the formGroup to the entity. If the form is invalid or the entity does not pass validation after applying the changes, an error will be thrown. The input entity will not be modified but a copy of it will be returned in case of success.
Parameters :
Returns :
Promise<T>
a copy of the input entity with the changes from the form group |
import { EventEmitter, Injectable } from "@angular/core";
import {
FormBuilder,
FormControl,
FormControlOptions,
FormGroup,
ɵElement,
} from "@angular/forms";
import { ColumnConfig, FormFieldConfig, toFormFieldConfig } from "./FormConfig";
import { Entity, EntityConstructor } from "../../entity/model/entity";
import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service";
import { EntitySchemaService } from "../../entity/schema/entity-schema.service";
import { DynamicValidatorsService } from "./dynamic-form-validators/dynamic-validators.service";
import { EntityAbility } from "../../permissions/ability/entity-ability";
import { InvalidFormFieldError } from "./invalid-form-field.error";
import { UnsavedChangesService } from "../../entity-details/form/unsaved-changes.service";
import { ActivationStart, Router } from "@angular/router";
import { Subscription } from "rxjs";
import { filter } from "rxjs/operators";
import { EntitySchemaField } from "../../entity/schema/entity-schema-field";
import { DefaultValueService } from "../../default-values/default-value.service";
/**
* These are utility types that allow to define the type of `FormGroup` the way it is returned by `EntityFormService.create`
*/
export type TypedFormGroup<T> = FormGroup<{
[K in keyof T]: ɵElement<T[K], null>;
}>;
export type EntityFormGroup<T extends Entity> = TypedFormGroup<Partial<T>>;
export interface EntityForm<T extends Entity> {
formGroup: EntityFormGroup<T>;
entity: T;
/**
* (possible overridden) field configurations for that form
*/
fieldConfigs: FormFieldConfig[];
onFormStateChange: EventEmitter<"saved" | "cancelled">;
/**
* map of field ids to the current value to be inherited from the referenced parent entities' field
*/
inheritedParentValues: Map<string, any>;
watcher: Map<string, Subscription>;
}
/**
* This service provides helper functions for creating tables or forms for an entity as well as saving
* new changes correctly to the entity.
*/
@Injectable({ providedIn: "root" })
export class EntityFormService {
private subscriptions: Subscription[] = [];
constructor(
private fb: FormBuilder,
private entityMapper: EntityMapperService,
private entitySchemaService: EntitySchemaService,
private dynamicValidator: DynamicValidatorsService,
private ability: EntityAbility,
private unsavedChanges: UnsavedChangesService,
private defaultValueService: DefaultValueService,
router: Router,
) {
router.events
.pipe(filter((e) => e instanceof ActivationStart))
.subscribe(() => {
// Clean up everything once navigation happens
this.subscriptions.forEach((sub) => sub.unsubscribe());
this.subscriptions = [];
this.unsavedChanges.pending = false;
});
}
/**
* Uses schema information to fill missing fields in the FormFieldConfig.
* @param formField
* @param entityType
* @param forTable
*/
public extendFormFieldConfig(
formField: ColumnConfig,
entityType: EntityConstructor,
forTable = false,
): FormFieldConfig {
const fullField = toFormFieldConfig(formField);
try {
return this.addSchemaToFormField(
fullField,
entityType.schema.get(fullField.id),
forTable,
);
} catch (err) {
throw new Error(
`Could not create form config for ${fullField.id}: ${err}`,
);
}
}
private addSchemaToFormField(
formField: FormFieldConfig,
propertySchema: EntitySchemaField | undefined,
forTable: boolean,
): FormFieldConfig {
// formField config has precedence over schema
const fullField = Object.assign(
{},
JSON.parse(JSON.stringify(propertySchema ?? {})), // deep copy to avoid modifying the original schema
formField,
);
fullField.editComponent =
fullField.editComponent ||
this.entitySchemaService.getComponent(propertySchema, "edit");
fullField.viewComponent =
fullField.viewComponent ||
this.entitySchemaService.getComponent(propertySchema, "view");
if (forTable) {
fullField.forTable = true;
fullField.label =
fullField.label || fullField.labelShort || fullField.label;
delete fullField.description;
} else {
fullField.forTable = false;
fullField.label =
fullField.label || fullField.label || fullField.labelShort;
}
return fullField;
}
/**
* Creates a form with the formFields and the existing values from the entity.
* Missing fields in the formFields are filled with schema information.
* @param formFields
* @param entity
* @param forTable
* @param withPermissionCheck if true, fields without 'update' permissions will stay disabled when enabling form
*/
public async createEntityForm<T extends Entity>(
formFields: ColumnConfig[],
entity: T,
forTable = false,
withPermissionCheck = true,
): Promise<EntityForm<T>> {
const fields = formFields.map((f) =>
this.extendFormFieldConfig(f, entity.getConstructor(), forTable),
);
const typedFormGroup: TypedFormGroup<Partial<T>> = this.createFormGroup(
fields,
entity,
withPermissionCheck,
);
const entityForm: EntityForm<T> = {
formGroup: typedFormGroup,
entity: entity,
fieldConfigs: fields,
onFormStateChange: new EventEmitter(),
inheritedParentValues: new Map(),
watcher: new Map(),
};
await this.defaultValueService.handleEntityForm(entityForm, entity);
return entityForm;
}
/**
*
* @param formFields The field configs in their final form (will not be extended by schema automatically)
* @param entity
* @param withPermissionCheck
* @private
*/
private createFormGroup<T extends Entity>(
formFields: FormFieldConfig[],
entity: T,
withPermissionCheck = true,
): EntityFormGroup<T> {
const formConfig = {};
const copy = entity.copy();
formFields = formFields.filter((f) =>
entity.getSchema().has(toFormFieldConfig(f).id),
);
for (const f of formFields) {
this.addFormControlConfig(formConfig, f, copy);
}
const group = this.fb.group<Partial<T>>(formConfig);
const valueChangesSubscription = group.valueChanges.subscribe(
() => (this.unsavedChanges.pending = group.dirty),
);
this.subscriptions.push(valueChangesSubscription);
if (withPermissionCheck) {
this.disableReadOnlyFormControls(group, entity);
const statusChangesSubscription = group.statusChanges
.pipe(filter((status) => status !== "DISABLED"))
.subscribe(() => this.disableReadOnlyFormControls(group, entity));
this.subscriptions.push(statusChangesSubscription);
}
return group;
}
/**
* Add a property with form control initialization config to the given formConfig object.
* @param formConfig
* @param field The final field config (will not be automatically extended by schema)
* @param entity
* @private
*/
private addFormControlConfig(
formConfig: { [key: string]: FormControl },
field: FormFieldConfig,
entity: Entity,
) {
let value = entity[field.id];
const controlOptions: FormControlOptions = { nonNullable: true };
if (field.validators) {
const validators = this.dynamicValidator.buildValidators(
field.validators,
entity,
);
Object.assign(controlOptions, validators);
}
formConfig[field.id] = new FormControl(value, controlOptions);
}
private disableReadOnlyFormControls<T extends Entity>(
form: EntityFormGroup<T>,
entity: T,
) {
const action = entity.isNew ? "create" : "update";
Object.keys(form.controls).forEach((fieldId) => {
if (this.ability.cannot(action, entity, fieldId)) {
form.get(fieldId).disable({ onlySelf: true, emitEvent: false });
}
});
}
/**
* This function applies the changes of the formGroup to the entity.
* If the form is invalid or the entity does not pass validation after applying the changes, an error will be thrown.
* The input entity will not be modified but a copy of it will be returned in case of success.
* @param form The formGroup holding the changes (marked pristine and disabled after successful save)
* @param entity The entity on which the changes should be applied.
* @returns a copy of the input entity with the changes from the form group
*/
public async saveChanges<T extends Entity>(
entityForm: EntityForm<T>,
entity: T,
): Promise<T> {
const form: EntityFormGroup<T> = entityForm.formGroup;
this.checkFormValidity(form);
const updatedEntity = entity.copy() as T;
for (const [key, value] of Object.entries(form.getRawValue())) {
if (value !== null) {
updatedEntity[key] = value;
} else {
// formControls' value is null if it is empty (untouched or cleared by user) but we don't want entity docs to be full of null properties
delete updatedEntity[key];
}
}
updatedEntity.assertValid();
this.assertPermissionsToSave(entity, updatedEntity);
try {
await this.entityMapper.save(updatedEntity);
} catch (err) {
throw new Error($localize`Could not save ${entity.getType()}\: ${err}`);
}
this.unsavedChanges.pending = false;
form.markAsPristine();
form.disable();
Object.assign(entity, updatedEntity);
entityForm.onFormStateChange.emit("saved");
return entity;
}
private checkFormValidity<T extends Entity>(form: EntityFormGroup<T>) {
// errors regarding invalid fields won't be displayed unless marked as touched
form.markAllAsTouched();
if (form.invalid) {
throw new InvalidFormFieldError();
}
}
private assertPermissionsToSave(oldEntity: Entity, newEntity: Entity) {
let action: "create" | "update", entity: Entity;
if (oldEntity.isNew) {
action = "create";
entity = newEntity;
} else {
action = "update";
entity = oldEntity;
}
if (!this.ability.can(action, entity, undefined, true)) {
const conditions = this.ability
.rulesFor(action, entity.getType())
.map((r) => r.conditions);
throw new Error(
$localize`Current user is not permitted to save these changes: ${JSON.stringify(
conditions,
)}`,
);
}
}
resetForm<E extends Entity>(entityForm: EntityForm<E>, entity: E) {
const form = entityForm.formGroup;
for (const key of Object.keys(form.controls)) {
form.get(key).setValue(entity[key]);
}
form.markAsPristine();
this.unsavedChanges.pending = false;
entityForm.onFormStateChange.emit("cancelled");
}
}