src/app/core/common-components/entities-table/entities-table.component.ts
A reusable table component for displaying, sorting, filtering, and selecting entities.
| changeDetection | ChangeDetectionStrategy.OnPush |
| providers |
EntitiesTableSortStore
EntitiesTableSelectionStore
|
| selector | app-entities-table |
| imports |
EntityFieldEditComponent
EntityFieldLabelComponent
EntityFieldViewComponent
ListPaginatorComponent
MatCheckboxModule
MatProgressBarModule
MatSlideToggleModule
MatSortModule
MatTableModule
EntityInlineEditActionsComponent
EntityCreateButtonComponent
|
| templateUrl | ./entities-table.component.html |
| styleUrl | ./entities-table.component.scss |
Properties |
|
Methods |
Inputs |
Outputs |
Accessors |
constructor()
|
| clickMode | |
Type : "popup" | "navigate" | "popup-details" | "none"
|
|
Default value : "popup"
|
|
| columnsToDisplay | |
Type : string[]
|
|
| customColumns | |
Type : ColumnConfig[], ColumnConfig[] | undefined
|
|
Default value : [], { transform: (value) => value ?? [], }
|
|
| editable | |
Type : boolean
|
|
Default value : true
|
|
| entityType | |
Type : EntityConstructor<T>
|
|
| filter | |
Type : DataFilter<T>, DataFilter<T> | undefined
|
|
Default value : {}, { transform: (value) => value ?? {}, },
|
|
| filterFreetext | |
Type : string
|
|
| getBackgroundColor | |
Type : (rec: T) => string
|
|
| newRecordFactory | |
Type : () => T
|
|
| records | |
Type : T[]
|
|
| selectable | |
Type : boolean
|
|
Default value : false
|
|
| selectedRecords | |
Type : T[]
|
|
Default value : []
|
|
| showEntityColor | |
Type : boolean
|
|
Default value : false
|
|
| showInactive | |
Type : boolean
|
|
Default value : false
|
|
| sortBy | |
Type : Sort
|
|
| entityClick | |
Type : T
|
|
| filteredRecordsChange | |
Type : T[]
|
|
| selectedRecords | |
Type : T[]
|
|
| showInactive | |
Type : boolean
|
|
| getCurrentPageRows |
getCurrentPageRows()
|
|
Returns :
TableRow[]
|
| onRowClick | |||||||||
onRowClick(row: TableRow<T>, event: MouseEvent)
|
|||||||||
|
Parameters :
Returns :
void
|
| onRowMouseDown | |||||||||
onRowMouseDown(event: MouseEvent, row: TableRow<T>)
|
|||||||||
|
Parameters :
Returns :
void
|
| showEntity | ||||||
showEntity(entity: T)
|
||||||
|
Parameters :
Returns :
void
|
| Readonly _columns |
Type : unknown
|
Default value : this.sortStore.columns
|
|
Columns with sorting rules applied (managed by the sort store). |
| Readonly ACTIONCOLUMN_EDIT |
Type : string
|
Default value : "__edit"
|
| Readonly ACTIONCOLUMN_SELECT |
Type : string
|
Default value : "__select"
|
| Readonly idForSavingPagination |
Type : unknown
|
Default value : computed(() =>
this._customColumns()
.map((column) => column.id)
.join(""),
)
|
| Readonly isLoading |
Type : unknown
|
Default value : signal(true)
|
| projectedColumns |
Type : QueryList<MatColumnDef>
|
Decorators :
@ContentChildren(MatColumnDef)
|
| Readonly recordsDataSource |
Type : unknown
|
Default value : this.createDataSource()
|
| Protected Readonly selectionStore |
Type : unknown
|
Default value : inject(
EntitiesTableSelectionStore,
) as EntitiesTableSelectionStore<T>
|
| Protected Readonly sortStore |
Type : unknown
|
Default value : inject(
EntitiesTableSortStore,
) as EntitiesTableSortStore<T>
|
| table |
Type : MatTable<T>
|
Decorators :
@ViewChild(MatTable, {static: true})
|
| sort | ||||||
setsort(sort: MatSort)
|
||||||
|
Parameters :
Returns :
void
|
import {
AfterContentInit,
ChangeDetectionStrategy,
Component,
computed,
ContentChildren,
effect,
inject,
input,
model,
output,
QueryList,
signal,
ViewChild,
} from "@angular/core";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { MatProgressBarModule } from "@angular/material/progress-bar";
import { MatSlideToggleModule } from "@angular/material/slide-toggle";
import { MatSort, MatSortModule, Sort } from "@angular/material/sort";
import {
MatColumnDef,
MatTable,
MatTableDataSource,
MatTableModule,
} from "@angular/material/table";
import { Router } from "@angular/router";
import { EntityFieldEditComponent } from "../../entity/entity-field-edit/entity-field-edit.component";
import { EntityFieldLabelComponent } from "../../entity/entity-field-label/entity-field-label.component";
import { EntityFieldViewComponent } from "../../entity/entity-field-view/entity-field-view.component";
import { getEntityRuntimeRoute } from "../../entity/entity-config.service";
import { Entity, EntityConstructor } from "../../entity/model/entity";
import { entityFilterPredicate } from "../../filter/filter-generator/filter-predicate";
import { FilterService } from "../../filter/filter.service";
import { DataFilter } from "../../filter/filters/filters";
import { FormDialogService } from "../../form-dialog/form-dialog.service";
import { EntityCreateButtonComponent } from "../entity-create-button/entity-create-button.component";
import {
ColumnConfig,
FormFieldConfig,
toFormFieldConfig,
} from "../entity-form/FormConfig";
import { EntityFormService } from "../entity-form/entity-form.service";
import { EntityInlineEditActionsComponent } from "./entity-inline-edit-actions/entity-inline-edit-actions.component";
import { ListPaginatorComponent } from "./list-paginator/list-paginator.component";
import { TableRow } from "./table-row";
import { tableSort } from "./table-sort/table-sort";
import {
EntitiesTableSelectionStore,
shouldSkipRowInteraction,
} from "./entities-table-selection";
import { EntitiesTableSortStore } from "./entities-table-sort.store";
/**
* A reusable table component for displaying, sorting, filtering, and selecting entities.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-entities-table",
providers: [EntitiesTableSortStore, EntitiesTableSelectionStore],
imports: [
EntityFieldEditComponent,
EntityFieldLabelComponent,
EntityFieldViewComponent,
ListPaginatorComponent,
MatCheckboxModule,
MatProgressBarModule,
MatSlideToggleModule,
MatSortModule,
MatTableModule,
EntityInlineEditActionsComponent,
EntityCreateButtonComponent,
],
templateUrl: "./entities-table.component.html",
styleUrl: "./entities-table.component.scss",
})
export class EntitiesTableComponent<
T extends Entity,
> implements AfterContentInit {
private readonly formDialog = inject(FormDialogService);
private readonly router = inject(Router);
private readonly filterService = inject(FilterService);
private readonly entityFormService = inject(EntityFormService);
protected readonly sortStore = inject(
EntitiesTableSortStore,
) as EntitiesTableSortStore<T>;
protected readonly selectionStore = inject(
EntitiesTableSelectionStore,
) as EntitiesTableSelectionStore<T>;
// --- Inputs ---
records = input<T[]>();
customColumns = input<ColumnConfig[], ColumnConfig[] | undefined>([], {
transform: (value) => value ?? [],
});
columnsToDisplay = input<string[]>();
entityType = input<EntityConstructor<T>>();
sortBy = input<Sort>();
filter = input<DataFilter<T>, DataFilter<T> | undefined>(
{},
{
transform: (value) => value ?? {},
},
);
filterFreetext = input<string>();
showEntityColor = input<boolean>(false);
getBackgroundColor = input<(rec: T) => string>();
clickMode = input<"popup" | "navigate" | "popup-details" | "none">("popup");
newRecordFactory = input<() => T>();
editable = input<boolean>(true);
selectable = input<boolean>(false);
// --- Outputs & Models ---
filteredRecordsChange = output<T[]>();
entityClick = output<T>();
selectedRecords = model<T[]>([]);
showInactive = model<boolean>(false);
// --- Internal constants ---
readonly ACTIONCOLUMN_SELECT = "__select";
readonly ACTIONCOLUMN_EDIT = "__edit";
// --- Column state ---
readonly _customColumns = computed<FormFieldConfig[]>(() =>
this.customColumns().map((column) => {
const entityType = this.entityType();
return entityType
? this.entityFormService.extendFormFieldConfig(column, entityType)
: toFormFieldConfig(column);
}),
);
readonly _columnsToDisplay = computed<string[]>(() => {
let colsToDisplay = this.columnsToDisplay();
if (!colsToDisplay || colsToDisplay.length === 0) {
colsToDisplay = this._customColumns()
.filter((column) => !column.hideFromTable)
.map((column) => column.id);
}
const columns = colsToDisplay.filter((col) => !col.startsWith("__"));
if (this.selectable()) {
columns.unshift(this.ACTIONCOLUMN_SELECT);
}
if (this.editable()) {
columns.splice(this.selectable() ? 1 : 0, 0, this.ACTIONCOLUMN_EDIT);
}
return columns;
});
/** Columns with sorting rules applied (managed by the sort store). */
readonly _columns = this.sortStore.columns;
readonly idForSavingPagination = computed(() =>
this._customColumns()
.map((column) => column.id)
.join(""),
);
// --- Filtering (stateless derivation) ---
readonly effectiveFilter = computed<DataFilter<T>>(() => {
const nextFilter = { ...this.filter() };
if (this.showInactive()) {
delete nextFilter["isActive"];
} else {
nextFilter["isActive"] = true;
}
return nextFilter;
});
readonly filteredRecords = computed<T[]>(() => {
const records = this.records() ?? [];
const predicate = this.filterService.getFilterPredicate(
this.effectiveFilter(),
);
const domainFiltered = records.filter(predicate);
const freetext = this.filterFreetext() ?? "";
if (!freetext) {
return domainFiltered;
}
return domainFiltered.filter((record) =>
entityFilterPredicate(record, freetext),
);
});
// --- Background color ---
readonly effectiveBackgroundColor = computed<(rec: T) => string>(() => {
const custom = this.getBackgroundColor();
const useEntityColor = this.showEntityColor();
return custom ?? ((rec: T) => (useEntityColor ? rec.getColor() : ""));
});
// --- Loading state ---
readonly isLoading = signal(true);
// --- Material DataSource (for paginator interop) ---
readonly recordsDataSource = this.createDataSource();
@ViewChild(MatTable, { static: true }) table: MatTable<T>;
@ContentChildren(MatColumnDef) projectedColumns: QueryList<MatColumnDef>;
@ViewChild(MatSort, { static: false }) set sort(sort: MatSort) {
this.sortStore.attachSort(sort);
if (sort) {
this.recordsDataSource.sort = sort;
}
}
constructor() {
// Connect sort store
this.sortStore.connect({
columnsToDisplay: this._columnsToDisplay,
columns: computed(() => {
const mappedCustomColumns = this._customColumns();
const entityType = this.entityType();
const entityColumns = entityType?.schema
? [...entityType.schema.entries()].map(
([id, field]) => ({ ...field, id }) as FormFieldConfig,
)
: [];
return [
...entityColumns.filter(
(col) =>
!mappedCustomColumns.some((custom) => custom.id === col.id),
),
...mappedCustomColumns,
];
}),
externalSort: this.sortBy,
filteredRecords: this.filteredRecords,
});
// Connect selection store
this.selectionStore.connect({
selectedRecords: this.selectedRecords,
sortedRows: this.sortStore.sortedRows,
getCurrentPageRows: () => this.getCurrentPageRows(),
});
// Sync sorted rows to Material DataSource
effect(() => {
this.recordsDataSource.data = this.sortStore.sortedRows();
});
// Track loading state
effect(() => {
const records = this.records();
if (records !== undefined && records !== null) {
this.isLoading.set(false);
}
});
// Emit filtered records changes
effect(() => {
this.filteredRecordsChange.emit(
this.sortStore.sortedRows().map((row) => row.record),
);
});
}
ngAfterContentInit() {
this.projectedColumns.forEach((columnDef) =>
this.table.addColumnDef(columnDef),
);
}
onRowClick(row: TableRow<T>, event: MouseEvent) {
if (shouldSkipRowInteraction(event.target, row)) {
return;
}
if (this.selectable()) {
this.selectionStore.selectRow(
row,
!this.selectedRecords()?.includes(row.record),
);
return;
}
this.showEntity(row.record);
this.entityClick.emit(row.record);
}
onRowMouseDown(event: MouseEvent, row: TableRow<T>) {
if (!this.selectable()) {
this.onRowClick(row, event);
return;
}
if (this.selectionStore.handleSelectableRowMouseDown(event, row)) {
this.onRowClick(row, event);
}
}
showEntity(entity: T) {
switch (this.clickMode()) {
case "popup":
this.formDialog.openFormPopup(entity, this._customColumns());
break;
case "popup-details":
this.formDialog.openView(entity, "EntityDetails");
break;
case "navigate":
this.router.navigate([
getEntityRuntimeRoute(entity.getConstructor()),
entity.isNew ? "new" : entity.getId(true),
]);
break;
}
}
getCurrentPageRows(): TableRow<T>[] {
const rows = this.sortStore.sortedRows();
const paginator = this.recordsDataSource.paginator;
if (!paginator) {
return rows;
}
const startIndex = paginator.pageIndex * paginator.pageSize;
return rows.slice(startIndex, startIndex + paginator.pageSize);
}
private createDataSource() {
const dataSource = new MatTableDataSource<TableRow<T>>();
dataSource.sortData = (data, sort) =>
tableSort<T, keyof T>(data, {
active: (sort.active as keyof T) ?? "",
direction: sort.direction,
sortValueFns: this.sortStore.sortValueFns(),
});
dataSource.filterPredicate = (data, filter) =>
entityFilterPredicate(data.record, filter);
return dataSource;
}
}
@if (isLoading()) {
<div class="process-spinner">
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
</div>
}
<div [hidden]="isLoading()" class="table-container">
<table
mat-table
[dataSource]="recordsDataSource"
matSort
[matSortActive]="sortStore.effectiveSort()?.active"
[matSortDirection]="sortStore.effectiveSort()?.direction"
class="full-width table"
>
@for (col of _columns(); track col.id) {
<ng-container [matColumnDef]="col.id">
<th
mat-header-cell
mat-sort-header
*matHeaderCellDef
[disabled]="col.noSorting"
>
<app-entity-field-label
[field]="col"
[entityType]="entityType()"
></app-entity-field-label>
</th>
<td mat-cell *matCellDef="let row">
@if (row.formGroup?.enabled) {
<app-entity-field-edit
[field]="col"
[entity]="row.record"
[form]="row.formGroup"
[compactMode]="true"
></app-entity-field-edit>
} @else {
<app-entity-field-view
[field]="col"
[entity]="row.record"
></app-entity-field-view>
}
</td>
</ng-container>
}
<!--
BULK SELECT
-->
<ng-container [matColumnDef]="ACTIONCOLUMN_SELECT">
<th mat-header-cell *matHeaderCellDef style="width: 0">
<mat-checkbox
(change)="selectionStore.selectAllRows($event.checked)"
[checked]="selectionStore.allRowsSelected()"
[indeterminate]="selectionStore.selectionIndeterminate()"
></mat-checkbox>
</th>
<td mat-cell *matCellDef="let row">
<mat-checkbox
(change)="selectionStore.selectRow(row, $event.checked)"
[checked]="selectedRecords().includes(row.record)"
(click)="$event.stopPropagation()"
></mat-checkbox>
</td>
</ng-container>
<!--
INLINE EDIT ACTIONS
-->
<ng-container [matColumnDef]="ACTIONCOLUMN_EDIT">
<th mat-header-cell *matHeaderCellDef class="remove-padding-left">
<app-entity-create-button
[entityType]="entityType()"
[newRecordFactory]="newRecordFactory()"
(entityCreate)="showEntity($event); entityClick.emit($event)"
[iconOnly]="true"
></app-entity-create-button>
</th>
<td mat-cell *matCellDef="let row">
<app-entity-inline-edit-actions [row]="row">
</app-entity-inline-edit-actions>
</td>
</ng-container>
<!-- custom columns via content projection -->
<ng-content></ng-content>
<tr mat-header-row *matHeaderRowDef="_columnsToDisplay()"></tr>
<tr
mat-row
*matRowDef="let row; columns: _columnsToDisplay()"
[class.inactive-row]="!row.record.isActive"
[style.background-color]="effectiveBackgroundColor()(row.record)"
class="table-row"
(mousedown)="onRowMouseDown($event, row)"
style="cursor: pointer"
></tr>
</table>
<!--
PAGINATION
-->
<app-list-paginator
class="table-footer"
[dataSource]="recordsDataSource"
[idForSavingPagination]="idForSavingPagination()"
></app-list-paginator>
<!--
SHOW ARCHIVED TOGGLE
-->
<div class="table-footer filter-inactive-toggle">
<mat-slide-toggle
[checked]="showInactive()"
(change)="showInactive.set($event.checked)"
i18n="slider|also show entries that are archived"
>
Include archived records
</mat-slide-toggle>
</div>
</div>