src/app/features/change-history/record-diff/record-diff.component.ts

Description

Renders the field-level before -> after diff of a single ChangeEvent.

Values are raw database-format (enum ids, ISO date strings, entity-ref ids). To render them through the correct datatype view (and avoid [object Object] / raw ISO strings) each side is loaded into a transient entity via EntitySchemaService.loadDataIntoEntity, then displayed with app-entity-field-view — the same mechanism the merge/compare UI uses.

Example

Metadata

Relationships

Index

Properties
Inputs

Inputs

entityType
Type : EntityConstructor
Required :  true
event
Type : ChangeEvent
Required :  true

Properties

Readonly isStructural
Type : unknown
Default value : computed(() => this.event().action === "deleted")

a deleted record has no before/after pairs — show a structural message

Readonly rows
Type : unknown
Default value : computed<DiffRow[]>(() => { const ctor = this.entityType(); return this.event().changes.map((change) => ({ field: change.field, oldEntity: this.schemaService.loadDataIntoEntity( new ctor(), this.seed(change.field, change.from), ), newEntity: this.schemaService.loadDataIntoEntity( new ctor(), this.seed(change.field, change.to), ), hasFrom: !this.isEmpty(change.from), hasTo: !this.isEmpty(change.to), })); })
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  inject,
  input,
} from "@angular/core";
import { EntityFieldViewComponent } from "../../../core/entity/entity-field-view/entity-field-view.component";
import { EntityFieldLabelComponent } from "../../../core/entity/entity-field-label/entity-field-label.component";
import { FaDynamicIconComponent } from "../../../core/common-components/fa-dynamic-icon/fa-dynamic-icon.component";
import { EntitySchemaService } from "../../../core/entity/schema/entity-schema.service";
import { Entity, EntityConstructor } from "../../../core/entity/model/entity";
import { ChangeEvent } from "../change-history.types";

interface DiffRow {
  field: string;
  /** transient entity holding only the "before" value, hydrated via the schema */
  oldEntity: Entity;
  /** transient entity holding only the "after" value, hydrated via the schema */
  newEntity: Entity;
  hasFrom: boolean;
  hasTo: boolean;
}

/**
 * Renders the field-level before -> after diff of a single {@link ChangeEvent}.
 *
 * Values are raw database-format (enum ids, ISO date strings, entity-ref ids).
 * To render them through the correct datatype view (and avoid `[object Object]`
 * / raw ISO strings) each side is loaded into a transient entity via
 * {@link EntitySchemaService.loadDataIntoEntity}, then displayed with
 * `app-entity-field-view` — the same mechanism the merge/compare UI uses.
 */
@Component({
  selector: "app-record-diff",
  standalone: true,
  imports: [
    EntityFieldViewComponent,
    EntityFieldLabelComponent,
    FaDynamicIconComponent,
  ],
  templateUrl: "./record-diff.component.html",
  styleUrls: ["./record-diff.component.scss"],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RecordDiffComponent {
  readonly event = input.required<ChangeEvent>();
  readonly entityType = input.required<EntityConstructor>();

  private readonly schemaService = inject(EntitySchemaService);

  /** a deleted record has no before/after pairs — show a structural message */
  readonly isStructural = computed(() => this.event().action === "deleted");

  readonly rows = computed<DiffRow[]>(() => {
    const ctor = this.entityType();
    return this.event().changes.map((change) => ({
      field: change.field,
      oldEntity: this.schemaService.loadDataIntoEntity(
        new ctor(),
        this.seed(change.field, change.from),
      ),
      newEntity: this.schemaService.loadDataIntoEntity(
        new ctor(),
        this.seed(change.field, change.to),
      ),
      hasFrom: !this.isEmpty(change.from),
      hasTo: !this.isEmpty(change.to),
    }));
  });

  private seed(field: string, value: any): object {
    return this.isEmpty(value) ? {} : { [field]: value };
  }

  private isEmpty(value: any): boolean {
    return (
      value === undefined ||
      value === null ||
      value === "" ||
      (Array.isArray(value) && value.length === 0)
    );
  }
}
@if (event().note) {
  <div class="diff-note flex-row align-start gap-small">
    <app-fa-dynamic-icon icon="circle-info"></app-fa-dynamic-icon>
    <span>{{ event().note }}</span>
  </div>
}

@if (isStructural()) {
  <div class="structural" i18n="Change history deleted record message">
    Record was deleted. The full record at the time of deletion is retained in
    the audit store.
  </div>
} @else if (rows().length === 0) {
  <div class="structural" i18n="Change history no field changes">
    No field-level changes recorded for this entry.
  </div>
} @else {
  <div class="diff-grid flex-column gap-small">
    <div class="diff-head">
      <span class="head" i18n="Change history diff column">Field</span>
      <span class="head before" i18n="Change history diff column">Before</span>
      <span class="head"></span>
      <span class="head after" i18n="Change history diff column">After</span>
    </div>

    @for (row of rows(); track row.field) {
      <div class="diff-row">
        <div class="field-label">
          <app-entity-field-label
            [field]="row.field"
            [entityType]="entityType()"
          ></app-entity-field-label>
        </div>

        <div class="value before">
          <span class="value-caption" i18n="Change history diff inline caption"
            >↳ before:</span
          >
          @if (row.hasFrom) {
            <app-entity-field-view
              [entity]="row.oldEntity"
              [field]="row.field"
            ></app-entity-field-view>
          } @else {
            <span class="empty" i18n="Change history empty value">empty</span>
          }
        </div>

        <div class="arrow">
          <app-fa-dynamic-icon icon="arrow-right"></app-fa-dynamic-icon>
        </div>

        <div class="value after">
          <span class="value-caption" i18n="Change history diff inline caption"
            >↳ after:</span
          >
          @if (row.hasTo) {
            <app-entity-field-view
              [entity]="row.newEntity"
              [field]="row.field"
            ></app-entity-field-view>
          } @else {
            <span class="empty" i18n="Change history empty value">empty</span>
          }
        </div>
      </div>
    }
  </div>
}

./record-diff.component.scss

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

$diff-columns: 140px minmax(0, 1fr) 28px minmax(0, 1fr);

// layout (flex/align/gap) comes from utility classes in the template
.diff-note {
  padding: sizes.$small;
  margin-bottom: sizes.$regular;
  border-radius: 6px;
  // light info-blue (design); ndb brand palette is orange
  background: #eef4fb;
  color: colors.$text-secondary;
}

.structural {
  padding: sizes.$small;
  color: colors.$text-secondary;
  font-style: italic;
}

.diff-head,
.diff-row {
  display: grid;
  grid-template-columns: $diff-columns;
  column-gap: sizes.$small;
  align-items: start;
}

// allow value cells to shrink and their chips/text to wrap instead of
// overflowing the dialog (multi-value fields render several enum bubbles)
.field-label,
.value {
  min-width: 0;
  overflow-wrap: anywhere;
}

// multi-value enum fields render a flex row of bubbles with no wrap; let them
// wrap inside the (narrow) diff cells instead of overflowing the dialog
.value ::ng-deep .bubble-list {
  flex-wrap: wrap;
}

.head {
  font-size: 0.7em;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: colors.$muted;
  padding-bottom: sizes.$x-small;
  border-bottom: 1px solid colors.$grey-medium;
}

.head.before {
  color: colors.$error;
}

.head.after {
  color: colors.$success;
}

.field-label {
  color: colors.$text-secondary;
}

// values render through their own datatype view component (enum pills, dates,
// entity refs) — do not override their color; only style the empty placeholder
.value .empty {
  font-style: italic;
  color: colors.$muted;
}

// inline Before/After captions are only needed in the stacked narrow layout
// (the .diff-head column captions cover the wide layout); the text lives in the
// template so it stays translatable
.value-caption {
  display: none;
  font-size: 0.85em;
}

.arrow {
  color: colors.$muted;
  text-align: center;
}

// on narrow screens the 4-column grid does not fit; stack each field's
// label / before / after vertically with inline Before/After captions
@media (max-width: breakpoints.$sm) {
  .diff-head {
    display: none;
  }

  .diff-row {
    display: flex;
    flex-direction: column;
    row-gap: 2px;
    padding-bottom: sizes.$small;

    &:not(:last-child) {
      border-bottom: 1px solid colors.$grey-medium;
    }
  }

  .field-label {
    font-weight: 600;
    color: colors.$text;
  }

  .arrow {
    display: none;
  }

  .value .value-caption {
    display: inline;
    margin-right: 2px;
  }

  .value.before .value-caption {
    color: colors.$error;
  }

  .value.after .value-caption {
    color: colors.$success;
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""