import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { ArrowUp, ArrowDown, ArrowDownUp } from 'lucide-react'; import { useReactTable, getCoreRowModel, flexRender, type SortingState, type VisibilityState, type ColumnDef, type Row, type Table as TTable, } from '@tanstack/react-table'; import type { DataTableProps, ProcessedDataRow } from './DataTable.types'; import { SelectionCheckbox, MemoizedTableRow, SkeletonRows } from './DataTableComponents'; import { Table, TableBody, TableHead, TableHeader, TableCell, TableRow } from '../Table'; import { useDebounced, useOptimizedRowSelection } from './DataTable.hooks'; import { DataTableErrorBoundary } from './DataTableErrorBoundary'; import { useMediaQuery, useLocalize } from '~/hooks'; import { DataTableSearch } from './DataTableSearch'; import { cn, logger } from '~/utils'; import { Button } from '../Button'; import { Label } from '../Label'; import { Spinner } from '~/svgs'; function DataTable, TValue>({ columns, data, className = '', isLoading = false, isFetching = false, config, filterValue = '', onFilterChange, defaultSort = [], isFetchingNextPage = false, hasNextPage = false, fetchNextPage, onReset, sorting, onSortingChange, customActionsRenderer, }: DataTableProps) { const localize = useLocalize(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const tableContainerRef = useRef(null); const scrollTimeoutRef = useRef(null); const scrollRAFRef = useRef(null); const { selection: { enableRowSelection = true, showCheckboxes = true } = {}, search: { enableSearch = true, debounce: debounceDelay = 300 } = {}, skeleton: { count: skeletonCount = 10 } = {}, virtualization: { overscan = 10, minRows = 50, rowHeight = 56, fastOverscanMultiplier = 4, } = {}, } = config || {}; const virtualizationActive = data.length >= minRows; // Dynamic overscan adjustment for fast scroll bursts (state kept stable, minimal updates) const [dynamicOverscan, setDynamicOverscan] = useState(overscan); const lastScrollTopRef = useRef(0); const lastScrollTimeRef = useRef(performance.now()); const fastScrollTimeoutRef = useRef(null); // Sync overscan prop changes useEffect(() => { setDynamicOverscan(overscan); }, [overscan]); // Cleanup timeout on unmount useEffect(() => { return () => { if (fastScrollTimeoutRef.current) { clearTimeout(fastScrollTimeoutRef.current); } }; }, []); const [columnVisibility, setColumnVisibility] = useState({}); const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection(); const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(filterValue); const [internalSorting, setInternalSorting] = useState(defaultSort); const selectedCount = Object.keys(optimizedRowSelection).length; const isAllSelected = useMemo( () => data.length > 0 && selectedCount === data.length, [data.length, selectedCount], ); const isIndeterminate = selectedCount > 0 && !isAllSelected; const getRowId = useCallback( (row: TData, index?: number) => String(row.id ?? `row-${index ?? 0}`), [], ); const selectedRows = useMemo(() => { if (Object.keys(optimizedRowSelection).length === 0) return []; const dataMap = new Map(data.map((item, index) => [getRowId(item, index), item])); return Object.keys(optimizedRowSelection) .map((id) => dataMap.get(id)) .filter(Boolean) as TData[]; }, [optimizedRowSelection, data, getRowId]); const cleanupTimers = useCallback(() => { if (scrollRAFRef.current) { cancelAnimationFrame(scrollRAFRef.current); scrollRAFRef.current = null; } if (scrollTimeoutRef.current) { clearTimeout(scrollTimeoutRef.current); scrollTimeoutRef.current = null; } }, []); const debouncedTerm = useDebounced(searchTerm, debounceDelay); const finalSorting = sorting ?? internalSorting; const calculatedVisibility = useMemo(() => { const newVisibility: VisibilityState = {}; columns.forEach((col) => { const meta = (col as { meta?: { desktopOnly?: boolean } }).meta; if (!meta?.desktopOnly) return; const rawId = (col as { id?: string | number; accessorKey?: string | number }).id ?? (col as { accessorKey?: string | number }).accessorKey; if ((typeof rawId === 'string' || typeof rawId === 'number') && String(rawId).length > 0) { newVisibility[String(rawId)] = !isSmallScreen; } else { logger.warn( 'DataTable: A desktopOnly column is missing id/accessorKey; cannot control header visibility automatically.', col, ); } }); return newVisibility; }, [isSmallScreen, columns]); useEffect(() => { setColumnVisibility((prev) => ({ ...prev, ...calculatedVisibility })); }, [calculatedVisibility]); const hasWarnedAboutMissingIds = useRef(false); useEffect(() => { if (data.length > 0 && !hasWarnedAboutMissingIds.current) { const missing = data.filter((item) => item.id === null || item.id === undefined); if (missing.length > 0) { logger.warn( `DataTable Warning: ${missing.length} data rows are missing a unique "id" property. Using index as a fallback. This can lead to unexpected behavior with selection and sorting.`, { missingCount: missing.length, sample: missing.slice(0, 3) }, ); hasWarnedAboutMissingIds.current = true; } } }, [data]); const tableColumns = useMemo((): ColumnDef[] => { if (!enableRowSelection || !showCheckboxes) { return columns.map((col) => col as unknown as ColumnDef); } const selectColumn: ColumnDef = { id: 'select', header: () => { const extraCheckboxProps = (isIndeterminate ? { indeterminate: true } : {}) as Record< string, unknown >; return (
{ if (isAllSelected || !value) { setOptimizedRowSelection({}); } else { const allSelection = data.reduce>((acc, item, index) => { acc[getRowId(item, index)] = true; return acc; }, {}); setOptimizedRowSelection(allSelection); } }} ariaLabel={localize('com_ui_select_all')} {...extraCheckboxProps} />
); }, cell: ({ row }) => { const rowDescription = row.original.name ? `named ${row.original.name}` : `at position ${row.index + 1}`; return (
row.toggleSelected(value)} ariaLabel={localize(`com_ui_select_row`, { rowDescription })} />
); }, meta: { className: 'w-12', }, }; return [selectColumn, ...columns.map((col) => col as unknown as ColumnDef)]; }, [columns, enableRowSelection, showCheckboxes, localize]); const table = useReactTable({ data, columns: tableColumns, getRowId: getRowId, getCoreRowModel: getCoreRowModel(), enableRowSelection, enableMultiRowSelection: true, manualSorting: true, manualFiltering: true, state: { sorting: finalSorting, columnVisibility, rowSelection: optimizedRowSelection, }, onSortingChange: onSortingChange ?? setInternalSorting, onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: setOptimizedRowSelection, }); const rowVirtualizer = useVirtualizer({ enabled: virtualizationActive, count: data.length, getScrollElement: () => tableContainerRef.current, getItemKey: (index) => getRowId(data[index] as TData, index), estimateSize: useCallback(() => rowHeight, [rowHeight]), overscan: dynamicOverscan, }); const virtualRows = rowVirtualizer.getVirtualItems(); const totalSize = rowVirtualizer.getTotalSize(); const paddingTop = virtualRows[0]?.start ?? 0; const paddingBottom = virtualRows.length > 0 ? totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0) : 0; const { rows } = table.getRowModel(); const headerGroups = table.getHeaderGroups(); const showSkeletons = isLoading || (isFetching && !isFetchingNextPage); const shouldShowSearch = enableSearch && onFilterChange; // useEffect(() => { // if (data.length > 1000) { // const cleanup = setTimeout(() => { // rowVirtualizer.scrollToIndex(0, { align: 'start' }); // rowVirtualizer.measure(); // }, 1000); // return () => clearTimeout(cleanup); // } // }, [data.length, rowVirtualizer]); useEffect(() => { setSearchTerm(filterValue); }, [filterValue]); useEffect(() => { if (debouncedTerm !== filterValue && onFilterChange) { onFilterChange(debouncedTerm); setOptimizedRowSelection({}); } }, [debouncedTerm, filterValue, onFilterChange, setOptimizedRowSelection]); // Re-measure on key state changes that can affect layout useEffect(() => { if (!virtualizationActive) return; // With fixed rowHeight, just ensure the range recalculates rowVirtualizer.calculateRange(); }, [data.length, finalSorting, columnVisibility, virtualizationActive, rowVirtualizer]); // ResizeObserver to re-measure when container size changes useEffect(() => { if (!virtualizationActive) return; const container = tableContainerRef.current; if (!container) return; const ro = new ResizeObserver(() => { rowVirtualizer.calculateRange(); }); ro.observe(container); return () => ro.disconnect(); }, [virtualizationActive, rowVirtualizer]); const handleScroll = useMemo(() => { let rafId: number | null = null; let timeoutId: number | null = null; return () => { if (rafId) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { const container = tableContainerRef.current; if (container) { const now = performance.now(); const delta = Math.abs(container.scrollTop - lastScrollTopRef.current); const dt = now - lastScrollTimeRef.current; if (dt > 0) { const velocity = delta / dt; // px per ms if ( velocity > 2 && virtualizationActive && dynamicOverscan === overscan /* only expand if not already expanded */ ) { if (fastScrollTimeoutRef.current) { window.clearTimeout(fastScrollTimeoutRef.current); } setDynamicOverscan(Math.min(overscan * fastOverscanMultiplier, overscan * 8)); fastScrollTimeoutRef.current = window.setTimeout(() => { setDynamicOverscan((current) => (current !== overscan ? overscan : current)); }, 160); } } lastScrollTopRef.current = container.scrollTop; lastScrollTimeRef.current = now; } if (timeoutId) clearTimeout(timeoutId); timeoutId = window.setTimeout(() => { const loaderContainer = tableContainerRef.current; if (!loaderContainer || !fetchNextPage || !hasNextPage || isFetchingNextPage) return; const { scrollTop, scrollHeight, clientHeight } = loaderContainer; if (scrollTop + clientHeight >= scrollHeight - 200) { fetchNextPage().finally(); } }, 100); }); }; }, [ fetchNextPage, hasNextPage, isFetchingNextPage, overscan, fastOverscanMultiplier, virtualizationActive, dynamicOverscan, ]); useEffect(() => { const scrollElement = tableContainerRef.current; if (!scrollElement) return; scrollElement.addEventListener('scroll', handleScroll, { passive: true }); return () => { scrollElement.removeEventListener('scroll', handleScroll); cleanupTimers(); }; }, [handleScroll, cleanupTimers]); const handleReset = useCallback(() => { setError(null); setOptimizedRowSelection({}); setSearchTerm(''); onReset?.(); }, [onReset, setOptimizedRowSelection]); if (error) { return (

{error.message}

); } return (
{shouldShowSearch && } {customActionsRenderer && customActionsRenderer({ selectedCount, selectedRows, table: table as unknown as TTable>, })}
{headerGroups.map((headerGroup) => ( {headerGroup.headers.map((header) => { const isDesktopOnly = (header.column.columnDef.meta as { desktopOnly?: boolean } | undefined) ?.desktopOnly ?? false; if (!header.column.getIsVisible() || (isSmallScreen && isDesktopOnly)) { return null; } const isSelectHeader = header.id === 'select'; const meta = header.column.columnDef.meta as { className?: string } | undefined; const canSort = header.column.getCanSort(); let sortAriaLabel: string | undefined; if (canSort) { const sortState = header.column.getIsSorted(); let sortStateLabel = 'sortable'; if (sortState === 'asc') { sortStateLabel = 'ascending'; } else if (sortState === 'desc') { sortStateLabel = 'descending'; } const headerLabel = typeof header.column.columnDef.header === 'string' ? header.column.columnDef.header : header.column.id; sortAriaLabel = `${headerLabel ?? ''} column, ${sortStateLabel}`; } const handleSortingKeyDown = (e: React.KeyboardEvent) => { if (canSort && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); header.column.toggleSorting(); } }; return ( {isSelectHeader ? ( flexRender(header.column.columnDef.header, header.getContext()) ) : (
{flexRender(header.column.columnDef.header, header.getContext())} {canSort && ( )}
)}
); })}
))}
{showSkeletons ? ( >[]} /> ) : virtualizationActive ? ( <> {paddingTop > 0 && ( )} {virtualRows.map((virtualRow) => { const row = rows[virtualRow.index]; if (!row) return null; return ( } virtualIndex={virtualRow.index} selected={row.getIsSelected()} style={{ height: rowHeight }} /> ); })} {paddingBottom > 0 && ( )} ) : ( rows.map((row) => ( } virtualIndex={row.index} selected={row.getIsSelected()} style={{ height: rowHeight }} /> )) )} {isFetchingNextPage && (
)}
{!isLoading && !showSkeletons && rows.length === 0 && (
)}
); } export default DataTable;