360 lines
11 KiB
TypeScript
360 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;
|
|
hidden?: (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) => {
|
|
if (action.hidden?.(item)) return null;
|
|
|
|
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)
|
|
: undefined,
|
|
state: {
|
|
rowSelection,
|
|
columnVisibility,
|
|
columnPinning,
|
|
columnSizing,
|
|
sorting,
|
|
...(pagination
|
|
? {
|
|
pagination: {
|
|
pageIndex: Math.floor(pagination.skip / pagination.limit),
|
|
pageSize: pagination.limit,
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
onPaginationChange: pagination
|
|
? (updater) => {
|
|
const currentState = {
|
|
pageIndex: Math.floor(pagination.skip / pagination.limit),
|
|
pageSize: pagination.limit,
|
|
};
|
|
const newState =
|
|
typeof updater === 'function' ? updater(currentState) : updater;
|
|
const pageSizeChanged = newState.pageSize !== pagination.limit;
|
|
|
|
if (pageSizeChanged) {
|
|
pagination.onLimitChange(newState.pageSize);
|
|
pagination.onPageChange(0);
|
|
return;
|
|
}
|
|
|
|
pagination.onPageChange(newState.pageIndex * newState.pageSize);
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
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>
|
|
</>
|
|
);
|
|
}
|