/* DataTable — paginated, sortable, optionally expandable list table. * * Backed by @tanstack/react-table v8 in **manual** mode: the parent owns * data fetching, sort + search dispatch, and the page/pageSize state. * The table itself just renders what it's given and emits user-input * callbacks. * * External API is intentionally stable from the previous custom * implementation: * * …} // optional * onSearch={(q) => …} // optional * onRowClick={(row) => …} // optional * isLoading={false} // optional * * // NEW since the TanStack rewrite: * getRowCanExpand={(row) => boolean} // optional, default false * renderSubRow={(row) => <…>} // optional; required when expandable * /> * * The Column shape is the same as before (key/label/sortable/render). * Internally we translate to ColumnDef so existing consumers keep working * without code changes. */ 'use client'; import { useMemo, useState } from 'react'; import { type ColumnDef, type ExpandedState, type Row, type SortingState, flexRender, getCoreRowModel, getExpandedRowModel, useReactTable, } from '@tanstack/react-table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/skeleton'; import { EmptyState } from '@/components/ui/empty-state'; import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2, Search, } from 'lucide-react'; import { cn } from '@/lib/utils'; export interface Column { key: string; label: string; sortable?: boolean; render?: (value: any, row: TData) => React.ReactNode; } export interface DataTableProps { columns: Column[]; data: TData[]; totalCount: number; page: number; pageSize: number; onPageChange: (page: number) => void; onSort?: (column: string, direction: 'asc' | 'desc') => void; onSearch?: (query: string) => void; onRowClick?: (row: TData) => void; isLoading?: boolean; /** Per-row gate for expansion. Return true to enable a chevron toggle. */ getRowCanExpand?: (row: TData) => boolean; /** Renders the expanded sub-row body when a row is open. */ renderSubRow?: (row: TData) => React.ReactNode; /** Empty-state slot. Defaults to a neutral "No results" message. */ emptyTitle?: string; emptyDescription?: string; } export default function DataTable({ columns, data, totalCount, page, pageSize, onPageChange, onSort, onSearch, onRowClick, isLoading = false, getRowCanExpand, renderSubRow, emptyTitle = 'No data found', emptyDescription = 'Try adjusting your search or filters.', }: DataTableProps) { const [searchQuery, setSearchQuery] = useState(''); const [sorting, setSorting] = useState([]); const [expanded, setExpanded] = useState({}); const expandable = !!renderSubRow; const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); // Translate the legacy Column shape into TanStack ColumnDef. const tanstackColumns = useMemo[]>(() => { const cols: ColumnDef[] = []; // Lead expansion column when expansion is enabled. if (expandable) { cols.push({ id: '__expand', header: () => null, cell: ({ row }) => row.getCanExpand() ? ( ) : null, size: 32, }); } for (const c of columns) { cols.push({ id: c.key, accessorKey: c.key, enableSorting: !!c.sortable, header: c.label, cell: ({ row, getValue }) => c.render ? c.render(getValue(), row.original) : (getValue() as React.ReactNode), }); } return cols; }, [columns, expandable]); const table = useReactTable({ data, columns: tanstackColumns, state: { sorting, expanded }, onSortingChange: (updater) => { const next = typeof updater === 'function' ? updater(sorting) : updater; setSorting(next); // Defer to caller for actual data fetch. if (onSort && next.length > 0) { onSort(next[0].id, next[0].desc ? 'desc' : 'asc'); } }, onExpandedChange: setExpanded, getRowCanExpand: getRowCanExpand ? (row) => getRowCanExpand(row.original) : () => expandable, getCoreRowModel: getCoreRowModel(), getExpandedRowModel: getExpandedRowModel(), manualPagination: true, manualSorting: true, pageCount: totalPages, }); const handleSearch = () => { onSearch?.(searchQuery); }; return (
{onSearch && (
setSearchQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} className="pl-10" />
)}
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const sortable = header.column.getCanSort(); const sortDir = header.column.getIsSorted(); return ( {header.isPlaceholder ? null : sortable ? ( ) : ( flexRender(header.column.columnDef.header, header.getContext()) )} ); })} ))} {isLoading ? ( renderLoadingRows(table) ) : table.getRowModel().rows.length === 0 ? ( ) : ( table.getRowModel().rows.map((row) => ( )) )}
Showing {totalCount === 0 ? 0 : Math.min((page - 1) * pageSize + 1, totalCount)} {' '} to {Math.min(page * pageSize, totalCount)} {' '} of {totalCount} results
Page {page} of {totalPages}
); } function ExpandableRow({ row, onRowClick, renderSubRow, colSpan, }: { row: Row; onRowClick?: (row: TData) => void; renderSubRow?: (row: TData) => React.ReactNode; colSpan: number; }) { const clickable = !!onRowClick; return ( <> onRowClick?.(row.original)} > {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} {row.getIsExpanded() && renderSubRow && ( {renderSubRow(row.original)} )} ); } function SortIcon({ dir }: { dir: 'asc' | 'desc' | null }) { if (dir === 'asc') return ; if (dir === 'desc') return ; return ; } function renderLoadingRows(table: ReturnType>) { const cols = table.getAllLeafColumns().length; return Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: cols }).map((_, j) => ( ))} )); }