src/app/core/common-components/entities-table/entities-table.component.ts
A simple display component (no logic and transformations) to display a table of entities.
AfterContentInit
selector | app-entities-table |
imports |
CommonModule
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(entityFormService: EntityFormService, formDialog: FormDialogService, router: Router, filterService: FilterService, schemaService: EntitySchemaService)
|
||||||||||||||||||
Parameters :
|
columnsToDisplay | |
Type : string[]
|
|
Manually define the columns to be shown. |
customColumns | |
Type : ColumnConfig[]
|
|
Additional or overwritten field configurations for columns |
editable | |
Type : boolean
|
|
INLINE EDIT User can switch a row into edit mode to change and save field values directly from within the table |
entityType | |
Type : EntityConstructor<T>
|
|
filter | |
Type : DataFilter<T>
|
|
Adds a filter for the displayed data. Only data, that passes the filter will be shown in the table. |
filterFreetext | |
Type : string
|
|
getBackgroundColor | |
Type : function
|
|
Default value : (rec: T) => rec.getColor()
|
|
function returns the background color for each row |
newRecordFactory | |
Type : function
|
|
factory method to create a new instance of the displayed Entity type used when the user adds a new entity to the list. |
records | |
Type : T[]
|
|
selectable | |
Type : boolean
|
|
BULK SELECT User can use checkboxes to select multiple rows, so that parent components can execute bulk actions on them. |
selectedRecords | |
Type : T[]
|
|
Default value : []
|
|
showInactive | |
Type : boolean
|
|
FILTER ARCHIVED RECORDS User can hide / show inactive records through a toggle |
sortBy | |
Type : Sort
|
|
how to sort data by default during initialization |
entityClick | |
Type : EventEmitter
|
|
Emits the entity being clicked in the table - or the newly created entity from the "create" button. |
filteredRecordsChange | |
Type : EventEmitter
|
|
output the currently displayed records, whenever filters for the user change |
showInactiveChange | |
Type : EventEmitter
|
|
addActiveInactiveFilter | ||||||
addActiveInactiveFilter(filter: DataFilter<T>)
|
||||||
Parameters :
Returns :
void
|
isAllSelected |
isAllSelected()
|
Returns :
boolean
|
isIndeterminate |
isIndeterminate()
|
Returns :
boolean
|
onRowClick | ||||||||||||
onRowClick(row: TableRow<T>, event: MouseEvent)
|
||||||||||||
Show one record's details in a modal dialog (if configured).
Parameters :
Returns :
void
|
onRowMouseDown | |||||||||
onRowMouseDown(event: MouseEvent, row: TableRow<T>)
|
|||||||||
Parameters :
Returns :
void
|
onRowSelect | |||||||||
onRowSelect(event: MatCheckboxChange, row: TableRow<T>)
|
|||||||||
Parameters :
Returns :
void
|
selectAllRows | ||||||
selectAllRows(event: MatCheckboxChange)
|
||||||
Parameters :
Returns :
void
|
selectRow | |||||||||
selectRow(row: TableRow<T>, checked: boolean)
|
|||||||||
Parameters :
Returns :
void
|
showEntity | ||||||
showEntity(entity: T)
|
||||||
Parameters :
Returns :
void
|
_columns |
Type : FormFieldConfig[]
|
Default value : []
|
_columnsToDisplay |
Type : string[]
|
Default value : []
|
_customColumns |
Type : FormFieldConfig[]
|
_editable |
Type : boolean
|
Default value : true
|
_entityType |
Type : EntityConstructor<T>
|
_filter |
Type : DataFilter<T>
|
Default value : {}
|
_records |
Type : T[]
|
Default value : []
|
_selectable |
Type : boolean
|
Default value : false
|
_showInactive |
Type : boolean
|
Default value : false
|
_sortBy |
Type : Sort
|
Readonly ACTIONCOLUMN_EDIT |
Type : string
|
Default value : "__edit"
|
Readonly ACTIONCOLUMN_SELECT |
Type : string
|
Default value : "__select"
|
idForSavingPagination |
Type : string
|
isLoading |
Type : boolean
|
Default value : true
|
projectedColumns |
Type : QueryList<MatColumnDef>
|
Decorators :
@ContentChildren(MatColumnDef)
|
recordsDataSource |
Type : MatTableDataSource<TableRow<T>>
|
data displayed in the template's table |
table |
Type : MatTable<T>
|
Decorators :
@ViewChild(MatTable, {static: true})
|
records | ||||||
setrecords(value: T[])
|
||||||
Parameters :
Returns :
void
|
customColumns | ||||||
setcustomColumns(value: ColumnConfig[])
|
||||||
Additional or overwritten field configurations for columns
Parameters :
Returns :
void
|
columnsToDisplay | ||||||
setcolumnsToDisplay(value: string[])
|
||||||
Manually define the columns to be shown.
Parameters :
Returns :
void
|
entityType | ||||||
setentityType(value: EntityConstructor<T>)
|
||||||
Parameters :
Returns :
void
|
sortBy | ||||||
setsortBy(value: Sort)
|
||||||
how to sort data by default during initialization
Parameters :
Returns :
void
|
sort | ||||||
setsort(sort: MatSort)
|
||||||
Parameters :
Returns :
void
|
filter | ||||||
setfilter(value: DataFilter<T>)
|
||||||
Adds a filter for the displayed data. Only data, that passes the filter will be shown in the table.
Parameters :
Returns :
void
|
filterFreetext | ||||||
setfilterFreetext(value: string)
|
||||||
Parameters :
Returns :
void
|
selectable | ||||||
setselectable(v: boolean)
|
||||||
BULK SELECT User can use checkboxes to select multiple rows, so that parent components can execute bulk actions on them.
Parameters :
Returns :
void
|
editable | ||||||
seteditable(v: boolean)
|
||||||
INLINE EDIT User can switch a row into edit mode to change and save field values directly from within the table
Parameters :
Returns :
void
|
showInactive | ||||||
setshowInactive(value: boolean)
|
||||||
FILTER ARCHIVED RECORDS User can hide / show inactive records through a toggle
Parameters :
Returns :
void
|
import {
AfterContentInit,
Component,
ContentChildren,
EventEmitter,
Input,
Output,
QueryList,
ViewChild,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { EntityFieldEditComponent } from "../entity-field-edit/entity-field-edit.component";
import { EntityFieldLabelComponent } from "../entity-field-label/entity-field-label.component";
import { EntityFieldViewComponent } from "../entity-field-view/entity-field-view.component";
import { ListPaginatorComponent } from "./list-paginator/list-paginator.component";
import {
MatCheckboxChange,
MatCheckboxModule,
} from "@angular/material/checkbox";
import { MatProgressBarModule } from "@angular/material/progress-bar";
import { MatSlideToggleModule } from "@angular/material/slide-toggle";
import {
MatSort,
MatSortModule,
Sort,
SortDirection,
} from "@angular/material/sort";
import {
MatColumnDef,
MatTable,
MatTableDataSource,
MatTableModule,
} from "@angular/material/table";
import { Entity, EntityConstructor } from "../../entity/model/entity";
import {
ColumnConfig,
FormFieldConfig,
toFormFieldConfig,
} from "../entity-form/FormConfig";
import { EntityFormService } from "../entity-form/entity-form.service";
import { tableSort } from "./table-sort/table-sort";
import { UntilDestroy } from "@ngneat/until-destroy";
import { entityFilterPredicate } from "../../filter/filter-generator/filter-predicate";
import { FormDialogService } from "../../form-dialog/form-dialog.service";
import { Router } from "@angular/router";
import { FilterService } from "../../filter/filter.service";
import { DataFilter } from "../../filter/filters/filters";
import { EntityInlineEditActionsComponent } from "./entity-inline-edit-actions/entity-inline-edit-actions.component";
import { EntityCreateButtonComponent } from "../entity-create-button/entity-create-button.component";
import { DateDatatype } from "../../basic-datatypes/date/date.datatype";
import { EntitySchemaService } from "../../entity/schema/entity-schema.service";
import { EntityDatatype } from "../../basic-datatypes/entity/entity.datatype";
import { TableRow } from "./table-row";
/**
* A simple display component (no logic and transformations) to display a table of entities.
*/
@UntilDestroy()
@Component({
selector: "app-entities-table",
imports: [
CommonModule,
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
{
@Input() set records(value: T[]) {
if (!value) {
return;
}
this._records = value;
this.updateFilteredData();
this.isLoading = false;
}
private lastSelectedIndex: number = null;
private lastSelection: boolean = null;
_records: T[] = [];
/** data displayed in the template's table */
recordsDataSource: MatTableDataSource<TableRow<T>>;
isLoading: boolean = true;
@ViewChild(MatTable, { static: true }) table: MatTable<T>;
@ContentChildren(MatColumnDef) projectedColumns: QueryList<MatColumnDef>;
ngAfterContentInit() {
// dynamically add columns from content-projection (https://stackoverflow.com/a/58017564/1473411)
this.projectedColumns.forEach((columnDef) =>
this.table.addColumnDef(columnDef),
);
}
/**
* Additional or overwritten field configurations for columns
* @param value
*/
@Input() set customColumns(value: ColumnConfig[]) {
this._customColumns = (value ?? []).map((c) =>
this._entityType
? this.entityFormService.extendFormFieldConfig(c, this._entityType)
: toFormFieldConfig(c),
);
const entityColumns = this._entityType?.schema
? [...this._entityType.schema.entries()].map(
([id, field]) => ({ ...field, id }) as FormFieldConfig,
)
: [];
this._columns = [
...entityColumns.filter(
// if there is a customColumn for a field from entity config, don't add the base schema field
(c) => !this._customColumns.some((customCol) => customCol.id === c.id),
),
...this._customColumns,
];
this._columns.forEach((c) => this.disableSortingHeaderForAdvancedFields(c));
if (!this.columnsToDisplay) {
this.columnsToDisplay = this._customColumns
.filter((c) => !c.hideFromTable)
.map((c) => c.id);
}
this.idForSavingPagination = this._customColumns
.map((col) => col.id)
.join("");
}
_customColumns: FormFieldConfig[];
_columns: FormFieldConfig[] = [];
/**
* Manually define the columns to be shown.
*
* @param value
*/
@Input() set columnsToDisplay(value: string[]) {
if (!value || value.length === 0) {
value = (this._customColumns ?? this._columns).map((c) => c.id);
}
value = value.filter((c) => !c.startsWith("__")); // remove internal action columns
const cols = [];
if (this._selectable) {
cols.push(this.ACTIONCOLUMN_SELECT);
}
if (this._editable) {
cols.push(this.ACTIONCOLUMN_EDIT);
}
cols.push(...value);
this._columnsToDisplay = cols;
if (this.sortIsInferred) {
this.sortBy = this.inferDefaultSort();
this.sortIsInferred = true;
}
}
_columnsToDisplay: string[] = [];
@Input() set entityType(value: EntityConstructor<T>) {
this._entityType = value;
this.customColumns = this._customColumns;
}
_entityType: EntityConstructor<T>;
/** how to sort data by default during initialization */
@Input() set sortBy(value: Sort) {
if (!value) {
return;
}
this._sortBy = value;
this.sortIsInferred = false;
}
_sortBy: Sort;
@ViewChild(MatSort, { static: false }) set sort(sort: MatSort) {
this.recordsDataSource.sort = sort;
}
private sortIsInferred: boolean = true;
/**
* Adds a filter for the displayed data.
* Only data, that passes the filter will be shown in the table.
*/
@Input() set filter(value: DataFilter<T>) {
this._filter = value ?? {};
this.updateFilteredData();
}
_filter: DataFilter<T> = {};
/** output the currently displayed records, whenever filters for the user change */
@Output() filteredRecordsChange = new EventEmitter<T[]>(true);
private updateFilteredData() {
this.addActiveInactiveFilter(this._filter);
const filterPredicate = this.filterService.getFilterPredicate(this._filter);
const filteredData = this._records.filter(filterPredicate);
this.recordsDataSource.data = filteredData.map((record) => ({ record }));
this.filteredRecordsChange.emit(filteredData);
}
@Input() set filterFreetext(value: string) {
this.recordsDataSource.filter = value;
}
/** function returns the background color for each row*/
@Input() getBackgroundColor?: (rec: T) => string = (rec: T) => rec.getColor();
idForSavingPagination: string;
/**
* The action the system triggers when a user clicks on an entry (row):
* - popup: open dialog with simplified form with the given fields only
* - navigate: route the app to the details view of the entity
* - popup-details: open dialog with the full EntityDetails view
* - none: do not trigger any automatic action
*/
@Input() clickMode: "popup" | "navigate" | "popup-details" | "none" = "popup";
/**
* Emits the entity being clicked in the table - or the newly created entity from the "create" button.
*/
@Output() entityClick = new EventEmitter<T>();
/**
* BULK SELECT
* User can use checkboxes to select multiple rows, so that parent components can execute bulk actions on them.
*/
@Input() set selectable(v: boolean) {
this._selectable = v;
this.columnsToDisplay = this._columnsToDisplay;
}
_selectable: boolean = false;
readonly ACTIONCOLUMN_SELECT = "__select";
/**
* outputs an event containing an array of currently selected records (checkmarked by the user)
* Checkboxes to select rows are only displayed if you set "selectable" also.
*/
@Output() selectedRecordsChange: EventEmitter<T[]> = new EventEmitter<T[]>();
@Input() selectedRecords: T[] = [];
selectRow(row: TableRow<T>, checked: boolean) {
if (checked) {
this.selectedRecords.push(row.record);
} else {
const index = this.selectedRecords.indexOf(row.record);
if (index > -1) {
this.selectedRecords.splice(index, 1);
}
}
this.selectedRecordsChange.emit(this.selectedRecords);
}
/**
* INLINE EDIT
* User can switch a row into edit mode to change and save field values directly from within the table
*/
@Input() set editable(v: boolean) {
this._editable = v;
this.columnsToDisplay = this._columnsToDisplay;
}
_editable: boolean = true;
readonly ACTIONCOLUMN_EDIT = "__edit";
/**
* factory method to create a new instance of the displayed Entity type
* used when the user adds a new entity to the list.
*/
@Input() newRecordFactory: () => T;
/**
* Show one record's details in a modal dialog (if configured).
* @param row The entity whose details should be displayed.
*/
onRowClick(row: TableRow<T>, event: MouseEvent) {
const targetElement = event.target as HTMLElement;
// Check if the clicked element has the 'clickable' class
if (targetElement && targetElement.closest(".clickable")) {
return;
}
if (row.formGroup && !row.formGroup.disabled) {
return;
}
if (this._selectable) {
this.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;
}
// Find the index of the row in the sorted and filtered data
const sortedData = this.recordsDataSource.sortData(
this.recordsDataSource.data,
this.recordsDataSource.sort,
);
const currentIndex = sortedData.indexOf(row);
const isCheckboxClick =
event.target instanceof HTMLInputElement &&
event.target.type === "checkbox";
if (event.shiftKey && this.lastSelectedIndex !== null) {
const start = Math.min(this.lastSelectedIndex, currentIndex);
const end = Math.max(this.lastSelectedIndex, currentIndex);
const shouldCheck =
this.lastSelection !== null
? !this.lastSelection
: !this.selectedRecords.includes(row.record);
for (let i = start; i <= end; i++) {
const rowToSelect = sortedData[i];
const isSelected = this.selectedRecords.includes(rowToSelect.record);
if (shouldCheck && !isSelected) {
this.selectedRecords.push(rowToSelect.record);
} else if (!shouldCheck && isSelected) {
this.selectedRecords = this.selectedRecords.filter(
(record) => record !== rowToSelect.record,
);
}
}
this.selectedRecordsChange.emit(this.selectedRecords);
} else {
const isSelected = this.selectedRecords.includes(row.record);
this.selectRow(row, !isSelected);
this.lastSelectedIndex = currentIndex;
this.lastSelection = isSelected;
}
if (isCheckboxClick) {
this.onRowClick(row, event);
}
}
onRowSelect(event: MatCheckboxChange, row: TableRow<T>) {
this.selectRow(row, event.checked);
}
selectAllRows(event: MatCheckboxChange) {
if (event.checked) {
this.selectedRecords = this.recordsDataSource.data.map(
(row) => row.record,
);
} else {
this.selectedRecords = [];
}
this.selectedRecordsChange.emit(this.selectedRecords);
}
isAllSelected() {
return this.selectedRecords.length === this.recordsDataSource.data.length;
}
isIndeterminate() {
return this.selectedRecords.length > 0 && !this.isAllSelected();
}
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([
entity.getConstructor().route,
entity.isNew ? "new" : entity.getId(true),
]);
break;
}
}
constructor(
private entityFormService: EntityFormService,
private formDialog: FormDialogService,
private router: Router,
private filterService: FilterService,
private schemaService: EntitySchemaService,
) {
this.recordsDataSource = this.createDataSource();
}
private createDataSource() {
const dataSource = new MatTableDataSource<TableRow<T>>();
dataSource.sortData = (data, sort) =>
tableSort(data, {
active: sort.active as keyof Entity | "",
direction: sort.direction,
});
dataSource.filterPredicate = (data, filter) =>
entityFilterPredicate(data.record, filter);
return dataSource;
}
private inferDefaultSort(): Sort {
// initial sorting by first column, ensure that not the 'action' column is used
const sortBy = this._columnsToDisplay.filter((c) => !c.startsWith("__"))[0];
const sortByColumn = this._columns.find((c) => c.id === sortBy);
let sortDirection: SortDirection = "asc";
if (
sortByColumn?.viewComponent === "DisplayDate" ||
sortByColumn?.viewComponent === "DisplayMonth" ||
this.schemaService.getDatatypeOrDefault(sortByColumn?.dataType) instanceof
DateDatatype
) {
// flip default sort order for dates (latest first)
sortDirection = "desc";
}
return sortBy ? { active: sortBy, direction: sortDirection } : undefined;
}
/**
* Advanced fields like entity references cannot be sorted sensibly yet - disable sort for them.
* @param c
* @private
*/
private disableSortingHeaderForAdvancedFields(c: FormFieldConfig) {
if (c.viewComponent === "DisplayAge") {
// we have implemented support for age specifically
return;
}
// if no dataType is defined, these are dynamic, display-only components
if (c.isArray || c.dataType === EntityDatatype.dataType || !c.dataType) {
c.noSorting = true;
}
}
/**
* FILTER ARCHIVED RECORDS
* User can hide / show inactive records through a toggle
*/
@Input() set showInactive(value: boolean) {
if (value === this._showInactive) {
return;
}
this._showInactive = value;
this.updateFilteredData();
this.showInactiveChange.emit(value);
}
_showInactive: boolean = false;
@Output() showInactiveChange = new EventEmitter<boolean>();
addActiveInactiveFilter(filter: DataFilter<T>) {
if (this._showInactive) {
delete filter["isActive"];
} else {
filter["isActive"] = true;
}
}
}
<div *ngIf="isLoading" 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]="_sortBy?.active"
[matSortDirection]="_sortBy?.direction"
class="full-width table"
>
<ng-container *ngFor="let col of _columns" [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">
<app-entity-field-edit
*ngIf="row.formGroup?.enabled; else viewField"
[field]="col"
[entity]="row.record"
[form]="row.formGroup"
[compactMode]="true"
></app-entity-field-edit>
<ng-template #viewField>
<app-entity-field-view
[field]="col"
[entity]="row.record"
></app-entity-field-view>
</ng-template>
</td>
</ng-container>
<!--
BULK SELECT
-->
<ng-container [matColumnDef]="ACTIONCOLUMN_SELECT">
<th mat-header-cell *matHeaderCellDef style="width: 0">
<mat-checkbox
(change)="selectAllRows($event)"
[checked]="isAllSelected()"
[indeterminate]="isIndeterminate()"
></mat-checkbox>
</th>
<td mat-cell *matCellDef="let row">
<mat-checkbox
(change)="onRowSelect($event, row)"
[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]="getBackgroundColor?.(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 = $event.checked"
i18n="slider|also show entries that are archived"
>
Include archived records
</mat-slide-toggle>
</div>
</div>