Description
Dialog showing an entity's change history as a reverse-chronological
accordion timeline.
Example
Methods
|
Static
open
|
open(dialog: MatDialog, entity: Entity)
|
|
|
Open the change-history dialog for an entity (single source of sizing/data).
Parameters :
| Name |
Type |
Optional |
| dialog |
MatDialog
|
No
|
| entity |
Entity
|
No
|
|
|
Readonly
auditEnabled
|
Type : unknown
|
Default value : this.service.isAuditEnabled
|
|
|
backend feature flag (undefined while loading, then true/false)
|
|
Readonly
created
|
Type : unknown
|
Default value : this.entity.created
|
|
|
|
Readonly
entity
|
Type : Entity
|
Default value : this.data.entity
|
|
|
|
Readonly
entityType
|
Type : EntityConstructor
|
Default value : this.entity.getConstructor()
|
|
|
|
Readonly
events
|
Type : unknown
|
Default value : signal<ChangeEvent[] | null>(null)
|
|
|
null while loading, then the loaded (possibly empty) list
|
|
Readonly
hasPermission
|
Type : unknown
|
Default value : this.service.hasHistoryPermission()
|
|
|
whether the user may read the audit data
|
|
Readonly
loadError
|
Type : unknown
|
Default value : signal(false)
|
|
|
true when the audit-db read failed despite the feature being enabled
|
|
Readonly
updated
|
Type : unknown
|
Default value : this.entity.updated
|
|
|
The entity's own created / last-updated metadata. Pure entity state, so it
is always shown — even when the audit history below is unavailable.
|
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
signal,
} from "@angular/core";
import { AsyncPipe, NgTemplateOutlet } from "@angular/common";
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogModule,
MatDialogRef,
} from "@angular/material/dialog";
import { MatButtonModule } from "@angular/material/button";
import { MatExpansionModule } from "@angular/material/expansion";
import { MatProgressBarModule } from "@angular/material/progress-bar";
import { FaDynamicIconComponent } from "../../../core/common-components/fa-dynamic-icon/fa-dynamic-icon.component";
import { DialogCloseComponent } from "../../../core/common-components/dialog-close/dialog-close.component";
import { HintBoxComponent } from "../../../core/common-components/hint-box/hint-box.component";
import { FeatureDisabledInfoComponent } from "../../../core/common-components/feature-disabled-info/feature-disabled-info.component";
import { EntityBlockComponent } from "../../../core/basic-datatypes/entity/entity-block/entity-block.component";
import { CustomDatePipe } from "../../../core/basic-datatypes/date/custom-date.pipe";
import { NotificationTimePipe } from "../../notification/notification-time.pipe";
import { Entity, EntityConstructor } from "../../../core/entity/model/entity";
import { ChangeHistoryService } from "../change-history.service";
import { ChangeEvent } from "../change-history.types";
import { ChangeHistoryActionBadgeComponent } from "../change-history-action-badge/change-history-action-badge.component";
import { RecordDiffComponent } from "../record-diff/record-diff.component";
import { MatTooltipModule } from "@angular/material/tooltip";
export interface ChangeHistoryDialogData {
entity: Entity;
}
/**
* Dialog showing an entity's change history as a reverse-chronological
* accordion timeline.
*/
@Component({
selector: "app-change-history-dialog",
standalone: true,
imports: [
AsyncPipe,
NgTemplateOutlet,
MatDialogModule,
MatButtonModule,
MatExpansionModule,
MatProgressBarModule,
MatTooltipModule,
FaDynamicIconComponent,
DialogCloseComponent,
HintBoxComponent,
FeatureDisabledInfoComponent,
EntityBlockComponent,
CustomDatePipe,
NotificationTimePipe,
ChangeHistoryActionBadgeComponent,
RecordDiffComponent,
],
templateUrl: "./change-history-dialog.component.html",
styleUrls: ["./change-history-dialog.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChangeHistoryDialogComponent {
/** Open the change-history dialog for an entity (single source of sizing/data). */
static open(
dialog: MatDialog,
entity: Entity,
): MatDialogRef<ChangeHistoryDialogComponent> {
return dialog.open(ChangeHistoryDialogComponent, {
data: { entity } satisfies ChangeHistoryDialogData,
// near-fullscreen for the wide diff table; maxWidth must be raised too,
// else Material's default maxWidth (80vw) caps the 98vw width
width: "98vw",
maxWidth: "98vw",
});
}
private readonly service = inject(ChangeHistoryService);
private readonly data = inject<ChangeHistoryDialogData>(MAT_DIALOG_DATA);
readonly entity: Entity = this.data.entity;
readonly entityType: EntityConstructor = this.entity.getConstructor();
/**
* The entity's own created / last-updated metadata. Pure entity state, so it
* is always shown — even when the audit history below is unavailable.
*/
readonly updated = this.entity.updated;
readonly created = this.entity.created;
/** backend feature flag (undefined while loading, then true/false) */
readonly auditEnabled = this.service.isAuditEnabled;
/** whether the user may read the audit data */
readonly hasPermission = this.service.hasHistoryPermission();
/** null while loading, then the loaded (possibly empty) list */
readonly events = signal<ChangeEvent[] | null>(null);
/** true when the audit-db read failed despite the feature being enabled */
readonly loadError = signal(false);
private fetchStarted = false;
constructor() {
// trigger the (lazy) backend feature-flag fetch now that the dialog is open
this.service.loadAuditFeatureFlag();
// Fetch the history once we know the feature is enabled and the user is
// permitted. Driven by the feature-flag signal so a no-backend deployment
// resolves to "disabled" (clean notice) rather than an audit-db read error.
effect(() => {
if (
this.auditEnabled() === true &&
this.hasPermission &&
!this.fetchStarted
) {
this.fetchStarted = true;
void this.loadHistory();
}
});
}
private async loadHistory() {
try {
this.events.set(await this.service.getHistory(this.entity));
} catch {
this.loadError.set(true);
this.events.set([]);
}
}
}
<h2 mat-dialog-title class="flex-row align-center gap-small">
<span class="flex-grow" i18n="Change history dialog title"
>Change History</span
>
<app-dialog-close></app-dialog-close>
</h2>
<mat-dialog-content>
<!-- The entity's own created / last-updated metadata. Pure entity state, so
it is always shown — even when the audit history below is unavailable. -->
<div
class="entity-meta flex-row flex-wrap"
matTooltip="When this record was created and last edited - including recent or offline changes."
i18n-matTooltip="Change history tooltip"
>
@if (updated) {
<div>
<div class="flex-row gap-small align-baseline">
<app-fa-dynamic-icon
class="meta-icon"
icon="pen-to-square"
></app-fa-dynamic-icon>
<span class="meta-line flex-column" i18n="Change history">
Last updated
</span>
</div>
<ng-container
*ngTemplateOutlet="
lastUpdated;
context: {
by: updated.by,
at: updated.at,
}
"
></ng-container>
</div>
}
@if (created) {
<div>
<div class="flex-row gap-small align-baseline">
<app-fa-dynamic-icon
class="meta-icon"
icon="circle-plus"
></app-fa-dynamic-icon>
<span class="meta-line flex-column" i18n="Change history">
Created
</span>
</div>
<ng-container
*ngTemplateOutlet="
lastUpdated;
context: {
by: created.by,
at: created.at,
}
"
></ng-container>
</div>
}
<ng-template #lastUpdated let-by="by" let-at="at">
<div class="hint-text">
<span i18n>by</span>
<app-entity-block [entityId]="by"></app-entity-block>
</div>
<div class="hint-text">
{{ at | customDate: "short" }}
({{ at | notificationTime | async }})
</div>
</ng-template>
</div>
<h3 class="section-title" i18n="Change history log section title">
Change log
</h3>
<!-- feature-flag state: nothing when enabled, a disabled notice when off, a
loading indicator while the flag is still being fetched -->
<app-feature-disabled-info
[featureEnabled]="auditEnabled()"
featureName="Change History"
i18n-featureName="Change history feature name"
></app-feature-disabled-info>
@if (auditEnabled() === true) {
@if (!hasPermission) {
<div
class="empty-state flex-column align-center gap-regular text-secondary"
>
<app-fa-dynamic-icon icon="clock-rotate-left"></app-fa-dynamic-icon>
<p i18n="Change history no access">
You don't have permission to view the detailed change history of this
record. The created and last-updated information above is still
available.
</p>
</div>
} @else if (events() === null) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
} @else if (loadError()) {
<div
class="empty-state flex-column align-center gap-regular text-secondary"
>
<app-fa-dynamic-icon icon="clock-rotate-left"></app-fa-dynamic-icon>
<p i18n="Change history load error">
Couldn't load the change history — there may be a temporary problem.
Please try again or contact your administrator.
</p>
</div>
} @else if (events().length === 0) {
<div
class="empty-state flex-column align-center gap-regular text-secondary"
>
<app-fa-dynamic-icon icon="clock-rotate-left"></app-fa-dynamic-icon>
<p i18n="Change history empty">No change history recorded yet.</p>
</div>
} @else {
<app-hint-box class="history-hints">
<p i18n="Change history sync hint">
Only changes already synced to the server are shown.
</p>
<p i18n="Change history linked-records hint">
Actions that link this record to another (e.g. adding a note) appear
in the change history of that related record, not here.
</p>
</app-hint-box>
<mat-accordion class="timeline">
@for (event of events() ?? []; track event.id; let isFirst = $first) {
<mat-expansion-panel>
<mat-expansion-panel-header
class="event-header"
collapsedHeight="56px"
expandedHeight="56px"
>
<mat-panel-title
class="flex-row align-center gap-regular full-width"
>
<app-change-history-action-badge
[action]="event.action"
></app-change-history-action-badge>
<span class="user flex-grow">
@if (event.by) {
<app-entity-block [entityId]="event.by"></app-entity-block>
}
</span>
<span class="when">
<span>{{ event.at | customDate: "short" }}</span>
@if (isFirst) {
<span class="relative">{{
event.at | notificationTime | async
}}</span>
}
</span>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<app-record-diff
[event]="event"
[entityType]="entityType"
></app-record-diff>
</ng-template>
</mat-expansion-panel>
}
</mat-accordion>
}
}
</mat-dialog-content>
<mat-dialog-actions
align="end"
class="flex-row align-center gap-regular padding-top-small"
>
<button mat-raised-button color="accent" mat-dialog-close i18n>Close</button>
</mat-dialog-actions>
@use "variables/colors";
@use "variables/sizes";
@use "variables/breakpoints";
// design-specific light info-blue to differentiate the expanded panel
$history-open-bg: #f1f6fb;
// small section labels distinguishing the "Summary" (record metadata) from the
// "Change log" (server-recorded history) so the two aren't read as one list
.section-title {
font-size: 0.8em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: colors.$muted;
margin: 0 0 sizes.$x-small;
}
// entity created / last-updated metadata, always shown above the audit history
.entity-meta {
padding-bottom: sizes.$regular;
margin-bottom: sizes.$regular;
border-bottom: 1px solid colors.$border-color;
// wider gap between the "last updated by" and "created by" columns
column-gap: sizes.$large * 2;
row-gap: sizes.$regular;
.meta-icon {
// muted grey so the icons read as decorative, not clickable
color: colors.$muted;
margin-top: 2px;
}
}
.text-center {
text-align: center;
}
.history-hints {
font-size: 0.85em;
p {
margin: 0;
&:not(:last-child) {
margin-bottom: sizes.$x-small;
}
}
}
.empty-state {
padding: sizes.$large sizes.$regular;
text-align: center;
}
// card-style expansion panels: each panel is a separated, elevated card with a
// border and spacing between them; the expanded header gets a light-blue tint
.timeline {
display: block;
::ng-deep .mat-expansion-panel {
margin-bottom: sizes.$small;
border: 1px solid colors.$grey-medium;
border-radius: 6px;
// even, soft shadow on all sides (override Material's bottom-heavy elevation)
box-shadow: 0 0 5px rgba(0, 0, 0, 0.08);
// tint the whole open card (header + body) to differentiate it
&.mat-expanded {
background: $history-open-bg;
}
}
::ng-deep .mat-expansion-panel-header {
&:hover {
background: rgba(0, 0, 0, 0.04);
}
}
}
.event-header {
.user {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.when {
display: flex;
flex-direction: column;
align-items: flex-end;
color: colors.$text-secondary;
margin-right: sizes.$regular;
flex-shrink: 0;
.relative {
color: colors.$muted;
font-size: 0.85em;
}
}
}
// on narrow screens the fixed-height header overlaps; let it grow and wrap the
// date onto its own line below the badge + author
@media (max-width: breakpoints.$sm) {
.timeline ::ng-deep .mat-expansion-panel-header {
height: auto !important;
padding-top: sizes.$small;
padding-bottom: sizes.$small;
}
.event-header ::ng-deep mat-panel-title {
flex-wrap: wrap;
}
.event-header .when {
width: 100%;
flex-direction: row;
align-items: center;
gap: sizes.$small;
margin-right: 0;
}
}
Legend
Html element with directive