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 |
| standalone | true |
| imports |
MatCheckboxModule
MatProgressBarModule
MatSlideToggleModule
MatSortModule
MatTableModule
|
| styleUrls | ./entities-table.component.scss |
| templateUrl | ./entities-table.component.html |
| styleUrl | ./entities-table.component.scss |
EntityFieldEditComponent
EntityFieldLabelComponent
EntityFieldViewComponent
ListPaginatorComponent
MatCheckboxModule
MatProgressBarModule
MatSlideToggleModule
MatSortModule
MatTableModule
EntityInlineEditActionsComponent
EntityCreateButtonComponent
EntitiesTableSortStore
EntitiesTableSelectionStore
AfterContentInit
OnInit
Properties |
|
Methods |
Inputs |
Outputs |
Accessors |
constructor()
|
| clickMode | |
Type : "popup" | "navigate" | "popup-details" | "none"
|
|
Default value : "popup"
|
|
| columnsToDisplay | |
Type : string[]
|
|
| customColumns | |
Type : ColumnConfig[]
|
|
Default value : []
|
|
| editable | |
Type : boolean
|
|
Default value : true
|
|
| entityType | |
Type : EntityConstructor<T>
|
|
| filter | |
Type : DataFilter<T>
|
|
Default value : {}
|
|
| getBackgroundColor | |
Type : (rec: T) => string
|
|
| newRecordFactory | |
Type : () => T
|
|
| recordsDataSource | |
Type : EntitiesTableDataSource<T>
|
|
| Required : true | |
| 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
|
|
| selectedRecords | |
Type : T[]
|
|
| showInactive | |
Type : boolean
|
|
| 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(""),
)
|
| projectedColumns |
Type : QueryList<MatColumnDef>
|
Decorators :
@ContentChildren(MatColumnDef)
|
| 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,
OnInit,
output,
QueryList,
untracked,
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,
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 { DataFilter } from "../../filter/filters/filters";
import { restrictToNotArchived } from "../../filter/not-archived-filter";
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 {
EntitiesTableSelectionStore,
shouldSkipRowInteraction,
} from "./entities-table-selection";
import { EntitiesTableSortStore } from "./entities-table-sort.store";
import { EntitiesTableDataSource } from "#src/app/core/common-components/entities-table/data-source/entities-table-data-source";
/**
* 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, OnInit
{
private readonly formDialog = inject(FormDialogService);
private readonly router = inject(Router);
private readonly entityFormService = inject(EntityFormService);
protected readonly sortStore = inject(
EntitiesTableSortStore,
) as EntitiesTableSortStore<T>;
protected readonly selectionStore = inject(
EntitiesTableSelectionStore,
) as EntitiesTableSelectionStore<T>;
// --- Inputs ---
recordsDataSource = input.required<EntitiesTableDataSource<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 ?? {},
},
);
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 ---
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()) {
// the toggle overrides an archived condition that may come from the configured filter
delete nextFilter["inactive"];
return nextFilter;
}
return restrictToNotArchived(nextFilter);
});
// --- Background color ---
readonly effectiveBackgroundColor = computed<(rec: T) => string>(() => {
const custom = this.getBackgroundColor();
const useEntityColor = this.showEntityColor();
return custom ?? ((rec: T) => (useEntityColor ? rec.getColor() : ""));
});
@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() {
effect(() => {
this.recordsDataSource().dataFilter.set(this.effectiveFilter());
});
effect(() => {
this.recordsDataSource().sortValueFns.set(this.sortStore.sortValueFns());
});
effect(() => {
this.recordsDataSource().dataFilter();
this.recordsDataSource().displayedData();
if (untracked(this.selectedRecords)?.length > 0) {
// reset selection if filter or input data changes
this.selectedRecords.set([]);
}
});
}
ngOnInit() {
// 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,
});
// Connect selection store
this.selectionStore.connect({
selectedRecords: this.selectedRecords,
selectableRows: this.recordsDataSource().displayedData,
currentPageRows: this.recordsDataSource().renderedRows,
});
}
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 (event.shiftKey) {
// stop the browser from also extending its text selection across the rows,
// which blocks scrolling until the selection is cleared again
event.preventDefault();
}
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;
}
}
}
<div class="table-container">
@if (recordsDataSource().isLoading()) {
<!-- shown on top of the (possibly stale) table while data is (re)loaded,
e.g. after a filter/sort/page change with the server-side data source -->
<mat-progress-bar
mode="indeterminate"
class="loading-bar"
></mat-progress-bar>
}
<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.inactive"
[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>
./entities-table.component.scss
@use "variables/sizes";
@use "variables/colors";
@use "@angular/material/core/style/elevation" as mat-elevation;
.table-container {
position: relative; /* anchor for further absolute positioning of child elements */
}
.column-menu {
position: absolute;
top: 0;
right: 0;
}
.table-row:hover {
background: #f5f5f5;
}
.loading-bar {
/* overlay the loading indicator at the top of the table so the current
content stays visible while new data is being loaded */
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
}
.table {
@include mat-elevation.elevation(2);
}
.table-footer {
@include mat-elevation.elevation(1);
border-top: colors.$grey-darker solid 2px;
}
.filter-inactive-toggle {
padding: sizes.$regular;
background-color: white;
border-top: none;
}
.inactive-row {
color: colors.$inactive;
}
/* adjust action coloumn width to use least possible width */
.mat-column-__edit {
flex: 0 0 auto !important;
width: 78px;
padding-right: 0;
}