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 ?? []; 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>

./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;
  }

  /* 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
Component
Html element with directive

results matching ""

    No results matching ""