Files
road-monitoring-ui/src/components/data-table/index.tsx

353 lines
11 KiB
TypeScript

'use client';
import React from 'react';
import {
ColumnDef,
ColumnPinningState,
ColumnSizingState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
useReactTable,
getSortedRowModel,
getPaginationRowModel,
} from '@tanstack/react-table';
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
import type { PermissionInput } from '@/hooks/usePermissions';
import { PermissionGuard } from '@/guards';
import TopHeader from './Header';
import TableHeader from './TableHeader';
import { TableFooter } from './Footer';
import { TableActionButton } from './TableActionButton';
import { ColumnViewOptions } from './ColumnViewOptions';
import { getPinnedColumnStyles } from './columnStyles';
import { cn } from '@/lib/utils';
function shouldTruncateCell(column: {
id: string;
columnDef: { meta?: unknown };
}) {
if (column.id === 'actions') return false;
const meta = column.columnDef.meta as
| { disableTruncate?: boolean }
| undefined;
return !meta?.disableTruncate;
}
export type ColumnAlign = 'left' | 'right';
export interface DataTableAction<TData> {
label: string | ((item: TData) => string);
icon: React.ReactNode;
onClick: (item: TData) => void;
permission?: PermissionInput;
disabled?: (item: TData) => boolean;
className?: string;
}
export interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
title?: string;
onAddNew?: () => void;
addButtonText?: string;
isLoading?: boolean;
actions?: DataTableAction<TData>[];
/** Sticky-pin the actions column (use with actionsAlign). */
actionsSticky?: boolean;
actionsAlign?: ColumnAlign;
toolbar?: React.ReactNode;
emptyTitle?: string;
emptyDescription?: string;
pagination?: {
skip: number;
limit: number;
totalItems?: number;
onPageChange: (newSkip: number) => void;
onLimitChange: (newLimit: number) => void;
};
sorting?: SortingState;
onSortingChange?: (sorting: SortingState) => void;
enableColumnControls?: boolean;
onRowClick?: (row: TData) => void;
/** Minimum width (px) for every column unless overridden in the column def. */
minColumnSize?: number;
}
export function DataTable<TData, TValue>(props: DataTableProps<TData, TValue>) {
const { actions, ...rest } = props;
if (!actions?.length) {
return <DataTableContent {...props} />;
}
const actionPermissions = actions.map((action) => action.permission);
return (
<PermissionGuard
permissions={actionPermissions}
match="any"
fallback={<DataTableContent {...rest} actions={undefined} />}
>
<DataTableContent {...props} />
</PermissionGuard>
);
}
function DataTableContent<TData, TValue>({
columns: initialColumns,
data,
title,
onAddNew,
addButtonText,
isLoading = false,
actions,
actionsSticky = false,
actionsAlign = 'right',
toolbar,
emptyTitle = 'No results found.',
emptyDescription = 'Try adjusting your filters or search terms.',
pagination,
onRowClick,
sorting: controlledSorting,
onSortingChange,
enableColumnControls = true,
minColumnSize = 120,
}: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({});
const [internalSorting, setInternalSorting] = React.useState<SortingState>(
[],
);
const sorting = controlledSorting ?? internalSorting;
const isManualSorting = Boolean(onSortingChange);
const handleSortingChange = React.useCallback(
(updater: SortingState | ((old: SortingState) => SortingState)) => {
const nextSorting =
typeof updater === 'function' ? updater(sorting) : updater;
if (onSortingChange) {
onSortingChange(nextSorting);
return;
}
setInternalSorting(nextSorting);
},
[onSortingChange, sorting],
);
const columns = React.useMemo(() => {
const cols: ColumnDef<TData, TValue>[] = [...initialColumns];
if (actions?.length) {
cols.push({
id: 'actions',
size: 110,
minSize: 90,
enableHiding: false,
enableSorting: false,
enablePinning: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => {
const item = row.original;
return (
<div className="flex justify-start gap-2">
{actions.map((action) => {
const label =
typeof action.label === 'function'
? action.label(item)
: action.label;
return (
<TableActionButton
key={label}
label={label}
permission={action.permission}
className={action.className}
disabled={action.disabled?.(item)}
onClick={(event) => {
event.stopPropagation();
action.onClick(item);
}}
>
{action.icon}
</TableActionButton>
);
})}
</div>
);
},
});
}
return cols;
}, [actions, initialColumns]);
const columnPinning = React.useMemo<ColumnPinningState>(
() =>
actionsSticky && actions?.length ? { [actionsAlign]: ['actions'] } : {},
[actions, actionsAlign, actionsSticky],
);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection,
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
onSortingChange: handleSortingChange,
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
enableColumnResizing: true,
enableColumnPinning: true,
columnResizeMode: 'onChange',
defaultColumn: {
minSize: minColumnSize,
size: minColumnSize,
},
manualPagination: !!pagination,
manualSorting: isManualSorting,
pageCount: pagination
? Math.ceil((pagination.totalItems ?? data.length) / pagination.limit)
: -1,
state: {
rowSelection,
columnVisibility,
columnPinning,
columnSizing,
sorting,
pagination: pagination
? {
pageIndex: Math.floor(pagination.skip / pagination.limit),
pageSize: pagination.limit,
}
: undefined,
},
onPaginationChange: (updater) => {
if (typeof updater === 'function' && pagination) {
const newState = updater({
pageIndex: Math.floor(pagination.skip / pagination.limit),
pageSize: pagination.limit,
});
const pageSizeChanged = newState.pageSize !== pagination.limit;
if (pageSizeChanged) {
pagination.onLimitChange(newState.pageSize);
pagination.onPageChange(0);
return;
}
pagination.onPageChange(newState.pageIndex * newState.pageSize);
}
},
});
return (
<>
<section className="space-y-3">
<TopHeader
title={onAddNew ? title : undefined}
itemCount={pagination?.totalItems ?? data.length}
onAddNew={onAddNew}
addButtonText={addButtonText}
toolbar={toolbar}
tableTools={
enableColumnControls ? <ColumnViewOptions table={table} /> : null
}
/>
<div className="overflow-hidden rounded-lg border bg-background">
<Table
containerClassName="max-h-[calc(100vh-350px)] overflow-auto"
className="table-fixed border-separate border-spacing-0"
style={{ width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader table={table} />
<TableBody>
{isLoading ? (
Array.from({ length: 5 }).map((_, idx) => (
<TableRow key={idx}>
{columns.map((_, colIdx) => (
<TableCell
key={colIdx}
className="h-13 border-b border-border px-4 py-3"
>
<Skeleton className="h-4 w-full max-w-35" />
</TableCell>
))}
</TableRow>
))
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => {
const truncate = shouldTruncateCell(cell.column);
const pinned = cell.column.getIsPinned();
const isActionsCell = cell.column.id === 'actions';
const isRowClickable = Boolean(
onRowClick && !isActionsCell,
);
const content = flexRender(
cell.column.columnDef.cell,
cell.getContext(),
);
return (
<TableCell
key={cell.id}
style={getPinnedColumnStyles(cell.column)}
onClick={
isRowClickable
? () => onRowClick?.(row.original)
: undefined
}
className={cn(
'h-13 max-w-0 overflow-hidden border-b border-border px-4 py-3 text-sm',
pinned && 'bg-background',
isRowClickable && 'cursor-pointer',
isActionsCell && 'cursor-default',
)}
>
{truncate ? (
<div className="truncate">{content}</div>
) : (
content
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-28 text-center"
>
<div className="flex flex-col items-center justify-center text-muted-foreground gap-1">
<p className="text-sm font-medium">{emptyTitle}</p>
<p className="text-xs">{emptyDescription}</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<TableFooter
table={table}
totalItems={pagination?.totalItems ?? data.length}
pageSize={pagination?.limit}
onPageSizeChange={pagination?.onLimitChange}
/>
</section>
</>
);
}