import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Row, ColumnDef, flexRender, SortingState, useReactTable, getCoreRowModel, VisibilityState, getSortedRowModel, ColumnFiltersState, getFilteredRowModel, } from '@tanstack/react-table'; import type { Table as TTable } from '@tanstack/react-table'; import { Button, Table, Checkbox, TableRow, TableBody, TableCell, TableHead, TableHeader, AnimatedSearchInput, } from './'; import { TrashIcon, Spinner } from '~/components/svg'; import { useLocalize, useMediaQuery } from '~/hooks'; import { cn } from '~/utils'; type TableColumn = ColumnDef & { meta?: { size?: string | number; mobileSize?: string | number; minWidth?: string | number; }; }; const SelectionCheckbox = memo( ({ checked, onChange, ariaLabel, }: { checked: boolean; onChange: (value: boolean) => void; ariaLabel: string; }) => (
e.stopPropagation()} className="flex h-full w-[30px] items-center justify-center" onClick={(e) => e.stopPropagation()} >
), ); SelectionCheckbox.displayName = 'SelectionCheckbox'; interface DataTableProps { columns: TableColumn[]; data: TData[]; onDelete?: (selectedRows: TData[]) => Promise; filterColumn?: string; defaultSort?: SortingState; columnVisibilityMap?: Record; className?: string; pageSize?: number; isFetchingNextPage?: boolean; hasNextPage?: boolean; fetchNextPage?: (options?: unknown) => Promise; enableRowSelection?: boolean; showCheckboxes?: boolean; onFilterChange?: (value: string) => void; filterValue?: string; } const TableRowComponent = ({ row, isSmallScreen, onSelectionChange, index, isSearching, }: { row: Row; isSmallScreen: boolean; onSelectionChange?: (rowId: string, selected: boolean) => void; index: number; isSearching: boolean; }) => { const handleSelection = useCallback( (value: boolean) => { row.toggleSelected(value); onSelectionChange?.(row.id, value); }, [row, onSelectionChange], ); return ( {row.getVisibleCells().map((cell) => { if (cell.column.id === 'select') { return ( ); } return ( , isSmallScreen, )} >
{flexRender(cell.column.columnDef.cell, cell.getContext())}
); })}
); }; const MemoizedTableRow = memo(TableRowComponent) as typeof TableRowComponent; function getColumnStyle( column: TableColumn, isSmallScreen: boolean, ): React.CSSProperties { return { width: isSmallScreen ? column.meta?.mobileSize : column.meta?.size, minWidth: column.meta?.minWidth, maxWidth: column.meta?.size, }; } const DeleteButton = memo( ({ onDelete, isDeleting, disabled, isSmallScreen, localize, }: { onDelete?: () => Promise; isDeleting: boolean; disabled: boolean; isSmallScreen: boolean; localize: (key: string) => string; }) => { if (!onDelete) { return null; } return ( ); }, ); export default function DataTable({ columns, data, onDelete, filterColumn, defaultSort = [], className = '', isFetchingNextPage = false, hasNextPage = false, fetchNextPage, enableRowSelection = true, showCheckboxes = true, onFilterChange, filterValue, }: DataTableProps) { const localize = useLocalize(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const tableContainerRef = useRef(null); const [isDeleting, setIsDeleting] = useState(false); const [rowSelection, setRowSelection] = useState>({}); const [sorting, setSorting] = useState(defaultSort); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); const [searchTerm, setSearchTerm] = useState(filterValue ?? ''); const [isSearching, setIsSearching] = useState(false); const tableColumns = useMemo(() => { if (!enableRowSelection || !showCheckboxes) { return columns; } const selectColumn = { id: 'select', header: ({ table }: { table: TTable }) => (
table.toggleAllPageRowsSelected(Boolean(value))} aria-label="Select all" />
), cell: ({ row }: { row: Row }) => ( row.toggleSelected(value)} ariaLabel="Select row" /> ), meta: { size: '50px' }, }; return [selectColumn, ...columns]; }, [columns, enableRowSelection, showCheckboxes]); const table = useReactTable({ data, columns: tableColumns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), enableRowSelection, enableMultiRowSelection: true, state: { sorting, columnFilters, columnVisibility, rowSelection, }, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: setRowSelection, }); const { rows } = table.getRowModel(); const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => tableContainerRef.current, estimateSize: useCallback(() => 48, []), overscan: 10, }); const virtualRows = rowVirtualizer.getVirtualItems(); const totalSize = rowVirtualizer.getTotalSize(); const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0; const paddingBottom = virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0; useEffect(() => { const scrollElement = tableContainerRef.current; if (!scrollElement) { return; } const handleScroll = async () => { if (!hasNextPage || isFetchingNextPage) { return; } const { scrollTop, scrollHeight, clientHeight } = scrollElement; if (scrollHeight - scrollTop <= clientHeight * 1.5) { try { // Safely fetch next page without breaking if lastPage is undefined await fetchNextPage?.(); } catch (error) { console.error('Unable to fetch next page:', error); } } }; scrollElement.addEventListener('scroll', handleScroll, { passive: true }); return () => scrollElement.removeEventListener('scroll', handleScroll); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); useEffect(() => { setIsSearching(true); const timeout = setTimeout(() => { onFilterChange?.(searchTerm); setIsSearching(false); }, 300); return () => clearTimeout(timeout); }, [searchTerm, onFilterChange]); const handleDelete = useCallback(async () => { if (!onDelete) { return; } setIsDeleting(true); try { const itemsToDelete = table.getFilteredSelectedRowModel().rows.map((r) => r.original); await onDelete(itemsToDelete); setRowSelection({}); // await fetchNextPage?.({ pageParam: lastPage?.nextCursor }); } finally { setIsDeleting(false); } }, [onDelete, table]); return (
{/* Table controls */}
{enableRowSelection && showCheckboxes && ( )} {filterColumn !== undefined && table.getColumn(filterColumn) && (
setSearchTerm(e.target.value)} isSearching={isSearching} placeholder={`${localize('com_ui_search')}...`} />
)}
{/* Virtualized table */}
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( , isSmallScreen, )} onClick={ header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined } > {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ))} ))} {paddingTop > 0 && ( )} {virtualRows.map((virtualRow) => { const row = rows[virtualRow.index]; return ( ); })} {!virtualRows.length && ( {localize('com_ui_no_data')} )} {paddingBottom > 0 && ( )} {/* Loading indicator */} {(isFetchingNextPage || hasNextPage) && (
{isFetchingNextPage ? ( ) : ( hasNextPage &&
)}
)}
); }