src/app/features/change-history/change-history-list/change-history-list.component.ts
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.
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | app-change-history-list |
| standalone | true |
| imports |
MatFormFieldModule
MatInputModule
MatButtonModule
MatTooltipModule
|
| styleUrls | ./change-history-list.component.scss |
| templateUrl | ./change-history-list.component.html |
| styleUrl | ./change-history-list.component.scss |
No results matching.
MatFormFieldModule
MatInputModule
MatButtonModule
MatTooltipModule
ViewTitleComponent
FeatureDisabledInfoComponent
EntitiesTableComponent
FilterComponent
FaDynamicIconComponent
Properties |
|
Methods |
constructor()
|
| 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 :
Returns :
boolean
|
| onFilterChange | ||||||
onFilterChange(filter: DataFilter<AuditRecord>)
|
||||||
|
Adopt what the shared filter bar built from the user's selection.
Parameters :
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 :
Returns :
any
|
| setRelatedEntityFilter | ||||||||
setRelatedEntityFilter(relatedEntityId: string | undefined)
|
||||||||
|
Example :
Parameters :
Returns :
void
|
| 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 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 sortBy |
Type : object
|
Default value : { active: "timestamp", direction: "desc" as const }
|
|
Newest first. Pinned explicitly because the default would order by |
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;
}