| 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 { Table, TableRow, TableBody, TableCell, TableHead, TableHeader } from './Table'; | |
| import AnimatedSearchInput from './AnimatedSearchInput'; | |
| import { useMediaQuery, useLocalize } from '~/hooks'; | |
| import { TrashIcon, Spinner } from '~/svgs'; | |
| import { Skeleton } from './Skeleton'; | |
| import { Checkbox } from './Checkbox'; | |
| import { Button } from './Button'; | |
| import { cn } from '~/utils'; | |
| type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & { | |
| meta?: { | |
| size?: string | number; | |
| mobileSize?: string | number; | |
| minWidth?: string | number; | |
| }; | |
| }; | |
| const SelectionCheckbox = memo( | |
| ({ | |
| checked, | |
| onChange, | |
| ariaLabel, | |
| }: { | |
| checked: boolean; | |
| onChange: (value: boolean) => void; | |
| ariaLabel: string; | |
| }) => ( | |
| <div | |
| role="button" | |
| tabIndex={0} | |
| onKeyDown={(e) => e.stopPropagation()} | |
| className="flex h-full w-[30px] items-center justify-center" | |
| onClick={(e) => e.stopPropagation()} | |
| > | |
| <Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} /> | |
| </div> | |
| ), | |
| ); | |
| SelectionCheckbox.displayName = 'SelectionCheckbox'; | |
| interface DataTableProps<TData, TValue> { | |
| columns: TableColumn<TData, TValue>[]; | |
| data: TData[]; | |
| onDelete?: (selectedRows: TData[]) => Promise<void>; | |
| filterColumn?: string; | |
| defaultSort?: SortingState; | |
| columnVisibilityMap?: Record<string, string>; | |
| className?: string; | |
| pageSize?: number; | |
| isFetchingNextPage?: boolean; | |
| hasNextPage?: boolean; | |
| fetchNextPage?: (options?: unknown) => Promise<unknown>; | |
| enableRowSelection?: boolean; | |
| showCheckboxes?: boolean; | |
| onFilterChange?: (value: string) => void; | |
| filterValue?: string; | |
| isLoading?: boolean; | |
| enableSearch?: boolean; | |
| } | |
| const TableRowComponent = <TData, TValue>({ | |
| row, | |
| isSmallScreen, | |
| onSelectionChange, | |
| index, | |
| isSearching, | |
| }: { | |
| row: Row<TData>; | |
| 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 ( | |
| <TableRow | |
| data-state={row.getIsSelected() ? 'selected' : undefined} | |
| className="motion-safe:animate-fadeIn border-b border-border-light transition-all duration-300 ease-out hover:bg-surface-secondary" | |
| style={{ | |
| animationDelay: `${index * 20}ms`, | |
| transform: `translateY(${isSearching ? '4px' : '0'})`, | |
| opacity: isSearching ? 0.5 : 1, | |
| }} | |
| > | |
| {row.getVisibleCells().map((cell) => { | |
| if (cell.column.id === 'select') { | |
| return ( | |
| <TableCell key={cell.id} className="px-2 py-1 transition-all duration-300"> | |
| <SelectionCheckbox | |
| checked={row.getIsSelected()} | |
| onChange={handleSelection} | |
| ariaLabel="Select row" | |
| /> | |
| </TableCell> | |
| ); | |
| } | |
| if (cell.column.id === 'title') { | |
| return ( | |
| <TableHead | |
| key={cell.id} | |
| className="w-0 max-w-0 px-2 py-1 align-middle text-xs transition-all duration-300 sm:px-4 sm:py-2 sm:text-sm" | |
| style={getColumnStyle( | |
| cell.column.columnDef as TableColumn<TData, TValue>, | |
| isSmallScreen, | |
| )} | |
| scope="row" | |
| > | |
| <div className="overflow-hidden text-ellipsis"> | |
| {flexRender(cell.column.columnDef.cell, cell.getContext())} | |
| </div> | |
| </TableHead> | |
| ); | |
| } | |
| return ( | |
| <TableCell | |
| key={cell.id} | |
| className="w-0 max-w-0 px-2 py-1 align-middle text-xs transition-all duration-300 sm:px-4 sm:py-2 sm:text-sm" | |
| style={getColumnStyle( | |
| cell.column.columnDef as TableColumn<TData, TValue>, | |
| isSmallScreen, | |
| )} | |
| > | |
| <div className="overflow-hidden text-ellipsis"> | |
| {flexRender(cell.column.columnDef.cell, cell.getContext())} | |
| </div> | |
| </TableCell> | |
| ); | |
| })} | |
| </TableRow> | |
| ); | |
| }; | |
| const MemoizedTableRow = memo(TableRowComponent) as typeof TableRowComponent; | |
| function getColumnStyle<TData, TValue>( | |
| column: TableColumn<TData, TValue>, | |
| 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, | |
| ariaLabel, | |
| }: { | |
| onDelete?: () => Promise<void>; | |
| isDeleting: boolean; | |
| disabled: boolean; | |
| isSmallScreen: boolean; | |
| ariaLabel: string; | |
| }) => { | |
| if (!onDelete) { | |
| return null; | |
| } | |
| return ( | |
| <Button | |
| variant="outline" | |
| onClick={onDelete} | |
| disabled={disabled} | |
| className={cn('min-w-[40px] transition-all duration-200', isSmallScreen && 'px-2 py-1')} | |
| aria-label={ariaLabel} | |
| > | |
| {isDeleting ? ( | |
| <Spinner className="size-4" /> | |
| ) : ( | |
| <> | |
| <TrashIcon className="size-3.5 text-red-400 sm:size-4" /> | |
| {!isSmallScreen && <span className="ml-2">Delete</span>} | |
| </> | |
| )} | |
| </Button> | |
| ); | |
| }, | |
| ); | |
| export default function DataTable<TData, TValue>({ | |
| columns, | |
| data, | |
| onDelete, | |
| filterColumn, | |
| defaultSort = [], | |
| className = '', | |
| isFetchingNextPage = false, | |
| hasNextPage = false, | |
| fetchNextPage, | |
| enableRowSelection = true, | |
| showCheckboxes = true, | |
| onFilterChange, | |
| filterValue, | |
| isLoading, | |
| enableSearch = true, | |
| }: DataTableProps<TData, TValue>) { | |
| const localize = useLocalize(); | |
| const isSmallScreen = useMediaQuery('(max-width: 768px)'); | |
| const tableContainerRef = useRef<HTMLDivElement>(null); | |
| const [isDeleting, setIsDeleting] = useState(false); | |
| const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); | |
| const [sorting, setSorting] = useState<SortingState>(defaultSort); | |
| const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); | |
| const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); | |
| 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<TData> }) => ( | |
| <div className="flex h-full w-[30px] items-center justify-center"> | |
| <Checkbox | |
| checked={table.getIsAllPageRowsSelected()} | |
| onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))} | |
| aria-label="Select all" | |
| /> | |
| </div> | |
| ), | |
| cell: ({ row }: { row: Row<TData> }) => ( | |
| <SelectionCheckbox | |
| checked={row.getIsSelected()} | |
| onChange={(value) => 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({}); | |
| } finally { | |
| setIsDeleting(false); | |
| } | |
| }, [onDelete, table]); | |
| const getRandomWidth = () => Math.floor(Math.random() * (410 - 170 + 1)) + 170; | |
| const skeletons = Array.from({ length: 13 }, (_, index) => { | |
| const randomWidth = getRandomWidth(); | |
| const firstDataColumnIndex = tableColumns[0]?.id === 'select' ? 1 : 0; | |
| return ( | |
| <TableRow key={index} className="motion-safe:animate-fadeIn border-b border-border-light"> | |
| {tableColumns.map((column, columnIndex) => { | |
| const style = getColumnStyle(column as TableColumn<TData, TValue>, isSmallScreen); | |
| const isFirstDataColumn = columnIndex === firstDataColumnIndex; | |
| return ( | |
| <TableCell key={column.id} className="px-2 py-1 sm:px-4 sm:py-2" style={style}> | |
| <Skeleton | |
| className="h-6" | |
| style={isFirstDataColumn ? { width: `${randomWidth}px` } : { width: '100%' }} | |
| /> | |
| </TableCell> | |
| ); | |
| })} | |
| </TableRow> | |
| ); | |
| }); | |
| return ( | |
| <div className={cn('flex h-full flex-col gap-4', className)}> | |
| {/* Table controls */} | |
| <div className="flex flex-wrap items-center gap-2 sm:gap-4"> | |
| {enableRowSelection && showCheckboxes && ( | |
| <DeleteButton | |
| onDelete={handleDelete} | |
| isDeleting={isDeleting} | |
| disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting} | |
| isSmallScreen={isSmallScreen} | |
| ariaLabel={localize('com_ui_delete_selected_items')} | |
| /> | |
| )} | |
| {filterColumn !== undefined && table.getColumn(filterColumn) && enableSearch && ( | |
| <div className="relative flex-1"> | |
| <AnimatedSearchInput | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| isSearching={isSearching} | |
| placeholder="Search..." | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| {/* Virtualized table */} | |
| <div | |
| ref={tableContainerRef} | |
| className={cn( | |
| 'relative h-[calc(100vh-20rem)] max-w-full overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10', | |
| 'transition-all duration-300 ease-out', | |
| isSearching && 'bg-surface-secondary/50', | |
| className, | |
| )} | |
| > | |
| <Table className="w-full min-w-[300px] table-fixed border-separate border-spacing-0"> | |
| <TableHeader className="sticky top-0 z-50 bg-surface-secondary"> | |
| {table.getHeaderGroups().map((headerGroup) => ( | |
| <TableRow key={headerGroup.id} className="border-b border-border-light"> | |
| {headerGroup.headers.map((header) => ( | |
| <TableHead | |
| key={header.id} | |
| className="whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary sm:px-4" | |
| style={getColumnStyle( | |
| header.column.columnDef as TableColumn<TData, TValue>, | |
| isSmallScreen, | |
| )} | |
| onClick={ | |
| header.column.getCanSort() | |
| ? header.column.getToggleSortingHandler() | |
| : undefined | |
| } | |
| scope="col" | |
| > | |
| {header.isPlaceholder | |
| ? null | |
| : flexRender(header.column.columnDef.header, header.getContext())} | |
| </TableHead> | |
| ))} | |
| </TableRow> | |
| ))} | |
| </TableHeader> | |
| <TableBody> | |
| {paddingTop > 0 && ( | |
| <tr> | |
| <td style={{ height: `${paddingTop}px` }} /> | |
| </tr> | |
| )} | |
| {isLoading && skeletons} | |
| {virtualRows.map((virtualRow) => { | |
| const row = rows[virtualRow.index]; | |
| return ( | |
| <MemoizedTableRow | |
| key={row.id} | |
| row={row} | |
| isSmallScreen={isSmallScreen} | |
| index={virtualRow.index} | |
| isSearching={isSearching} | |
| /> | |
| ); | |
| })} | |
| {!virtualRows.length && ( | |
| <TableRow className="hover:bg-transparent"> | |
| <TableCell colSpan={columns.length} className="p-4 text-center"> | |
| No data available | |
| </TableCell> | |
| </TableRow> | |
| )} | |
| {paddingBottom > 0 && ( | |
| <tr> | |
| <td style={{ height: `${paddingBottom}px` }} /> | |
| </tr> | |
| )} | |
| {/* Loading indicator */} | |
| {(isFetchingNextPage || hasNextPage) && ( | |
| <TableRow className="hover:bg-transparent"> | |
| <TableCell colSpan={columns.length} className="p-4"> | |
| <div className="flex h-full items-center justify-center"> | |
| {isFetchingNextPage ? ( | |
| <Spinner className="size-4" /> | |
| ) : ( | |
| hasNextPage && <div className="h-6" /> | |
| )} | |
| </div> | |
| </TableCell> | |
| </TableRow> | |
| )} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| </div> | |
| ); | |
| } | |