src/app/features/change-history/record-diff/record-diff.component.ts
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.
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | app-record-diff |
| standalone | true |
| imports | |
| styleUrls | ./record-diff.component.scss |
| templateUrl | ./record-diff.component.html |
Properties |
|
Inputs |
| entityType | |
Type : EntityConstructor
|
|
| Required : true | |
| event | |
Type : ChangeEvent
|
|
| Required : true | |
| Readonly isStructural |
Type : unknown
|
Default value : computed(() => this.event().action === "deleted")
|
|
a deleted record has no before/after pairs — show a structural message |
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;
}
}