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 CellContext, type Row, } from '@tanstack/react-table'; import type { DataTableProps } from './DataTable.types'; import { Table, TableBody, TableHead, TableHeader, TableCell, TableRow, Button, Label, } from '~/components'; import { SelectionCheckbox, MemoizedTableRow, SkeletonRows } from './DataTableComponents'; import { useDebounced, useOptimizedRowSelection } from './DataTable.hooks'; import { DataTableErrorBoundary } from './DataTableErrorBoundary'; import { DataTableSearch } from './DataTableSearch'; import { useMediaQuery, useLocalize } from '~/hooks'; import { useToastContext } from '~/Providers'; import { cn, logger } from '~/utils'; 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 { showToast } = useToastContext(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const isDesktop = useMediaQuery('(min-width: 1024px)'); 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 = 5 } = {}, } = config || {}; const [columnVisibility, setColumnVisibility] = useState({}); const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection(); const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(filterValue); const [internalSorting, setInternalSorting] = useState(defaultSort); const [isScrollingFetching, setIsScrollingFetching] = useState(false); const debouncedTerm = useDebounced(searchTerm, debounceDelay); const finalSorting = sorting ?? internalSorting; // Memoize column visibility calculations const calculatedVisibility = useMemo(() => { const newVisibility: VisibilityState = {}; if (isSmallScreen) { columns.forEach((col: ColumnDef & { meta?: { hideOnMobile?: boolean } }) => { if (col.id && col.meta?.hideOnMobile) { newVisibility[col.id] = false; } }); } return newVisibility; }, [isSmallScreen, columns]); useEffect(() => { setColumnVisibility(calculatedVisibility); }, [calculatedVisibility]); const processedData = useMemo( () => data.map((item, index) => { if (item.id === null || item.id === undefined) { logger.warn( 'DataTable Warning: A data row is missing a unique "id" property. Using index as a fallback. This can lead to unexpected behavior with selection and sorting.', item, ); } return { ...item, _index: index, _id: String(item.id ?? `row-${index}`), }; }), [data], ); // Enhanced columns with desktop-only cell rendering const enhancedColumns = useMemo(() => { return columns.map((col) => { const originalCol = col as ColumnDef & { meta?: { hideOnMobile?: boolean; desktopOnly?: boolean; className?: string; }; }; if (originalCol.meta?.desktopOnly && originalCol.cell) { const originalCell = originalCol.cell; return { ...originalCol, cell: (props: CellContext) => { if (!isDesktop) { return null; } return typeof originalCell === 'function' ? originalCell(props) : originalCell; }, }; } return originalCol; }); }, [columns, isDesktop]); const tableColumns = useMemo(() => { if (!enableRowSelection || !showCheckboxes) { return enhancedColumns as ColumnDef[]; } const selectColumn: ColumnDef = { id: 'select', header: ({ table }) => (
table.toggleAllRowsSelected(value)} ariaLabel={localize('com_ui_select_all' as string)} />
), cell: ({ row }) => (
row.toggleSelected(value)} ariaLabel={`Select row ${row.index + 1}`} />
), meta: { className: 'w-12', }, }; return [ selectColumn, ...(enhancedColumns as ColumnDef[]), ] as ColumnDef[]; }, [enhancedColumns, enableRowSelection, showCheckboxes, localize]); const table = useReactTable({ data: processedData, columns: tableColumns, getRowId: (row) => row._id, 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({ count: processedData.length, getScrollElement: () => tableContainerRef.current, estimateSize: useCallback(() => 50, []), overscan, measureElement: typeof window !== 'undefined' && navigator.userAgent.indexOf('Firefox') === -1 ? (element) => element?.getBoundingClientRect().height ?? 50 : undefined, }); const virtualRows = rowVirtualizer.getVirtualItems(); const totalSize = rowVirtualizer.getTotalSize(); const paddingTop = virtualRows.length > 0 ? (virtualRows[0]?.start ?? 0) : 0; const paddingBottom = virtualRows.length > 0 ? totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0) : 0; const { rows } = table.getRowModel(); const headerGroups = table.getHeaderGroups(); const selectedCount = Object.keys(optimizedRowSelection).length; const showSkeletons = isLoading || (isFetching && !isFetchingNextPage); const shouldShowSearch = enableSearch && onFilterChange; useEffect(() => { setSearchTerm(filterValue); }, [filterValue]); useEffect(() => { if (debouncedTerm !== filterValue && onFilterChange) { onFilterChange(debouncedTerm); setOptimizedRowSelection({}); } }, [debouncedTerm, filterValue, onFilterChange, setOptimizedRowSelection]); // Optimized scroll handler with RAF const handleScroll = useCallback(() => { if (scrollRAFRef.current !== null) { cancelAnimationFrame(scrollRAFRef.current); } scrollRAFRef.current = requestAnimationFrame(() => { if (scrollTimeoutRef.current !== null) { clearTimeout(scrollTimeoutRef.current); } scrollTimeoutRef.current = window.setTimeout(() => { if ( !fetchNextPage || !hasNextPage || isFetchingNextPage || isScrollingFetching || !tableContainerRef.current ) { return; } const { scrollTop, scrollHeight, clientHeight } = tableContainerRef.current; const scrollBottom = scrollTop + clientHeight; const threshold = scrollHeight - 200; if (scrollBottom >= threshold) { setIsScrollingFetching(true); fetchNextPage().finally(() => { setIsScrollingFetching(false); }); } scrollTimeoutRef.current = null; }, 150); // Slightly increased debounce for better performance scrollRAFRef.current = null; }); }, [fetchNextPage, hasNextPage, isFetchingNextPage, isScrollingFetching]); useEffect(() => { const scrollElement = tableContainerRef.current; if (!scrollElement) return; scrollElement.addEventListener('scroll', handleScroll, { passive: true }); return () => { scrollElement.removeEventListener('scroll', handleScroll); if (scrollTimeoutRef.current) { clearTimeout(scrollTimeoutRef.current); } if (scrollRAFRef.current) { cancelAnimationFrame(scrollRAFRef.current); } }; }, [handleScroll]); const handleReset = useCallback(() => { setError(null); setOptimizedRowSelection({}); setSearchTerm(''); onReset?.(); }, [onReset, setOptimizedRowSelection]); if (error) { return (

{error.message}

); } return (
{shouldShowSearch && } {customActionsRenderer && customActionsRenderer({ selectedCount, selectedRows: table.getSelectedRowModel().rows.map((r) => r.original), table, showToast, })}
{headerGroups.map((headerGroup) => ( {headerGroup.headers.map((header) => { const isSelectHeader = header.id === 'select'; const meta = header.column.columnDef.meta as { className?: string } | undefined; return ( {isSelectHeader ? ( flexRender(header.column.columnDef.header, header.getContext()) ) : (
{flexRender(header.column.columnDef.header, header.getContext())} {header.column.getCanSort() && ( {{ asc: , desc: , }[header.column.getIsSorted() as string] ?? ( )} )}
)}
); })}
))}
{showSkeletons ? ( ) : ( <> {paddingTop > 0 && ( )} {virtualRows.map((virtualRow) => { const row = rows[virtualRow.index]; if (!row) return null; return ( } columns={tableColumns} index={virtualRow.index} virtualIndex={virtualRow.index} /> ); })} {paddingBottom > 0 && ( )} )} {isFetchingNextPage && (
)}
{!isLoading && !showSkeletons && rows.length === 0 && (
)}
); } export default DataTable;