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

Description

Admin screen listing every recorded change across the whole system, newest first: when, which record, what kind of change, by whom and which fields.

Complements the per-record change-history dialog, which is unreachable for a deleted record because its details view is gone.

Example

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods

Constructor

constructor()

Methods

canOpenHistory
canOpenHistory(record: AuditRecord)

Whether this row can be opened as a record history, which takes only the changed record's id: the history is queried by id, and the dialog's other content (the record block, the created/updated metadata) is optional.

Parameters :
Name Type Optional
record AuditRecord No
Returns : boolean
onFilterChange
onFilterChange(filter: DataFilter<AuditRecord>)

Adopt what the shared filter bar built from the user's selection.

Parameters :
Name Type Optional
filter DataFilter<AuditRecord> No
Returns : void
Async openHistory
openHistory(record: AuditRecord)

Open the same per-record change-history dialog the record's details view offers, showing this record's full history rather than the single change of the clicked row.

Parameters :
Name Type Optional
record AuditRecord No
Returns : any
setRelatedEntityFilter
setRelatedEntityFilter(relatedEntityId: string | undefined)
Example :
   app (`User:1`); anything blank clears the filter
Parameters :
Name Type Optional Description
relatedEntityId string | undefined No

a record id as displayed and copied elsewhere in the app (User:1); anything blank clears the filter

Returns : void

Properties

Readonly auditEnabled
Type : unknown
Default value : this.service.isAuditEnabled

backend feature flag (undefined while loading, then true/false)

Readonly auditRecordType
Type : unknown
Default value : AuditRecord
Readonly columns
Type : []
Default value : [ { id: "timestamp", label: $localize`:Change log column:When`, viewComponent: "DisplayAuditTimestamp", }, { id: "recordType", label: $localize`:Change log column:Record type`, viewComponent: "DisplayEntityType", }, { id: "record", label: $localize`:Change log column:Record`, viewComponent: "DisplayAuditRecord", }, { id: "operation", label: $localize`:Change log column:Action`, viewComponent: "ChangeHistoryActionBadge", }, { id: "user", label: $localize`:Change log column:Changed by`, viewComponent: "DisplayAuditUser", }, { id: "changedFields", label: $localize`:Change log column:Changed fields`, viewComponent: "ChangeHistoryChangedFields", }, ]
Readonly dataSource
Type : unknown
Default value : new PaginatedDataSource<AuditRecord>()

Built here rather than resolved by the table, because resolveDataSource decides on the session type while this type's database is remote in every session. The table takes a data source as an input, so nothing else is needed to bypass it.

It also answers the related-record filter, which needs a different query than the rest of the filter bar.

Readonly filter
Type : unknown
Default value : computed(() => combineFilterConditions<AuditRecord>( AUDIT_BASE_FILTER, this.selectedFilter(), ), )

what the table queries: the user's selection under AUDIT_BASE_FILTER

Readonly filterConfig
Type : unknown
Default value : computed<FilterConfig[]>(() => this.relatedEntityFilter() ? [this.dateFilterConfig] : this.allFilterConfigs(), )
Readonly hasPermission
Type : unknown
Default value : this.service.hasHistoryPermission()

whether the user may read the audit data

Readonly relatedEntityFilter
Type : unknown
Default value : signal<string | undefined>(undefined)
Readonly selectedFilter
Type : unknown
Default value : signal<DataFilter<AuditRecord>>({})

The selection the shared filter bar produces.

Bound back into it, not only read out of it: the bar compares each new selection against this before emitting, so leaving it unset would suppress the emission that clears the last filter - the list would go on querying a selection no longer shown.

Readonly sortBy
Type : object
Default value : { active: "timestamp", direction: "desc" as const }

Newest first. Pinned explicitly because the default would order by _id, which groups an audit database by record rather than by time - plausible looking, and wrong.

import { Component, computed, inject, resource, signal } from "@angular/core";
import { MatDialog } from "@angular/material/dialog";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatButtonModule } from "@angular/material/button";
import { MatTooltipModule } from "@angular/material/tooltip";
import { ViewTitleComponent } from "../../../core/common-components/view-title/view-title.component";
import { FeatureDisabledInfoComponent } from "../../../core/common-components/feature-disabled-info/feature-disabled-info.component";
import { EntitiesTableComponent } from "../../../core/common-components/entities-table/entities-table.component";
import { PaginatedDataSource } from "../../../core/common-components/entities-table/data-source/paginated-data-source";
import { LoaderMethod } from "../../../core/entity/entity-special-loader/entity-special-loader.service";
import { FilterComponent } from "../../../core/filter/filter/filter.component";
import {
  combineFilterConditions,
  DataFilter,
} from "../../../core/filter/filters/filters";
import {
  DateRangeFilterConfig,
  DateRangeFilterConfigOption,
  FilterConfig,
  PrebuiltFilterConfig,
} from "../../../core/entity-list/EntityListConfig";
import { EntityRegistry } from "../../../core/entity/database-entity.decorator";
import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service";
import { Entity } from "../../../core/entity/model/entity";
import { Logging } from "../../../core/logging/logging.service";
import { ChangeHistoryService } from "../change-history.service";
import { FaDynamicIconComponent } from "../../../core/common-components/fa-dynamic-icon/fa-dynamic-icon.component";
import { ChangeHistoryDialogComponent } from "../change-history-dialog/change-history-dialog.component";
import { AuditRecord } from "../model/audit-record";
import { TableStateUrlService } from "../../../core/common-components/entities-table/table-state-url.service";
import {
  authorFilterOptions,
  AUDIT_BASE_FILTER,
  entityTypeFilterOptions,
  operationFilterOptions,
} from "../audit-filter";

/**
 * Presets offered by the date-range filter, alongside the two date inputs the
 * shared filter also provides. Same shape and periods as the reports screen, so
 * "last month" means the same thing in both places.
 */
export const CHANGE_HISTORY_DATE_RANGES: DateRangeFilterConfigOption[] = [
  {
    startOffsets: [{ amount: 0, unit: "weeks" }],
    endOffsets: [{ amount: 0, unit: "weeks" }],
    label: $localize`:Change log date filter:Current week`,
  },
  {
    startOffsets: [{ amount: 0, unit: "months" }],
    endOffsets: [{ amount: 0, unit: "months" }],
    label: $localize`:Change log date filter:Current month`,
  },
  {
    startOffsets: [{ amount: -1, unit: "months" }],
    endOffsets: [{ amount: -1, unit: "months" }],
    label: $localize`:Change log date filter:Last month`,
  },
  {
    startOffsets: [{ amount: 0, unit: "quarter" }],
    endOffsets: [{ amount: 0, unit: "quarter" }],
    label: $localize`:Change log date filter:Current quarter`,
  },
  {
    startOffsets: [{ amount: 0, unit: "years" }],
    endOffsets: [{ amount: 0, unit: "years" }],
    label: $localize`:Change log date filter:Current year`,
  },
];

/**
 * Admin screen listing every recorded change across the whole system, newest
 * first: when, which record, what kind of change, by whom and which fields.
 *
 * Complements the per-record change-history dialog, which is unreachable for a
 * deleted record because its details view is gone.
 */
@Component({
  selector: "app-change-history-list",
  imports: [
    MatFormFieldModule,
    MatInputModule,
    MatButtonModule,
    MatTooltipModule,
    ViewTitleComponent,
    FeatureDisabledInfoComponent,
    EntitiesTableComponent,
    FilterComponent,
    FaDynamicIconComponent,
  ],
  templateUrl: "./change-history-list.component.html",
  styleUrl: "./change-history-list.component.scss",
})
export class ChangeHistoryListComponent {
  private readonly service = inject(ChangeHistoryService);
  private readonly entityRegistry = inject(EntityRegistry);
  private readonly entityMapper = inject(EntityMapperService);
  private readonly tableStateUrl = inject(TableStateUrlService);
  private readonly dialog = inject(MatDialog);

  /** 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();

  readonly auditRecordType = AuditRecord;

  /**
   * Built here rather than resolved by the table, because resolveDataSource
   * decides on the session type while this type's database is remote in every
   * session. The table takes a data source as an input, so nothing else is
   * needed to bypass it.
   *
   * It also answers the related-record filter, which needs a different query
   * than the rest of the filter bar.
   */
  readonly dataSource = new PaginatedDataSource<AuditRecord>();

  /**
   * Newest first. Pinned explicitly because the default would order by `_id`,
   * which groups an audit database by record rather than by time - plausible
   * looking, and wrong.
   */
  readonly sortBy = { active: "timestamp", direction: "desc" as const };

  readonly columns = [
    {
      id: "timestamp",
      label: $localize`:Change log column:When`,
      viewComponent: "DisplayAuditTimestamp",
    },
    {
      id: "recordType",
      label: $localize`:Change log column:Record type`,
      viewComponent: "DisplayEntityType",
    },
    {
      id: "record",
      label: $localize`:Change log column:Record`,
      viewComponent: "DisplayAuditRecord",
    },
    {
      id: "operation",
      label: $localize`:Change log column:Action`,
      viewComponent: "ChangeHistoryActionBadge",
    },
    {
      id: "user",
      label: $localize`:Change log column:Changed by`,
      viewComponent: "DisplayAuditUser",
    },
    {
      id: "changedFields",
      label: $localize`:Change log column:Changed fields`,
      viewComponent: "ChangeHistoryChangedFields",
    },
  ];

  /**
   * The selection the shared filter bar produces.
   *
   * Bound back into it, not only read out of it: the bar compares each new
   * selection against this before emitting, so leaving it unset would suppress
   * the emission that clears the last filter - the list would go on querying a
   * selection no longer shown.
   */
  readonly selectedFilter = signal<DataFilter<AuditRecord>>({});

  readonly relatedEntityFilter = signal<string | undefined>(undefined);

  /**
   * The related-record filter is served by a view keyed on the referenced id,
   * so only the date range can narrow it further: record type, operation and
   * author would need a different key order. They are therefore not offered
   * while it is set, rather than offered and silently ignored.
   */
  /** every dimension the ordinary query can be narrowed by */
  private readonly allFilterConfigs = computed<FilterConfig[]>(() => [
    this.entityTypeFilterConfig,
    this.operationFilterConfig,
    this.authorFilterConfig(),
    this.dateFilterConfig,
  ]);

  readonly filterConfig = computed<FilterConfig[]>(() =>
    this.relatedEntityFilter()
      ? [this.dateFilterConfig]
      : this.allFilterConfigs(),
  );

  /** what the table queries: the user's selection under {@link AUDIT_BASE_FILTER} */
  readonly filter = computed(() =>
    combineFilterConditions<AuditRecord>(
      AUDIT_BASE_FILTER,
      this.selectedFilter(),
    ),
  );

  private readonly entityTypeFilterConfig: PrebuiltFilterConfig<AuditRecord> = {
    id: "entityType",
    type: "prebuilt",
    label: $localize`:Change log filter label:Record type`,
    singleSelectOnly: true,
    options: entityTypeFilterOptions(
      this.entityRegistry
        .getEntityTypes(true)
        .map(({ key, value }) => ({ key, label: value.label }))
        .sort((a, b) => a.label.localeCompare(b.label)),
    ),
  };

  private readonly operationFilterConfig: PrebuiltFilterConfig<AuditRecord> = {
    id: "operation",
    type: "prebuilt",
    label: $localize`:Change log filter label:Action`,
    singleSelectOnly: true,
    options: operationFilterOptions(),
  };

  private readonly authorFilterConfig = computed<
    PrebuiltFilterConfig<AuditRecord>
  >(() => ({
    id: "changedBy",
    type: "prebuilt",
    label: $localize`:Change log filter label:Changed by`,
    singleSelectOnly: true,
    options: authorFilterOptions(this.authors()),
  }));

  /**
   * The shared date-range filter, the same control (and presets shape) the
   * reports screen uses, rather than a change-history-specific dropdown.
   */
  private readonly dateFilterConfig: DateRangeFilterConfig = {
    id: "timestamp",
    label: $localize`:Change log filter label:Date range`,
    options: CHANGE_HISTORY_DATE_RANGES,
  };

  /** Whether the audit data can be queried at all. */
  private readonly canQuery = computed(
    () => this.auditEnabled() === true && this.hasPermission,
  );

  private readonly authorsResource = resource({
    params: () => (this.canQuery() ? {} : undefined),
    // an unavailable author list only costs the filter its options, so it must
    // not fail the screen
    loader: async () => {
      try {
        return await this.service.getChangeAuthors();
      } catch (err) {
        Logging.debug("could not load the change log's author options", err);
        return [];
      }
    },
  });

  private readonly authors = computed(() => this.authorsResource.value() ?? []);

  constructor() {
    // the flag fetch is lazy, so nothing loads until a change-history UI asks
    this.service.loadAuditFeatureFlag();
    this.applyRelatedRecord();
  }

  /** Adopt what the shared filter bar built from the user's selection. */
  onFilterChange(filter: DataFilter<AuditRecord>) {
    this.selectedFilter.set(filter);
    // the related-record view is keyed on the date range too, so a changed
    // range has to re-query it
    if (this.relatedEntityFilter()) {
      this.applyRelatedRecord();
    }
  }

  /**
   * @param relatedEntityId a record id as displayed and copied elsewhere in the
   *        app (`User:1`); anything blank clears the filter
   */
  setRelatedEntityFilter(relatedEntityId: string | undefined) {
    this.relatedEntityFilter.set(relatedEntityId?.trim() || undefined);
    if (this.relatedEntityFilter()) {
      // the filters this mode no longer offers have to go from the URL too:
      // the shared filter bar restores any parameter naming a field of the
      // entity, so a left-over one would come back as an unconfigured filter -
      // matching by substring rather than exactly, labelled by its field name,
      // and narrowing a query the related-record view does not apply it to
      const stillOffered = new Set(this.filterConfig().map((c) => c.id));
      this.tableStateUrl.clearFilterParams(
        this.allFilterConfigs()
          .map((c) => c.id)
          .filter((id) => !stillOffered.has(id)),
      );
    }
    this.applyRelatedRecord();
  }

  /**
   * Point the list at the query the current filters need: the ordinary audit
   * records, or - when a related record is named - the loader that serves the
   * view keyed on it. Changing the config resets the paging, since everything
   * loaded so far answered the previous query.
   */
  private applyRelatedRecord() {
    const recordId = this.relatedEntityFilter();
    this.dataSource.loadRecordConfig.set({
      entityCtr: AuditRecord,
      loaderMethod: recordId
        ? LoaderMethod.AuditRecordsRelatedToEntity
        : undefined,
      forEntity: recordId ? this.standInFor(recordId) : undefined,
    });
  }

  /**
   * Whether this row can be opened as a record history, which takes only the
   * changed record's id: the history is queried by id, and the dialog's other
   * content (the record block, the created/updated metadata) is optional.
   */
  canOpenHistory(record: AuditRecord): boolean {
    return !!record.record;
  }

  /**
   * Open the same per-record change-history dialog the record's details view
   * offers, showing this record's full history rather than the single change of
   * the clicked row.
   */
  async openHistory(record: AuditRecord) {
    if (!this.canOpenHistory(record)) {
      return;
    }

    ChangeHistoryDialogComponent.open(
      this.dialog,
      await this.loadRecord(record),
      // the row's own audit record, so the dialog opens on the change that was
      // clicked rather than a collapsed list to search through again
      record.getId(),
    );
  }

  /**
   * The record of a change-log row, as an entity for the history dialog.
   *
   * A deleted record - which the log deliberately still lists - can no longer be
   * loaded, so an empty instance carrying just its id stands in: the dialog needs
   * the id to query the history and the type for the field labels, and it hides
   * the created/last-updated metadata that such a stand-in has none of.
   */
  /**
   * A placeholder for a record that can no longer be loaded, carrying its id so
   * the history can still be queried by it.
   *
   * Uses the registered constructor where there is one, so the dialog still
   * shows the right type. Where there is not, the stand-in carries the record's
   * type anyway: the plain Entity would prefix the id a second time
   * ("Entity:Child:1") and query a history that does not exist. An unregistered
   * type is otherwise fine here - only the id is needed to load the history,
   * and the dialog's remaining content is optional.
   */
  private standInFor(recordId: string): Entity {
    const recordType = Entity.extractTypeFromId(recordId);
    const ctor = this.entityRegistry.has(recordType)
      ? this.entityRegistry.get(recordType)
      : class extends Entity {
          static override readonly ENTITY_TYPE = recordType;
        };
    return new ctor(recordId);
  }

  private async loadRecord(record: AuditRecord): Promise<Entity> {
    try {
      return await this.entityMapper.load(record.recordType, record.record);
    } catch (err) {
      Logging.debug(
        "change log: record not available, showing its history only",
        record.record,
        err,
      );
      return this.standInFor(record.record);
    }
  }
}
<app-view-title i18n="Change log title">Change Log</app-view-title>

<p i18n="Change log intro" class="margin-bottom-regular">
  All changes recorded across every record of your system, newest first. Only
  changes already synced to the server are listed. Click a row to see the full
  history of that record.
</p>

<!-- 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 Log"
  i18n-featureName="Change log feature name"
></app-feature-disabled-info>

@if (auditEnabled() === true) {
  @if (!hasPermission) {
    <div class="flex-column align-center gap-regular text-secondary">
      <app-fa-dynamic-icon icon="clock-rotate-left"></app-fa-dynamic-icon>
      <p i18n="Change log no access">
        You don't have permission to view the change log. Please contact your
        administrator.
      </p>
    </div>
  } @else {
    <div class="flex-row flex-wrap gap-regular align-baseline">
      <app-filter
        class="flex-row gap-regular flex-wrap"
        [filterConfig]="filterConfig()"
        [entityType]="auditRecordType"
        [useUrlQueryParams]="true"
        [filterObj]="selectedFilter()"
        (filterObjChange)="onFilterChange($event)"
      ></app-filter>

      <!-- not one of the filters above: it selects a different query rather
           than narrowing this one, and takes a record id instead of an option -->
      <mat-form-field class="related-record-filter">
        <mat-label i18n="Change log filter label">Related record</mat-label>
        <input
          matInput
          #relatedRecordInput
          type="text"
          placeholder="e.g. User:1"
          i18n-placeholder="Change log filter placeholder"
          [value]="relatedEntityFilter() ?? ''"
          (change)="setRelatedEntityFilter(relatedRecordInput.value)"
        />
        @if (relatedEntityFilter()) {
          <button
            matSuffix
            mat-icon-button
            type="button"
            matTooltip="Clear related record filter"
            i18n-matTooltip
            aria-label="Clear related record filter"
            i18n-aria-label
            (click)="
              relatedRecordInput.value = ''; setRelatedEntityFilter(undefined)
            "
          >
            <app-fa-dynamic-icon icon="times"></app-fa-dynamic-icon>
          </button>
        }
      </mat-form-field>
    </div>

    <!-- a row opens the record's full history, the same dialog its details view
         offers - the log shows only the one change of that row -->
    <app-entities-table
      [recordsDataSource]="dataSource"
      [entityType]="auditRecordType"
      [customColumns]="columns"
      [filter]="filter()"
      [sortBy]="sortBy"
      [showInactiveToggle]="false"
      [showInactive]="true"
      [editable]="false"
      clickMode="none"
      (entityClick)="openHistory($event)"
    ></app-entities-table>
  }
}

./change-history-list.component.scss

/* record ids can be long (a type prefix plus a uuid), so give the input more
   room than the selects next to it get by default */
.related-record-filter {
  min-width: 260px;
}

:host ::ng-deep .mat-mdc-paginator .mat-mdc-paginator-container {
  justify-content: flex-start;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""