"use client"; import { ColumnDef, ColumnFiltersState, ExpandedState, flexRender, getCoreRowModel, getExpandedRowModel, getFilteredRowModel, getSortedRowModel, OnChangeFn, Row, RowSelectionState, SortingState, useReactTable, } from "@tanstack/react-table"; import { AnimatePresence } from "framer-motion"; import type { ReactNode } from "react"; import { Fragment, useEffect, useRef, useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/shadcn/table"; import { DataTableAnimatedRow } from "@/components/shadcn/table/data-table-animated-row"; import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination"; import { DataTableSearch } from "@/components/shadcn/table/data-table-search"; import { useFilterTransitionOptional } from "@/contexts"; import { cn } from "@/lib/utils"; import { FilterOption, MetaDataProps } from "@/types"; type DataTableRowAttributes = { [key: `data-${string}`]: string | undefined; }; /** * Default column size used by TanStack Table when no explicit size is set. * We skip applying inline width styles for columns with this default value * to allow them to flex naturally within the table layout. */ const DEFAULT_COLUMN_SIZE = 150; const ACTIONS_COLUMN_ID = "actions"; const TABLE_COLUMN_GAP_CLASS = "pr-6"; const STICKY_ACTION_COLUMN_CLASS = "sticky right-0 z-20 min-w-12"; const STICKY_ACTION_CELL_CLASS = `${STICKY_ACTION_COLUMN_CLASS} last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary`; const getTableColumnClassName = ( columnId: string, variant: "header" | "cell", ) => { const isActionsColumn = columnId === ACTIONS_COLUMN_ID; return cn( !isActionsColumn && TABLE_COLUMN_GAP_CLASS, isActionsColumn && variant === "cell" && STICKY_ACTION_CELL_CLASS, ); }; interface DataTableProviderProps { columns: ColumnDef[]; data: TData[]; metadata?: MetaDataProps; customFilters?: FilterOption[]; disableScroll?: boolean; enableRowSelection?: boolean; rowSelection?: RowSelectionState; onRowSelectionChange?: OnChangeFn; /** Function to determine if a row can be selected */ getRowCanSelect?: (row: Row) => boolean; /** Show search bar in the table toolbar */ showSearch?: boolean; /** Function to extract sub-rows from a row for hierarchical data */ getSubRows?: (row: TData) => TData[] | undefined; /** Controlled expanded state */ expanded?: ExpandedState; /** Callback when expanded state changes */ onExpandedChange?: OnChangeFn; /** Auto-select children when parent selected (default: true) */ enableSubRowSelection?: boolean; /** Expand all rows by default, or provide specific expanded state */ defaultExpanded?: boolean | ExpandedState; /** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */ paramPrefix?: string; /* * Controlled Mode Props * --------------------- * By default, DataTable uses URL params for pagination/search (via paramPrefix). * This causes Next.js page re-renders on every interaction. * * For tables inside drawers/modals, use controlled mode instead: * - Pass controlledPage, controlledPageSize, controlledSearch as state values * - Pass onPageChange, onPageSizeChange, onSearchChange as state setters * - This keeps state local, avoiding URL changes and unnecessary page re-renders * * Example: * const [page, setPage] = useState(1); * const [search, setSearch] = useState(""); * */ controlledSearch?: string; onSearchChange?: (value: string) => void; /** * Called when the user commits a search by pressing Enter. * Use this alongside onSearchChange to implement "search on Enter" behavior. */ onSearchCommit?: (value: string) => void; controlledPage?: number; controlledPageSize?: number; onPageChange?: (page: number) => void; onPageSizeChange?: (pageSize: number) => void; /** Show loading state with opacity overlay (for controlled mode) */ isLoading?: boolean; /** Custom placeholder text for the search input */ searchPlaceholder?: string; /** Render additional content after each row (e.g., inline expansion) */ renderAfterRow?: (row: Row) => ReactNode; /** Badge shown inside the search input (e.g., active drill-down group) */ searchBadge?: { label: string; onDismiss: () => void }; /** Optional click handler for top-level rows. */ onRowClick?: (row: Row) => void; /** Optional data attributes applied to each top-level row. */ getRowAttributes?: (row: Row) => DataTableRowAttributes; /** Optional header rendered inside the table container, above the toolbar. */ header?: ReactNode; /** Optional content rendered in the toolbar before the total entries count. */ toolbarRightContent?: ReactNode; } export function DataTable({ columns, data, metadata, disableScroll = false, enableRowSelection = false, rowSelection, onRowSelectionChange, getRowCanSelect, showSearch = false, getSubRows, expanded: controlledExpanded, onExpandedChange, enableSubRowSelection = true, defaultExpanded, paramPrefix = "", controlledSearch, onSearchChange, onSearchCommit, controlledPage, controlledPageSize, onPageChange, onPageSizeChange, isLoading = false, searchPlaceholder, renderAfterRow, searchBadge, onRowClick, getRowAttributes, header, toolbarRightContent, }: DataTableProviderProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); // ExpandedState should be a Record for individual row control // Note: We don't use `true` (boolean) as it makes rows permanently expanded const [expanded, setExpanded] = useState(() => { if (typeof defaultExpanded === "object") return defaultExpanded; return {}; }); // Get transition state from context for loading indicator const filterTransition = useFilterTransitionOptional(); // Use either context-based pending state or controlled isLoading prop const isPending = (filterTransition?.isPending ?? false) || isLoading; const table = useReactTable({ data, columns, enableSorting: true, enableRowSelection: getRowCanSelect ?? enableRowSelection, getCoreRowModel: getCoreRowModel(), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), onRowSelectionChange, manualPagination: true, // Expansion support for hierarchical data getSubRows, getExpandedRowModel: getSubRows ? getExpandedRowModel() : undefined, enableSubRowSelection, onExpandedChange: onExpandedChange ?? setExpanded, state: { sorting, columnFilters, rowSelection: rowSelection ?? {}, expanded: controlledExpanded ?? expanded, }, }); // Track whether initial expansion has been applied const hasInitiallyExpanded = useRef(false); // Expand all rows on mount when defaultExpanded={true} useEffect(() => { if ( !hasInitiallyExpanded.current && defaultExpanded === true && getSubRows ) { table.toggleAllRowsExpanded(true); hasInitiallyExpanded.current = true; } }, [defaultExpanded, getSubRows, table]); // Calculate selection key to force header re-render when selection changes const selectionKey = rowSelection ? Object.keys(rowSelection).filter((k) => rowSelection[k]).length : 0; // Calculate expansion key to force header re-render when expansion changes const currentExpanded = controlledExpanded ?? expanded; const expansionKey = currentExpanded === true ? "all" : typeof currentExpanded === "object" ? Object.keys(currentExpanded).filter((k) => currentExpanded[k]).length : 0; // Format total entries count const totalEntries = metadata?.pagination?.count ?? 0; const formattedTotal = totalEntries.toLocaleString(); const showToolbar = showSearch || metadata || toolbarRightContent; const rows = table.getRowModel().rows; const handleRowClick = (row: Row, target: HTMLElement | null) => { if (!onRowClick) { return; } if (target?.closest("a, button, input, [role=menuitem]")) { return; } onRowClick(row); }; return (
{header &&
{header}
} {/* Table Toolbar */} {showToolbar && (
{showSearch && ( )}
{toolbarRightContent} {metadata && ( {formattedTotal} Total Entries )}
)} {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const size = header.getSize(); const isActionsHeader = header.column.id === ACTIONS_COLUMN_ID; return ( {header.isPlaceholder || isActionsHeader ? null : flexRender( header.column.columnDef.header, header.getContext(), )} ); })} ))} {rows?.length ? ( rows.map((row) => getSubRows && row.depth > 0 ? ( ) : ( handleRowClick(row, event.target as HTMLElement) } > {row.getVisibleCells().map((cell) => ( {flexRender( cell.column.columnDef.cell, cell.getContext(), )} ))} {renderAfterRow?.(row)} ), ) ) : ( No results. )}
{metadata && ( )}
); }