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.
| providedIn | root |
Methods |
|
| Public Async createEntityForm | |||||||||||||||||||||||||||||||||||
createEntityForm<T>(formFields: ColumnConfig[], entity: T, destroyRef: DestroyRef, forTable: unknown, withPermissionCheck: unknown, withDefaultValues: unknown)
|
|||||||||||||||||||||||||||||||||||
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. caller's lifecycle (so its unsaved-changes state is cleared when the caller is destroyed)
Parameters :
Returns :
Promise<EntityForm<T>>
|
| Public extendFormFieldConfig | ||||||||||||||||
extendFormFieldConfig(formField: ColumnConfig, entityType: EntityConstructor, forTable: unknown)
|
||||||||||||||||
|
Uses schema information to fill missing fields in the FormFieldConfig.
Parameters :
Returns :
FormFieldConfig
|
| resetForm | |||||||||
resetForm<E>(entityForm: EntityForm<E>, entity: E)
|
|||||||||
Type parameters :
|
|||||||||
|
Parameters :
Returns :
void
|
| Public Async saveChanges | ||||||||||||
saveChanges<T>(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 { DestroyRef, EventEmitter, inject, Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, FormControl, FormControlOptions } 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 { PermissionConditionValidatorsService } from "./dynamic-form-validators/permission-condition-validators";
import { EntityAbility } from "../../permissions/ability/entity-ability";
import { InvalidFormFieldError } from "./invalid-form-field.error";
import { UnsavedChangesService } from "../../entity-details/form/unsaved-changes.service";
import { filter } from "rxjs/operators";
import { EntitySchemaField } from "../../entity/schema/entity-schema-field";
import { DefaultValueService } from "../../default-values/default-value-service/default-value.service";
import {
EntityForm,
EntityFormGroup,
EntityFormSavedEvent,
TypedFormGroup,
} from "#src/app/core/common-components/entity-form/entity-form";
import { asArray } from "app/utils/asArray";
/**
* 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 fb = inject(FormBuilder);
private entityMapper = inject(EntityMapperService);
private entitySchemaService = inject(EntitySchemaService);
private dynamicValidator = inject(DynamicValidatorsService);
private readonly permissionConditionValidators = inject(
PermissionConditionValidatorsService,
);
private ability = inject(EntityAbility);
private unsavedChanges = inject(UnsavedChangesService);
private defaultValueService = inject(DefaultValueService);
/**
* 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 destroyRef the caller's DestroyRef, used to scope the form's change tracking to the
* caller's lifecycle (so its unsaved-changes state is cleared when the caller is destroyed)
* @param forTable
* @param withPermissionCheck if true, fields without 'update' permissions will stay disabled when enabling form
* @param withDefaultValues if true, default value strategies are initialized and applied
*/
public async createEntityForm<T extends Entity>(
formFields: ColumnConfig[],
entity: T,
destroyRef: DestroyRef,
forTable = false,
withPermissionCheck = true,
withDefaultValues = true,
): Promise<EntityForm<T>> {
const fields = formFields.map((f) =>
this.extendFormFieldConfig(f, entity.getConstructor(), forTable),
);
const typedFormGroup: TypedFormGroup<Partial<T>> = this.createFormGroup(
fields,
entity,
destroyRef,
withPermissionCheck,
);
const entityForm: EntityForm<T> = {
formGroup: typedFormGroup,
entity: entity,
fieldConfigs: fields,
onFormStateChange: new EventEmitter(),
inheritedParentValues: new Map(),
watcher: new Map(),
};
// Track unsaved changes for this form, keyed by the EntityForm itself.
// Both the subscription and the final cleanup are tied to the caller's lifecycle.
// Note: if a caller creates several forms over its lifetime (e.g. reloading a
// resource), it should explicitly clear the old form's state via
// `setUnsavedChanges(oldForm, false)` before replacing it, to prevent stale
// dirty state from accumulating in UnsavedChangesService.sources.
typedFormGroup.valueChanges
.pipe(takeUntilDestroyed(destroyRef))
.subscribe(() =>
this.unsavedChanges.setUnsavedChanges(entityForm, typedFormGroup.dirty),
);
// The caller's view may already be destroyed by the time this async form
// finishes being created (e.g. navigated away while the form was still loading).
// `onDestroy` throws NG0911 in that case, so check `destroyed` first, the same
// way `takeUntilDestroyed` (used above) guards against it internally.
if (destroyRef.destroyed) {
this.unsavedChanges.setUnsavedChanges(entityForm, false);
} else {
destroyRef.onDestroy(() =>
this.unsavedChanges.setUnsavedChanges(entityForm, false),
);
}
if (withDefaultValues) {
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,
destroyRef: DestroyRef,
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, withPermissionCheck);
}
const group = this.fb.group<Partial<T>>(formConfig);
if (withPermissionCheck) {
this.disableReadOnlyFormControls(group, entity);
group.statusChanges
.pipe(
filter((status) => status !== "DISABLED"),
takeUntilDestroyed(destroyRef),
)
.subscribe(() => this.disableReadOnlyFormControls(group, entity));
}
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,
withPermissionCheck: boolean,
) {
let value = entity[field.id];
const controlOptions: FormControlOptions = { nonNullable: true };
if (field.validators) {
const validators = this.dynamicValidator.buildValidators(
field.validators,
entity,
field.id,
);
Object.assign(controlOptions, validators);
}
if (withPermissionCheck) {
const conditionValidator = this.permissionConditionValidators.forField(
entity,
field.id,
);
if (conditionValidator) {
controlOptions.validators = [
...asArray(controlOptions.validators ?? []),
conditionValidator,
];
}
}
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 entityForm 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 originalEntity = entity.copy();
const updatedEntity = this.createUpdatedEntity(entity, form);
updatedEntity.assertValid();
this.assertPermissionsToSave(entity, updatedEntity);
try {
await this.entityMapper.save(updatedEntity);
} catch (err) {
// the original error is kept as `cause`: remote monitoring links it into
// the reported exception chain, so a save failure can be told apart by
// what actually rejected it instead of only by this message
throw new Error(
$localize`Could not save ${entity.getType()}\: ${err?.message || String(err)}`,
{ cause: err },
);
}
this.unsavedChanges.setUnsavedChanges(entityForm, false);
form.markAsPristine();
form.disable();
Object.assign(entity, updatedEntity);
entityForm.onFormStateChange.emit(
new EntityFormSavedEvent(entity, originalEntity),
);
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 createUpdatedEntity<T extends Entity>(
entity: T,
form: EntityFormGroup<T>,
) {
const updatedEntity = entity.copy() as T;
for (const [key, value] of Object.entries(form.getRawValue())) {
if (value !== null) {
const schema = entity.getSchema().get(key);
const trimmedValue =
typeof value === "string" && schema?.trim !== false
? value.trim()
: value;
updatedEntity[key] = trimmedValue;
if (trimmedValue !== value) {
// keep the form control in sync with the persisted (trimmed) value,
// otherwise the entity's own save echo looks like an external
// conflicting change and triggers a spurious "Load changes?" prompt
form.get(key)?.setValue(trimmedValue, { emitEvent: false });
}
} 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];
}
}
return updatedEntity;
}
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 requiredValues =
this.permissionConditionValidators.describeRequiredValues(
action,
entity,
);
const message = $localize`Current user is not permitted to save these changes.`;
throw new Error(
requiredValues
? message +
" " +
$localize`Values required by your permissions - ${requiredValues}`
: message,
);
}
}
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.setUnsavedChanges(entityForm, false);
entityForm.onFormStateChange.emit("cancelled");
}
}