src/app/features/inherited-field/automated-field-update/automated-field-update.component.ts

Description

A confirmation dialog for the user to review and confirm the automated updates of fields in related entities, after a triggering entity has been updated.

The suggestions and what the user entered are kept apart: the suggestions are calculated again whenever the user enters a value (as the entities above are based on it), what the user entered stays.

(also see AutomatedFieldUpdateConfigService)

Example

Metadata

Relationships

Index

Properties
Methods

Constructor

constructor()

Methods

onConfirm
onConfirm()
Returns : void
onValueChange
onValueChange(row: DialogRow, value: unknown)

The user entered a value for the row (database format)

Parameters :
Name Type Optional
row DialogRow No
value unknown No
Returns : void
useSuggestion
useSuggestion(row: DialogRow)

Replace the value that the user entered with the suggested value

Parameters :
Name Type Optional
row DialogRow No
Returns : void

Properties

Readonly allLinkedThroughSameField
Type : boolean
Default value : this.data.entities .filter((entity) => entity.level === 1) .every( (entity, _, firstLevel) => entity.relatedReferenceField === firstLevel[0].relatedReferenceField && entity.relatedReferenceFieldEntityType === firstLevel[0].relatedReferenceFieldEntityType, )

Whether all entities directly linked to the changed entity are linked through the same field (otherwise each of them shows how it is linked).

data
Type : unknown
Default value : inject<{ entities: AffectedEntity[]; trigger?: UpdateTrigger; /** Finds the affected entities again, based on the values the user entered instead of the suggestions */ recalculate?: (enteredValues: EnteredValue[]) => Promise<AffectedEntity[]>; }>(MAT_DIALOG_DATA)
Readonly groups
Type : unknown
Default value : computed(() => this.groupByTriggerField(this.rows()))

The affected entities, grouped by the field of the changed entity that the update is based on

Readonly recalculating
Type : unknown
Default value : computed( () => !!this.data.recalculate && this.entries() !== this.calculatedFor(), )

Whether the entities above are being updated after a change, which has to finish before saving

Readonly recalculationFailed
Type : unknown
Default value : signal(false)

Whether the last update of the entities above failed, so that it has to be tried again with a change

Readonly rows
Type : unknown
Default value : computed<DialogRow[]>(() => { const entries = this.entries(); const suggestions = this.suggestions(); const suggestedKeys = new Set(suggestions.map(getRowKey)); const notSuggestedAnyMore = [...entries.values()] .filter((entry) => !suggestedKeys.has(getRowKey(entry.row))) .map((entry) => ({ ...entry.row, newValue: entry.row.savedValue, // the hints of the earlier suggestion noCommonValue: undefined, unlinked: undefined, })); return [...suggestions, ...notSuggestedAnyMore].map((row) => this.toDialogRow(row, entries.get(getRowKey(row))), ); })

The shown rows: the suggestions, and the rows the user changed (also if they are not suggested any more)

import { EntitySchemaService } from "#src/app/core/entity/schema/entity-schema.service";
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  DestroyRef,
  inject,
  signal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FaDynamicIconComponent } from "#src/app/core/common-components/fa-dynamic-icon/fa-dynamic-icon.component";
import { HintBoxComponent } from "#src/app/core/common-components/hint-box/hint-box.component";
import { MatButton } from "@angular/material/button";
import { MatExpansionModule } from "@angular/material/expansion";
import { MatTooltipModule } from "@angular/material/tooltip";
import {
  MAT_DIALOG_DATA,
  MatDialogModule,
  MatDialogRef,
} from "@angular/material/dialog";
import { EntityBlockComponent } from "#src/app/core/basic-datatypes/entity/entity-block/entity-block.component";
import { DialogCloseComponent } from "#src/app/core/common-components/dialog-close/dialog-close.component";
import { EntityFormService } from "#src/app/core/common-components/entity-form/entity-form.service";
import { FormFieldConfig } from "#src/app/core/common-components/entity-form/FormConfig";
import { EntityFieldViewComponent } from "#src/app/core/entity/entity-field-view/entity-field-view.component";
import { HelpButtonComponent } from "#src/app/core/common-components/help-button/help-button.component";
import { EntityFieldLabelPipe } from "#src/app/core/entity/entity-field-label/entity-field-label.pipe";
import { differenceWith, isEqual, uniq } from "lodash-es";
import { asArray } from "#src/app/utils/asArray";
import {
  catchError,
  debounceTime,
  EMPTY,
  from,
  map,
  Subject,
  switchMap,
} from "rxjs";
import { Logging } from "#src/app/core/logging/logging.service";
import { Entity } from "#src/app/core/entity/model/entity";
import { isEmptyValue } from "../add-missing-values";
import { isSameValue } from "../get-common-value";
import { AffectedEntityValueComponent } from "./affected-entity-value/affected-entity-value.component";
import { isFieldChanged } from "./change-set";
import {
  AffectedEntity,
  DialogRow,
  EnteredValue,
  UpdateTrigger,
} from "./automated-field-update.types";

/** What the user entered for an affected entity */
interface UserEntry {
  /** the affected entity, to keep showing it also if it is not suggested any more */
  row: AffectedEntity;

  /** database format */
  value: unknown;

  /** the suggestion when the user entered the value (database format) */
  suggestion: unknown;
}

/** A field of the changed entity that caused an update, and whether its value was changed */
interface TriggerChange {
  field: FormFieldConfig;
  isChanged: boolean;

  /** The old values that the change removed, as an entity with only these values (to display them) */
  removed?: Entity;

  /** The values that the change added, as an entity with only these values (to display them) */
  added?: Entity;
}

/** The affected entities that are based on the same field of the changed entity */
interface UpdateGroup {
  sourceFieldId: string;
  changes: TriggerChange[];
  rows: DialogRow[];
}

/** How long to wait for further changes of the user before the entities above are updated */
const RECALCULATION_DELAY_MS = 300;

function getRowKey(row: AffectedEntity): string {
  return `${row.id}|${row.targetFieldId}`;
}

/**
 * A confirmation dialog for the user to review and confirm
 * the automated updates of fields in related entities,
 * after a triggering entity has been updated.
 *
 * The suggestions and what the user entered are kept apart: the suggestions are calculated again
 * whenever the user enters a value (as the entities above are based on it), what the user entered stays.
 *
 * (also see AutomatedFieldUpdateConfigService)
 */
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-automated-field-update",
  imports: [
    AffectedEntityValueComponent,
    DialogCloseComponent,
    EntityBlockComponent,
    EntityFieldLabelPipe,
    EntityFieldViewComponent,
    FaDynamicIconComponent,
    HelpButtonComponent,
    HintBoxComponent,
    MatButton,
    MatDialogModule,
    MatExpansionModule,
    MatTooltipModule,
  ],
  templateUrl: "./automated-field-update.component.html",
  styleUrl: "./automated-field-update.component.scss",
})
export class AutomatedFieldUpdateComponent {
  data = inject<{
    entities: AffectedEntity[];
    trigger?: UpdateTrigger;
    /** Finds the affected entities again, based on the values the user entered instead of the suggestions */
    recalculate?: (enteredValues: EnteredValue[]) => Promise<AffectedEntity[]>;
  }>(MAT_DIALOG_DATA);
  private dialogRef =
    inject<MatDialogRef<AutomatedFieldUpdateComponent>>(MatDialogRef);
  private readonly entityFormService = inject(EntityFormService);
  private readonly entitySchemaService = inject(EntitySchemaService);
  private readonly destroyRef = inject(DestroyRef);

  /** The suggested updates */
  private readonly suggestions = signal<AffectedEntity[]>(this.data.entities);

  /** What the user entered, by row key */
  private readonly entries = signal(new Map<string, UserEntry>());

  /** The entries that the current suggestions are calculated for */
  private readonly calculatedFor = signal(this.entries());
  private readonly entriesChanged = new Subject<Map<string, UserEntry>>();

  /** The shown rows: the suggestions, and the rows the user changed (also if they are not suggested any more) */
  readonly rows = computed<DialogRow[]>(() => {
    const entries = this.entries();
    const suggestions = this.suggestions();
    const suggestedKeys = new Set(suggestions.map(getRowKey));
    const notSuggestedAnyMore = [...entries.values()]
      .filter((entry) => !suggestedKeys.has(getRowKey(entry.row)))
      .map((entry) => ({
        ...entry.row,
        newValue: entry.row.savedValue,
        // the hints of the earlier suggestion
        noCommonValue: undefined,
        unlinked: undefined,
      }));
    return [...suggestions, ...notSuggestedAnyMore].map((row) =>
      this.toDialogRow(row, entries.get(getRowKey(row))),
    );
  });

  /** Whether the entities above are being updated after a change, which has to finish before saving */
  readonly recalculating = computed(
    () => !!this.data.recalculate && this.entries() !== this.calculatedFor(),
  );

  /** Whether the last update of the entities above failed, so that it has to be tried again with a change */
  readonly recalculationFailed = signal(false);

  /** The affected entities, grouped by the field of the changed entity that the update is based on */
  readonly groups = computed(() => this.groupByTriggerField(this.rows()));

  /**
   * Whether all entities directly linked to the changed entity are linked through the same field
   * (otherwise each of them shows how it is linked).
   */
  readonly allLinkedThroughSameField: boolean = this.data.entities
    .filter((entity) => entity.level === 1)
    .every(
      (entity, _, firstLevel) =>
        entity.relatedReferenceField === firstLevel[0].relatedReferenceField &&
        entity.relatedReferenceFieldEntityType ===
          firstLevel[0].relatedReferenceFieldEntityType,
    );

  private groupByTriggerField(rows: DialogRow[]): UpdateGroup[] {
    const groups = new Map<string, DialogRow[]>();
    for (const row of rows) {
      groups.set(row.sourceFieldId, [
        ...(groups.get(row.sourceFieldId) ?? []),
        row,
      ]);
    }
    return [...groups].map(([sourceFieldId, groupRows]) => ({
      sourceFieldId,
      changes: this.getTriggerChanges(sourceFieldId, groupRows),
      rows: groupRows,
    }));
  }

  /** The fields of the changed entity that caused the update of the given rows, to show what changed */
  private getTriggerChanges(
    sourceFieldId: string,
    groupRows: DialogRow[],
  ): TriggerChange[] {
    const trigger = this.data.trigger;
    if (!trigger) return [];

    const entityType = trigger.entity.getConstructor();
    const isChanged = (fieldId: string) =>
      isFieldChanged(trigger.entity, trigger.before, fieldId);
    const firstLevel = groupRows.filter((entity) => entity.level === 1);
    // the value that is passed on, and the link to the affected entities if it was changed
    const fieldIds = uniq([
      sourceFieldId,
      ...firstLevel
        .filter(
          (entity) => entity.relatedReferenceFieldEntityType === entityType,
        )
        .map((entity) => entity.relatedReferenceField)
        .filter(isChanged),
    ]).filter((fieldId) => !!fieldId);
    return fieldIds.map((fieldId) => {
      const field = this.entityFormService.extendFormFieldConfig(
        fieldId,
        entityType,
      );
      return {
        field,
        isChanged: isChanged(fieldId),
        ...this.getRemovedAndAdded(trigger, field),
      };
    });
  }

  /** What a change of the changed entity removed from the old value of the field, and what it added */
  private getRemovedAndAdded(
    trigger: UpdateTrigger,
    field: FormFieldConfig,
  ): Pick<TriggerChange, "removed" | "added"> {
    const oldValues = this.getValues(trigger.before, field);
    const newValues = this.getValues(trigger.entity, field);

    return {
      removed: this.withOnlyValues(
        trigger.before,
        field,
        differenceWith(oldValues, newValues, isEqual),
      ),
      added: this.withOnlyValues(
        trigger.entity,
        field,
        differenceWith(newValues, oldValues, isEqual),
      ),
    };
  }

  /** The values of the field of the entity as a list (database format), which is empty if there is no value */
  private getValues(entity: Entity, field: FormFieldConfig): unknown[] {
    const value = this.entitySchemaService.valueToDatabaseFormat(
      entity[field.id],
      field,
      entity,
    );
    return isEmptyValue(value) ? [] : asArray<unknown>(value);
  }

  /**
   * A copy of the entity that has only the given values (database format) in the field, to display them,
   * or undefined if there are none.
   */
  private withOnlyValues(
    entity: Entity,
    field: FormFieldConfig,
    values: unknown[],
  ): Entity | undefined {
    if (values.length === 0) return undefined;
    const copy = entity.copy();
    copy[field.id] = this.entitySchemaService.valueToEntityFormat(
      field.isArray ? values : values[0],
      field,
    );
    return copy;
  }

  constructor() {
    this.recalculateOnChange();
  }

  private toDialogRow(row: AffectedEntity, entry?: UserEntry): DialogRow {
    const value = entry ? entry.value : row.newValue;
    return {
      ...row,
      value,
      edited: !!entry,
      suggestionChanged:
        !!entry && !isSameValue(entry.suggestion, row.newValue),
      removedEntity: this.getRemovedEntity(row, value),
    };
  }

  /** The saved values that the shown value does not have any more, as they are removed by it */
  private getRemovedEntity(
    row: AffectedEntity,
    value: unknown,
  ): Entity | undefined {
    if (!row.addToExisting || !Array.isArray(row.savedValue)) {
      return undefined;
    }

    return this.withOnlyValues(
      row.affectedEntity,
      row.selectedField,
      differenceWith(row.savedValue, asArray<unknown>(value ?? []), isEqual),
    );
  }

  /** The user entered a value for the row (database format) */
  onValueChange(row: DialogRow, value: unknown): void {
    const entries = new Map(this.entries());
    const key = getRowKey(row);
    if (isSameValue(value, row.newValue)) {
      entries.delete(key);
    } else {
      entries.set(key, {
        row,
        value,
        suggestion: entries.get(key)?.suggestion ?? row.newValue,
      });
    }
    this.setEntries(entries);
  }

  /** Replace the value that the user entered with the suggested value */
  useSuggestion(row: DialogRow): void {
    const entries = new Map(this.entries());
    entries.delete(getRowKey(row));
    this.setEntries(entries);
  }

  private setEntries(entries: Map<string, UserEntry>): void {
    this.recalculationFailed.set(false);
    this.entries.set(entries);
    this.entriesChanged.next(entries);
  }

  /** Suggest the values of the entities above again whenever a value is entered, as they are based on it */
  private recalculateOnChange(): void {
    if (!this.data.recalculate) return;

    this.entriesChanged
      .pipe(
        debounceTime(RECALCULATION_DELAY_MS),
        switchMap((entries) =>
          from(
            this.data.recalculate(
              [...entries.values()].map(({ row, value }) => ({
                id: row.id,
                targetFieldId: row.targetFieldId,
                value,
              })),
            ),
          ).pipe(
            map((suggestions) => ({ entries, suggestions })),
            catchError((error) => this.onRecalculationFailed(error)),
          ),
        ),
        takeUntilDestroyed(this.destroyRef),
      )
      .subscribe(({ entries, suggestions }) => {
        this.suggestions.set(suggestions);
        this.calculatedFor.set(entries);
      });
  }

  /**
   * Keep listening for changes, so that a later change tries again. Until an update succeeds,
   * saving stays disabled, because the entities above could be based on an outdated value.
   */
  private onRecalculationFailed(error: unknown) {
    Logging.warn(
      "AutomatedFieldUpdate: Could not update the suggestions for the entities above",
      error,
    );
    this.recalculationFailed.set(true);
    return EMPTY;
  }

  onConfirm(): void {
    this.dialogRef.close(
      this.rows().map((row) => ({ ...row, newValue: row.value })),
    );
  }
}
<h2 mat-dialog-title class="title">
  <span i18n>Update related records</span>
  <app-help-button
    i18n-text
    text="The suggested values come from the changed record. Fields that collect values get them added to the existing ones, and lose values that no linked record has any more; other fields take the suggested value. A record takes a shared value only if all its linked records agree. You can change a suggested value before you confirm."
  ></app-help-button>
</h2>
<app-dialog-close mat-dialog-close></app-dialog-close>

<mat-dialog-content>
  @for (group of groups(); track group.sourceFieldId) {
    @if (data.trigger) {
      <mat-expansion-panel class="trigger">
        <mat-expansion-panel-header>
          <mat-panel-title class="trigger-header">
            <span class="trigger-label" i18n>Triggered by</span>
            <app-entity-block
              [entityId]="data.trigger.entity.getId()"
              [linkDisabled]="true"
            ></app-entity-block>
          </mat-panel-title>
        </mat-expansion-panel-header>

        @for (change of group.changes; track change.field.id) {
          <div class="trigger-change">
            <span class="trigger-label">{{ change.field.label }}:</span>
            @if (!change.isChanged) {
              <app-entity-field-view
                [field]="change.field"
                [entity]="data.trigger.before"
              ></app-entity-field-view>
            }
            @if (change.removed; as removed) {
              <span class="trigger-removed">
                <app-fa-dynamic-icon
                  icon="minus"
                  i18n-matTooltip="tooltip for values that are removed"
                  matTooltip="Removed"
                ></app-fa-dynamic-icon>
                <span
                  class="visually-hidden"
                  i18n="screen reader text for values that are removed"
                  >Removed:</span
                >
                <app-entity-field-view
                  [field]="change.field"
                  [entity]="removed"
                ></app-entity-field-view>
              </span>
            }
            @if (change.added; as added) {
              <span class="trigger-added">
                <app-fa-dynamic-icon
                  icon="plus"
                  i18n-matTooltip="tooltip for values that are added"
                  matTooltip="Added"
                ></app-fa-dynamic-icon>
                <span
                  class="visually-hidden"
                  i18n="screen reader text for values that are added"
                  >Added:</span
                >
                <app-entity-field-view
                  [field]="change.field"
                  [entity]="added"
                ></app-entity-field-view>
              </span>
            }
          </div>
        }
      </mat-expansion-panel>
    }

    <table class="full-width">
      @for (
        entity of group.rows;
        track entity.id + "|" + entity.targetFieldId
      ) {
        <tr [class.above]="entity.level > 1" [style.--level]="entity.level">
          <td>
            <app-entity-block [entityId]="entity.id"></app-entity-block>
            @if (entity.level > 1) {
              <div class="hint-text via">
                <span i18n>via</span>
                @for (viaId of entity.via; track viaId; let last = $last) {
                  <app-entity-block [entityId]="viaId"></app-entity-block>
                  @if (!last) {
                    <span>, </span>
                  }
                }
              </div>
            }
            @if (!allLinkedThroughSameField && entity.level === 1) {
              <div class="hint-text" i18n>
                Linked through "{{
                  entity.relatedReferenceField
                    | entityFieldLabel: entity.relatedReferenceFieldEntityType
                }}"
              </div>
            }
          </td>
          <td>
            <app-affected-entity-value
              [row]="entity"
              (valueChange)="onValueChange(entity, $event)"
              (useSuggestion)="useSuggestion(entity)"
            ></app-affected-entity-value>
          </td>
        </tr>
      }
    </table>
  }

  @if (recalculationFailed()) {
    <app-hint-box type="warning">
      <span i18n>
        The suggestions for the records above could not be updated. Change a
        value to try again.
      </span>
    </app-hint-box>
  }
</mat-dialog-content>

<mat-dialog-actions>
  <button mat-button [disabled]="recalculating()" (click)="onConfirm()" i18n>
    Update related records
  </button>
  <button mat-button mat-dialog-close i18n>Do not update</button>
</mat-dialog-actions>

./automated-field-update.component.scss

@use "variables/colors";
@use "variables/sizes";

.title {
  display: flex;
  align-items: center;
  gap: sizes.$small;
}

// the change that opened the dialog, collapsed to the triggering record until expanded
.trigger {
  margin-bottom: sizes.$regular;
  background-color: colors.$background-secondary;

  .trigger-header {
    display: flex;
    align-items: center;
    gap: sizes.$small;
  }

  .trigger-label {
    color: colors.$text-secondary;
    font-size: 0.85em;
  }

  // one changed field per line
  .trigger-change {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: sizes.$small;

    &:not(:last-child) {
      margin-bottom: sizes.$small;
    }
  }

  // the change as removed and added values, in the style of the values that the suggestions add and remove
  .trigger-removed,
  .trigger-added {
    display: inline-flex;
    align-items: center;
    gap: sizes.$small;
  }

  .trigger-removed {
    color: colors.$error;

    app-entity-field-view {
      text-decoration: line-through;
    }
  }

  .trigger-added app-fa-dynamic-icon {
    color: colors.$success;
  }
}

// a further group is set apart from the table of the group above
table + .trigger {
  margin-top: sizes.$large;
}

// records further up the hierarchy are indented, with a line along the way from the records they are reached from
tr.above > td:first-child {
  padding-left: calc(#{sizes.$large} * (var(--level) - 1));
  border-left: 2px solid colors.$border-color;
}

.hint-text {
  margin-bottom: sizes.$regular;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""