Description
A general purpose form component for displaying and editing entities.
It uses the FormFieldConfig interface for building the form fields but missing information are also fetched from
the entity's schema definitions. Properties with sufficient schema information can be displayed by only providing
the name of this property (and not an FormFieldConfig object).
This component can be used directly or in a popup.
Inside the entity details component use the FormComponent which is registered as dynamic component.
Example
|
entity
|
Type : T
|
|
|
The entity which should be displayed and edited
|
|
fullWidth
|
Type : boolean
|
Default value : false
|
|
|
Whether the fields should use the max width of the container
|
|
gridLayout
|
Type : boolean
|
Default value : true
|
|
|
Whether the component should use a grid layout or just rows
|
|
Readonly
entityState
|
Type : unknown
|
Default value : signal<T | undefined>(undefined)
|
|
|
|
Readonly
filteredFieldGroups
|
Type : unknown
|
Default value : computed<FieldGroup[]>(() => {
const groups = this.fieldGroups();
const entity = this.entityState();
if (!groups || !entity) return groups ?? [];
return this.filterFieldGroupsByPermissions(groups, entity);
})
|
|
|
Field groups filtered by the current user's permissions
|
|
Readonly
isEntityLocked
|
Type : unknown
|
Default value : computed(() => !!this.entityState()?.anonymized)
|
|
|
import {
EntityForm,
EntityFormSavedEvent,
} from "#src/app/core/common-components/entity-form/entity-form";
import { AutomatedFieldUpdateConfigService } from "#src/app/features/inherited-field/automated-field-update/automated-field-update-config.service";
import {
Component,
computed,
effect,
inject,
input,
signal,
ViewEncapsulation,
ChangeDetectionStrategy,
} from "@angular/core";
import { FormsModule } from "@angular/forms";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import moment from "moment";
import { Subscription } from "rxjs";
import { filter } from "rxjs/operators";
import { FieldGroup } from "../../../entity-details/form/field-group";
import { EntityFieldEditComponent } from "../../../entity/entity-field-edit/entity-field-edit.component";
import { EntityMapperService } from "../../../entity/entity-mapper/entity-mapper.service";
import { Entity } from "../../../entity/model/entity";
import { EntityAbility } from "../../../permissions/ability/entity-ability";
import { ConfirmationDialogService } from "../../confirmation-dialog/confirmation-dialog.service";
/**
* A general purpose form component for displaying and editing entities.
* It uses the FormFieldConfig interface for building the form fields but missing information are also fetched from
* the entity's schema definitions. Properties with sufficient schema information can be displayed by only providing
* the name of this property (and not an FormFieldConfig object).
*
* This component can be used directly or in a popup.
* Inside the entity details component use the FormComponent which is registered as dynamic component.
*/
@UntilDestroy()
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-entity-form",
templateUrl: "./entity-form.component.html",
styleUrls: ["./entity-form.component.scss"],
// Use no encapsulation because we want to change the value of children (the mat-form-fields that are
// dynamically created)
encapsulation: ViewEncapsulation.None,
imports: [
FormsModule, // importing FormsModule ensures that buttons anywhere inside do not trigger form submission / page reload
EntityFieldEditComponent,
],
})
export class EntityFormComponent<T extends Entity = Entity> {
private entityMapper = inject(EntityMapperService);
private confirmationDialog = inject(ConfirmationDialogService);
private ability = inject(EntityAbility);
private automatedFieldUpdateConfigService = inject(
AutomatedFieldUpdateConfigService,
);
/**
* The entity which should be displayed and edited
*/
entity = input<T>();
fieldGroups = input<FieldGroup[]>();
form = input<EntityForm<T>>();
/**
* Whether the component should use a grid layout or just rows
*/
gridLayout = input<boolean>(true);
/**
* Whether the fields should use the max width of the container
*/
fullWidth = input<boolean>(false);
readonly entityState = signal<T | undefined>(undefined);
readonly isEntityLocked = computed(() => !!this.entityState()?.anonymized);
/** Field groups filtered by the current user's permissions */
readonly filteredFieldGroups = computed<FieldGroup[]>(() => {
const groups = this.fieldGroups();
const entity = this.entityState();
if (!groups || !entity) return groups ?? [];
return this.filterFieldGroupsByPermissions(groups, entity);
});
private initialFormValues: any;
private changesSubscription: Subscription;
constructor() {
effect(() => {
this.entityState.set(this.entity());
});
effect((onCleanup) => {
const entity = this.entityState();
if (!entity) return;
this.changesSubscription?.unsubscribe();
const sub = this.entityMapper
.receiveUpdates(entity.getConstructor())
.pipe(
filter(({ entity: e }) => e.getId() === entity.getId()),
filter(({ type }) => type !== "remove"),
untilDestroyed(this),
)
.subscribe(({ entity: updated }) => this.applyChanges(updated as T));
this.changesSubscription = sub;
onCleanup(() => sub.unsubscribe());
});
effect((onCleanup) => {
const form = this.form();
if (!form) return;
this.initialFormValues = form.formGroup.getRawValue();
if (this.isEntityLocked()) {
form.formGroup.disable();
}
const sub = form.onFormStateChange
.pipe(
untilDestroyed(this),
filter((event) => event instanceof EntityFormSavedEvent),
)
.subscribe(async (event: EntityFormSavedEvent) => {
await this.automatedFieldUpdateConfigService.applyRulesToDependentEntities(
event.newEntity,
event.previousEntity,
);
});
onCleanup(() => sub.unsubscribe());
});
}
private async applyChanges(externallyUpdatedEntity: T) {
const inputEntity = this.entity();
if (this.formIsUpToDate(externallyUpdatedEntity)) {
if (inputEntity) {
Object.assign(inputEntity, externallyUpdatedEntity);
}
this.entityState.set(externallyUpdatedEntity);
return;
}
const userEditedFields = Object.entries(
this.form().formGroup.getRawValue(),
).filter(([key]) => this.form().formGroup.controls[key].dirty);
let userEditsWithoutConflicts = userEditedFields.filter(([key]) =>
// no conflict with updated values
this.entityEqualsFormValue(
externallyUpdatedEntity[key],
this.initialFormValues[key],
),
);
if (
userEditsWithoutConflicts.length !== userEditedFields.length &&
!(await this.confirmationDialog.getConfirmation(
$localize`Load changes?`,
$localize`Local changes are in conflict with updated values synced from the server. Do you want the local changes to be overwritten with the latest values?`,
))
) {
// user "resolved" conflicts by confirming to overwrite
userEditsWithoutConflicts = userEditedFields;
}
// apply update to all pristine (not user-edited) fields and update base entity (to avoid conflicts when saving)
if (inputEntity) {
Object.assign(inputEntity, externallyUpdatedEntity);
}
this.entityState.set(externallyUpdatedEntity);
Object.assign(this.initialFormValues, externallyUpdatedEntity);
this.form().formGroup.reset(externallyUpdatedEntity as any);
// re-apply user-edited fields
userEditsWithoutConflicts.forEach(([key, value]) => {
this.form().formGroup.get(key).setValue(value);
this.form().formGroup.get(key).markAsDirty();
});
}
private formIsUpToDate(entity: T): boolean {
return Object.entries(this.form().formGroup.getRawValue()).every(
([key, value]) => this.entityEqualsFormValue(entity[key], value),
);
}
private filterFieldGroupsByPermissions<T extends Entity = Entity>(
fieldGroups: FieldGroup[],
entity: Entity,
): FieldGroup[] {
const action = entity.isNew ? "create" : "read";
return fieldGroups
.map((group) => ({
...group,
fields: group.fields.filter((field) =>
this.ability.can(
action,
entity,
typeof field === "string" ? field : field.id,
),
),
}))
.filter((group) => group.fields.length > 0);
}
private entityEqualsFormValue(entityValue, formValue) {
return (
(entityValue instanceof Date &&
moment(entityValue).isSame(formValue, "day")) ||
(entityValue === undefined && formValue === null) ||
entityValue === formValue ||
JSON.stringify(entityValue) === JSON.stringify(formValue)
);
}
}
<form [class.grid-layout]="gridLayout()">
@for (group of filteredFieldGroups(); track $index) {
<div class="entity-form-cell">
@if (group.header) {
<h2>{{ group.header }}</h2>
}
@for (
field of group.fields;
track typeof field === "string" ? field : field.id
) {
<div [class.full-width]="fullWidth()">
<app-entity-field-edit
[field]="field"
[entity]="entityState()"
[form]="form()"
></app-entity-field-edit>
</div>
}
</div>
}
</form>
@use "mixins/grid-layout";
@use "variables/sizes";
.grid-layout {
@include grid-layout.adaptive(
$min-block-width: sizes.$form-group-min-width,
$max-screen-width: 414px
);
}
.entity-form-cell {
display: flex;
flex-direction: column;
/* set the width of each form field to 100% in every form component that is a descendent
of the columns-wrapper class */
mat-form-field {
width: 100%;
max-width: 864px;
}
/* We align the photo (and only tht photo) to the center of the cell if there is one.
This looks better on desktop and mobile compared to an alignment to the start of the cell
which is the default for all other elements */
> app-edit-photo {
align-self: center;
}
}
.full-width mat-form-field {
max-width: none;
}
Legend
Html element with directive