610 lines
16 KiB
TypeScript
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';
|