Files
syscom-master-admin-ui/src/app/shared/components/data-table/data-table.ts
T

610 lines
16 KiB
TypeScript

import { NgTemplateOutlet } from '@angular/common';
import { Component, DestroyRef, ElementRef, HostListener, computed, contentChildren, effect, inject, input, output, signal, viewChild, contentChild } from '@angular/core';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DataTableCellDirective } from '../../directives/data-table-cell.directive';
import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay';
import { DataTableAction, DataTableActionEvent, DataTableCellContext, DataTableColumn, DataTablePageEvent, DataTableRecord, DataTableSortEvent } from './data-table.types';
import { Button } from '../button/button';
import { DataTableToolbarDirective } from '../../directives/data-table-toolbar/data-table-toolbar.directive';
import { DataTableFilterActionsDirective } from '../../directives/data-table-filter-actions/data-table-filter-actions.directive';
@Component({
selector: 'app-data-table',
imports: [NgTemplateOutlet,
MatPaginatorModule,
CdkOverlayOrigin,
CdkConnectedOverlay,
Button],
templateUrl: './data-table.html',
styleUrl: './data-table.scss',
standalone: true
})
export class DataTable<T extends DataTableRecord = DataTableRecord> {
private readonly destroyRef = inject(DestroyRef);
private readonly searchTerms$ = new Subject<string>();
readonly cellTemplates = contentChildren(DataTableCellDirective);
readonly toolbarTemplate = contentChild(DataTableToolbarDirective);
readonly filterActionsTemplate = contentChild(DataTableFilterActionsDirective);
readonly filterRowContent = viewChild<ElementRef<HTMLElement>>('filterRowContent');
readonly filterRowOverflowing = signal(false);
private filterRowResizeObserver?: ResizeObserver;
private readonly filterRowContentEffect = effect(onCleanup => {
const element = this.filterRowContent()?.nativeElement;
this.filterRowResizeObserver?.disconnect();
if (!element) {
this.filterRowOverflowing.set(false);
return;
}
const checkOverflow = () => {
this.filterRowOverflowing.set(element.scrollWidth > element.clientWidth + 1);
};
checkOverflow();
this.filterRowResizeObserver = new ResizeObserver(checkOverflow);
this.filterRowResizeObserver.observe(element);
onCleanup(() => this.filterRowResizeObserver?.disconnect());
});
private readonly defaultRowClasses = [
'table-primary',
'table-secondary',
'table-success',
'table-danger',
'table-warning',
'table-info',
'table-light'
];
readonly actionMenuPositions: ConnectedPosition[] = [
// Open below and align right
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
offsetY: 4
},
// Open above and align right
{
originX: 'end',
originY: 'top',
overlayX: 'end',
overlayY: 'bottom',
offsetY: -4
},
// Mobile fallback: open below towards the right
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: 4
},
// Mobile fallback: open above towards the right
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -4
}
];
openActionMenuUpward = signal(false);
columns = input<DataTableColumn<T>[]>([]);
rows = input<T[]>([]);
actions = input<DataTableAction<T>[]>([]);
loading = input(false);
/* --------- pagination inputs ---- */
totalRecords = input(0);
pageIndex = input(1);
pageSize = input(15);
pageSizeOptions = input<number[]>([10, 15, 20, 50]);
showPaginator = input(true);
/*---------------------------*/
tableTitle = input<string>('');
toolTip = input<string>('');
buttonTitle = input<string>('');
/* --------- Search inputs ---- */
showSearch = input<boolean>(false);
showformSelect = input<boolean>(false);
showAddButton = input<boolean>(false);
searchPlaceholder = input('Search...');
searchDebounceTime = input(300);
emptyMessage = input('No records found');
emptyDescription = input('There is currently no data to display.');
showFilterButton = input<boolean>(false);
filterActive = input<boolean>(false);
/*---------------------------*/
/* --------- Permission inputs ---- */
allowedPermissions = input<string[]>([]);
/*------------outputs ---------------*/
searchChanged = output<string>();
pageChanged = output<DataTablePageEvent>();
sortChanged = output<DataTableSortEvent>();
actionClicked = output<DataTableActionEvent<T>>();
rowClicked = output<T>();
sortColumn = signal('');
sortDirection = signal<'asc' | 'desc'>('asc');
openActionRowId = signal<string | number | null>(null);
tableClass = input<string>('table table-bordered whitespace-nowrap min-w-full');
tableHeadClass = input<string>('');
tableBodyClass = input<string>('');
trHeadClass = input<string>('border-b border-defaultborder bg-primary/10 dark:bg-primary/15 dark:border-defaultborder/10');
trBodyClass = input<string>('border-b border-defaultborder hover:bg-light cursor-pointer');
rowClass = input<string | ((row: T, index: number) => string)>('');
rowColorMode = input<'none' | 'status' | 'cycle'>('status');
defaultThClass = input<string>('text-defaulttextcolor dark:text-white font-semibold text-start');
defaultTdClass = input<string>('');
actionHeaderClass = input<string>('!text-center');
actionCellClass = input<string>('!text-center');
addClicked = output<void>();
filterClicked = output<void>();
totalVisibleColumns = computed(() => this.columns().length + (this.actions().length > 0 ? 1 : 0));
allowedPermissionSet = computed(() => new Set(this.allowedPermissions()));
cellTemplateMap = computed(() => {
const templateMap = new Map<string, DataTableCellDirective>();
for (const template of this.cellTemplates() as DataTableCellDirective[]) {
templateMap.set(template.appDataTableCell(), template);
}
return templateMap;
});
totalPages = computed(() => {
return Math.ceil(this.totalRecords() / this.pageSize()) || 1;
});
startRecord = computed(() => {
if (!this.totalRecords()) return 0;
return (this.pageIndex() - 1) * this.pageSize() + 1;
});
endRecord = computed(() => {
return Math.min(this.pageIndex() * this.pageSize(), this.totalRecords());
});
visiblePages = computed(() => {
const currentPage = this.pageIndex();
const total = this.totalPages();
const maxVisiblePages = 5;
let startPage = Math.max(
1,
currentPage - Math.floor(maxVisiblePages / 2)
);
let endPage = Math.min(
total,
startPage + maxVisiblePages - 1
);
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(
1,
endPage - maxVisiblePages + 1
);
}
return Array.from(
{ length: endPage - startPage + 1 },
(_, index) => startPage + index
);
});
goToPage(page: number): void {
if (
page < 1 ||
page > this.totalPages() ||
page === this.pageIndex()
) {
return;
}
this.onPageChange({
pageIndex: page - 1,
pageSize: this.pageSize(),
length: this.totalRecords()
});
}
onPageSizeChange(event: Event): void {
const pageSize = Number(
(event.target as HTMLSelectElement).value
);
if (!pageSize || pageSize === this.pageSize()) {
return;
}
this.onPageChange({
pageIndex: 0,
pageSize,
length: this.totalRecords()
});
}
constructor() {
effect((onCleanup) => {
const debounce = this.searchDebounceTime();
const subscription = this.searchTerms$
.pipe(debounceTime(debounce), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
this.searchChanged.emit(value);
});
onCleanup(() => subscription.unsubscribe());
});
}
getCellValue(row: T, key: string): unknown {
return row[key] ?? null;
}
onSearch(value: string): void {
this.searchTerms$.next(value.trim());
}
onSort(column: DataTableColumn<T>): void {
if (!column.sortable) return;
const key = String(column.key);
if (this.sortColumn() === key) {
this.sortDirection.update(value => value === 'asc' ? 'desc' : 'asc');
} else {
this.sortColumn.set(key);
this.sortDirection.set('asc');
}
this.sortChanged.emit({
column: this.sortColumn(),
direction: this.sortDirection()
});
this.closeActionMenu();
}
onPageChange(event: PageEvent): void {
this.pageChanged.emit({
pageIndex: event.pageIndex + 1,
pageSize: event.pageSize
});
this.closeActionMenu();
}
onActionClick(event: MouseEvent, action: DataTableAction<T>, row: T): void {
event.stopPropagation();
if (this.isActionDisabled(action, row)) {
return;
}
this.actionClicked.emit({
action,
row
});
}
onAddClick(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.closeActionMenu();
this.addClicked.emit();
}
toggleActionMenu(event: MouseEvent, row: T): void {
event.stopPropagation();
const rowId = this.getRowId(row);
if (rowId === null) {
return;
}
this.openActionRowId.update(currentRowId =>
currentRowId === rowId ? null : rowId
);
}
closeActionMenu(): void {
this.openActionRowId.set(null);
}
onDropdownActionClick(
event: MouseEvent,
action: DataTableAction<T>,
row: T
): void {
event.stopPropagation();
this.onActionClick(event, action, row);
this.closeActionMenu();
}
isActionMenuOpen(row: T): boolean {
const rowId = row['id'];
return (
(typeof rowId === 'string' ||
typeof rowId === 'number') &&
this.openActionRowId() === rowId
);
}
getActionMenuId(row: T): string {
const rowId = this.getRowId(row);
return rowId === null ? 'row-actions-menu' : `row-actions-menu-${String(rowId)}`;
}
onActionMenuOutsideClick(event: MouseEvent): void {
const target = event.target;
if (!(target instanceof HTMLElement)) {
this.closeActionMenu();
return;
}
if (target.closest('[data-action-menu-trigger]')) {
return;
}
this.closeActionMenu();
}
isActionVisible(action: DataTableAction<T>, row: T): boolean {
if (action.permission && !this.allowedPermissionSet().has(action.permission)) {
return false;
}
return action.visible ? action.visible(row) : true;
}
isActionDisabled(action: DataTableAction<T>, row: T): boolean {
return action.disabled ? action.disabled(row) : false;
}
getActionLabel(action: DataTableAction<T>): string {
return action.tooltip || action.label;
}
getColumnKey(column: DataTableColumn<T>): string {
return String(column.key);
}
getSortIconClass(column: DataTableColumn<T>): string {
if (!column.sortable) {
return '';
}
const key = this.getColumnKey(column);
if (this.sortColumn() !== key) {
return 'ri-arrow-up-down-line';
}
return this.sortDirection() === 'asc' ? 'ri-sort-asc' : 'ri-sort-desc';
}
getHeaderClass(column: DataTableColumn<T>): string {
return this.composeClass(this.defaultThClass(), this.getAlignClass(column.headerAlign), column.headerClass);
}
getCellClass(column: DataTableColumn<T>): string {
return this.composeClass(this.defaultTdClass(), this.getAlignClass(column.align), column.cellClass);
}
getBadgeClass(column: DataTableColumn<T>, row: T): string {
const value = this.getCellValue(row, this.getColumnKey(column));
return this.composeClass(column.badgeClass ? column.badgeClass(value, row) : this.getDefaultBadgeClass(value));
}
getDisplayValue(column: DataTableColumn<T>, row: T): string | number {
const value = this.getCellValue(row, this.getColumnKey(column));
if (column.formatter) {
return column.formatter(value, row);
}
if (value === null || value === undefined) {
return '-';
}
return typeof value === 'string' || typeof value === 'number' ? value : String(value);
}
getCellTemplate(column: DataTableColumn<T>): DataTableCellDirective | undefined {
return this.cellTemplateMap().get(this.getColumnKey(column));
}
getCellTemplateContext(column: DataTableColumn<T>, row: T): DataTableCellContext<T> {
return {
$implicit: row,
row,
value: this.getCellValue(row, this.getColumnKey(column))
};
}
getActionButtonClass(action: DataTableAction<T>): string {
return this.composeClass(
'ti-btn ti-btn-icon ti-btn-sm',
action.className || this.getDefaultActionClass(action.type)
);
}
onRowClick(row: T): void {
this.rowClicked.emit(row);
}
@HostListener('document:click')
onDocumentClick(): void {
this.closeActionMenu();
}
@HostListener('document:keydown.escape')
onEscapeKey(): void {
this.closeActionMenu();
}
private getAlignClass(align?: DataTableColumn<T>['align']): string {
switch (align) {
case 'left':
return 'text-start';
case 'right':
return 'text-end';
default:
return '!text-center';
}
}
composeClass(...classes: Array<string | undefined>): string {
return classes.filter(Boolean).join(' ');
}
private getRowId(row: T): string | number | null {
const rowId = row['id'];
return typeof rowId === 'string' || typeof rowId === 'number'
? rowId
: null;
}
private getDefaultActionClass(actionType: string): string {
switch (actionType) {
case 'view':
return 'ti-btn-info-full';
case 'edit':
return 'ti-btn-primary-full';
case 'delete':
return 'ti-btn-danger-full';
default:
return 'ti-btn-light';
}
}
private getDefaultBadgeClass(value: unknown): string {
if (typeof value === 'string') {
if (value.toLowerCase() === 'active') {
return 'badge bg-success/10 text-success';
}
if (value.toLowerCase() === 'inactive') {
return 'badge bg-danger/10 text-danger';
}
}
return 'badge bg-light text-defaulttextcolor';
}
getRowClass(row: T, index: number): string {
const baseClass = this.trBodyClass();
// 1. Parent override
const rowClassValue = this.rowClass();
if (rowClassValue) {
const customClass =
typeof rowClassValue === 'function'
? rowClassValue(row, index)
: rowClassValue;
return this.composeClass(baseClass, customClass);
}
// 2. Automatic status detection
if (this.rowColorMode() === 'none') {
return baseClass;
}
const status =
row['status'] ??
row['state'] ??
row['approvalStatus'] ??
row['workflowStatus'];
if (typeof status === 'string') {
switch (status.toLowerCase()) {
case 'active':
case 'approved':
case 'success':
return this.composeClass(baseClass, 'table-success');
case 'inactive':
case 'rejected':
case 'failed':
return this.composeClass(baseClass, 'table-danger');
case 'pending':
return this.composeClass(baseClass, 'table-warning');
case 'draft':
return this.composeClass(baseClass, 'table-info');
}
}
// 3. Boolean status
if (typeof row['isActive'] === 'boolean') {
return this.composeClass(
baseClass,
row['isActive'] ? '' : 'table-danger'
);
}
// 4. Cycle through Ynex colors
const colorClass =
this.defaultRowClasses[index % this.defaultRowClasses.length];
return this.composeClass(baseClass, '');
}
onFilterClick(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.closeActionMenu();
this.filterClicked.emit();
}
}
export { DataTableCellDirective } from '../../directives/data-table-cell.directive';
export { DataTableToolbarDirective } from '../../directives/data-table-toolbar/data-table-toolbar.directive';
export { DataTableFilterActionsDirective } from '../../directives/data-table-filter-actions/data-table-filter-actions.directive';