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 { private readonly destroyRef = inject(DestroyRef); private readonly searchTerms$ = new Subject(); readonly cellTemplates = contentChildren(DataTableCellDirective); readonly toolbarTemplate = contentChild(DataTableToolbarDirective); readonly filterActionsTemplate = contentChild(DataTableFilterActionsDirective); readonly filterRowContent = viewChild>('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[]>([]); rows = input([]); actions = input[]>([]); loading = input(false); /* --------- pagination inputs ---- */ totalRecords = input(0); pageIndex = input(1); pageSize = input(15); pageSizeOptions = input([10, 15, 20, 50]); showPaginator = input(true); /*---------------------------*/ tableTitle = input(''); toolTip = input(''); buttonTitle = input(''); /* --------- Search inputs ---- */ showSearch = input(false); showformSelect = input(false); showAddButton = input(false); searchPlaceholder = input('Search...'); searchDebounceTime = input(300); emptyMessage = input('No records found'); emptyDescription = input('There is currently no data to display.'); showFilterButton = input(false); filterActive = input(false); /*---------------------------*/ /* --------- Permission inputs ---- */ allowedPermissions = input([]); /*------------outputs ---------------*/ searchChanged = output(); pageChanged = output(); sortChanged = output(); actionClicked = output>(); rowClicked = output(); sortColumn = signal(''); sortDirection = signal<'asc' | 'desc'>('asc'); openActionRowId = signal(null); tableClass = input('table table-bordered whitespace-nowrap min-w-full'); tableHeadClass = input(''); tableBodyClass = input(''); trHeadClass = input('border-b border-defaultborder bg-primary/10 dark:bg-primary/15 dark:border-defaultborder/10'); trBodyClass = input('border-b border-defaultborder hover:bg-light cursor-pointer'); rowClass = input string)>(''); rowColorMode = input<'none' | 'status' | 'cycle'>('status'); defaultThClass = input('text-defaulttextcolor dark:text-white font-semibold text-start'); defaultTdClass = input(''); actionHeaderClass = input('!text-center'); actionCellClass = input('!text-center'); addClicked = output(); filterClicked = output(); totalVisibleColumns = computed(() => this.columns().length + (this.actions().length > 0 ? 1 : 0)); allowedPermissionSet = computed(() => new Set(this.allowedPermissions())); cellTemplateMap = computed(() => { const templateMap = new Map(); 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): 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, 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, 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, row: T): boolean { if (action.permission && !this.allowedPermissionSet().has(action.permission)) { return false; } return action.visible ? action.visible(row) : true; } isActionDisabled(action: DataTableAction, row: T): boolean { return action.disabled ? action.disabled(row) : false; } getActionLabel(action: DataTableAction): string { return action.tooltip || action.label; } getColumnKey(column: DataTableColumn): string { return String(column.key); } getSortIconClass(column: DataTableColumn): 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): string { return this.composeClass(this.defaultThClass(), this.getAlignClass(column.headerAlign), column.headerClass); } getCellClass(column: DataTableColumn): string { return this.composeClass(this.defaultTdClass(), this.getAlignClass(column.align), column.cellClass); } getBadgeClass(column: DataTableColumn, 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, 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): DataTableCellDirective | undefined { return this.cellTemplateMap().get(this.getColumnKey(column)); } getCellTemplateContext(column: DataTableColumn, row: T): DataTableCellContext { return { $implicit: row, row, value: this.getCellValue(row, this.getColumnKey(column)) }; } getActionButtonClass(action: DataTableAction): 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['align']): string { switch (align) { case 'left': return 'text-start'; case 'right': return 'text-end'; default: return '!text-center'; } } composeClass(...classes: Array): 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';