src/app/core/common-components/entity-form/entity-form/entity-form.component.ts

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

Metadata

Relationships

Index

Properties
Inputs

Constructor

constructor()

Inputs

entity
Type : T

The entity which should be displayed and edited

fieldGroups
Type : FieldGroup[]
form
Type : EntityForm<T>
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

Properties

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 ?? []; const hiddenFieldIds = this.conditionHiddenFieldIds(); return this.filterFieldGroups(groups, entity, hiddenFieldIds); })

Field groups filtered by the current user's permissions and by displayCondition

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 {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  input,
  signal,
  ViewEncapsulation,
} 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 { DataFilter } from "#src/app/core/filter/filters/filters";
import { FieldGroup } from "../../../entity-details/form/field-group";
import { ColumnConfig } from "../FormConfig";
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 { FilterService } from "../../../filter/filter.service";
import { ConfirmationDialogService } from "../../confirmation-dialog/confirmation-dialog.service";
import { Logging } from "#src/app/core/logging/logging.service";
import { AlertService } from "../../../alerts/alert.service";
import { LocaleTextPipe } from "../../../language/locale-text.pipe";

/**
 * 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,
    LocaleTextPipe,
  ],
})
export class EntityFormComponent<T extends Entity = Entity> {
  private entityMapper = inject(EntityMapperService);
  private confirmationDialog = inject(ConfirmationDialogService);
  private ability = inject(EntityAbility);
  private filterService = inject(FilterService);
  private alertService = inject(AlertService);
  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);

  /** ids of fields currently hidden because their own or their group's `displayCondition` is not met */
  private readonly conditionHiddenFieldIds = signal<ReadonlySet<string>>(
    new Set(),
  );
  /**
   * ids of fields this component has disabled because of an unmet `displayCondition`.
   *
   * Only fields tracked here are ever re-enabled by `updateFieldDisplayConditions`: a field
   * may also be disabled for unrelated reasons (the whole form is still in read-only "view"
   * mode until the user clicks "Edit", see `FormComponent`; missing update permissions; an
   * anonymized entity) and must stay disabled in that case even once its condition is met.
   */
  private readonly conditionDisabledFieldIds = new Set<string>();
  private lastDisplayConditionForm: EntityForm<T> | undefined;

  /** Field groups filtered by the current user's permissions and by `displayCondition` */
  readonly filteredFieldGroups = computed<FieldGroup[]>(() => {
    const groups = this.fieldGroups();
    const entity = this.entityState();
    if (!groups || !entity) return groups ?? [];
    const hiddenFieldIds = this.conditionHiddenFieldIds();
    return this.filterFieldGroups(groups, entity, hiddenFieldIds);
  });

  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) => {
          try {
            await this.automatedFieldUpdateConfigService.applyRulesToDependentEntities(
              event.newEntity,
              event.previousEntity,
            );
          } catch (error) {
            Logging.error(
              "EntityForm could not update the linked records after saving",
              error,
            );
            this.alertService.addDanger(
              $localize`The linked records could not be updated.`,
            );
          }
        });
      onCleanup(() => sub.unsubscribe());
    });

    effect((onCleanup) => {
      const form = this.form();
      if (!form) {
        this.conditionHiddenFieldIds.set(new Set());
        return;
      }

      this.updateFieldDisplayConditions(form);
      const sub = form.formGroup.valueChanges
        .pipe(untilDestroyed(this))
        .subscribe(() => this.updateFieldDisplayConditions(form));
      onCleanup(() => sub.unsubscribe());
    });
  }

  /**
   * Re-evaluate the `displayCondition` (if any) of each field and of each field group against
   * the entity's current, possibly unsaved state (i.e. including the current form values)
   * and hide/disable fields whose own condition or whose group's condition is not met.
   *
   * A field is only re-enabled if this component itself had previously disabled it for an
   * unmet condition - never a field that is disabled for some unrelated reason (the form is
   * still in read-only "view" mode, missing update permissions, an anonymized entity),
   * so it never overrides those.
   */
  private updateFieldDisplayConditions(form: EntityForm<T>) {
    const entity = this.entityState();
    if (!entity) return;

    if (form !== this.lastDisplayConditionForm) {
      this.conditionDisabledFieldIds.clear();
      this.lastDisplayConditionForm = form;
    }

    const fieldsWithCondition = form.fieldConfigs.filter(hasDisplayCondition);
    const groupsWithCondition = (this.fieldGroups() ?? []).filter(
      hasDisplayCondition,
    );
    if (
      fieldsWithCondition.length === 0 &&
      groupsWithCondition.length === 0 &&
      this.conditionDisabledFieldIds.size === 0
    ) {
      this.conditionHiddenFieldIds.set(new Set());
      return;
    }

    const currentEntityState = entity.copy();
    Object.assign(currentEntityState, form.formGroup.getRawValue());

    const hiddenFieldIds = new Set<string>();
    for (const group of groupsWithCondition) {
      if (
        !this.evaluateDisplayCondition(
          group.displayCondition,
          currentEntityState,
        )
      ) {
        group.fields
          .map(getFieldId)
          .filter(Boolean)
          .forEach((id) => hiddenFieldIds.add(id));
      }
    }
    for (const field of fieldsWithCondition) {
      if (
        !this.evaluateDisplayCondition(
          field.displayCondition,
          currentEntityState,
        )
      ) {
        hiddenFieldIds.add(field.id);
      }
    }

    let controlStateChanged = false;
    for (const fieldId of hiddenFieldIds) {
      const control = form.formGroup.get(fieldId);
      if (control?.enabled) {
        this.conditionDisabledFieldIds.add(fieldId);
        control.disable({ onlySelf: true, emitEvent: false });
        controlStateChanged = true;
      }
    }

    const action = entity.isNew ? "create" : "update";
    for (const fieldId of this.conditionDisabledFieldIds) {
      if (
        !hiddenFieldIds.has(fieldId) &&
        !this.isEntityLocked() &&
        !form.formGroup.disabled &&
        this.ability.can(action, entity, fieldId)
      ) {
        form.formGroup
          .get(fieldId)
          ?.enable({ onlySelf: true, emitEvent: false });
        this.conditionDisabledFieldIds.delete(fieldId);
        controlStateChanged = true;
      }
    }

    if (controlStateChanged) {
      // `onlySelf` leaves the form's own validity stale (e.g. still invalid because of a now hidden required field)
      form.formGroup.updateValueAndValidity({ emitEvent: false });
    }

    this.conditionHiddenFieldIds.set(hiddenFieldIds);
  }

  private evaluateDisplayCondition(condition: any, entity: T): boolean {
    try {
      return this.filterService.getFilterPredicate(condition)(entity);
    } catch (err) {
      Logging.error(
        "error evaluating display condition for entity",
        err,
        entity,
        condition,
      );
      // an invalid/misconfigured condition should not hide the field entirely
      return true;
    }
  }

  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 filterFieldGroups(
    fieldGroups: FieldGroup[],
    entity: Entity,
    hiddenFieldIds: ReadonlySet<string> = new Set(),
  ): FieldGroup[] {
    const action = entity.isNew ? "create" : "read";

    return fieldGroups
      .map((group) => ({
        ...group,
        fields: group.fields.filter((field) => {
          const fieldId = getFieldId(field);
          // an incompletely configured field (e.g. no id selected) must not break the whole group
          return (
            fieldId &&
            !hiddenFieldIds.has(fieldId) &&
            this.ability.can(action, entity, fieldId)
          );
        }),
      }))
      .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)
    );
  }
}

function hasDisplayCondition(item: {
  displayCondition?: DataFilter<any>;
}): boolean {
  return (
    !!item.displayCondition && Object.keys(item.displayCondition).length > 0
  );
}

function getFieldId(field: ColumnConfig): string | undefined {
  return typeof field === "string" ? field : field?.id;
}
<form [class.grid-layout]="gridLayout()">
  @for (group of filteredFieldGroups(); track $index) {
    <div class="entity-form-cell">
      @if (group.header) {
        <h2>{{ group.header | localeText }}</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>

./entity-form.component.scss

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

.full-width mat-form-field {
  max-width: none;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""