src/app/features/change-history/change-history-dialog/change-history-dialog.component.ts

Description

Dialog showing an entity's change history as a reverse-chronological accordion timeline.

Example

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods

Constructor

constructor()

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

Properties

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>&nbsp;
        <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>

./change-history-dialog.component.scss

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

results matching ""

    No results matching ""