mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-24 12:20:14 +01:00
598 lines
21 KiB
TypeScript
598 lines
21 KiB
TypeScript
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<TData extends Record<string, unknown>, TValue>({
|
|
columns,
|
|
data,
|
|
className = '',
|
|
isLoading = false,
|
|
isFetching = false,
|
|
config,
|
|
filterValue = '',
|
|
onFilterChange,
|
|
defaultSort = [],
|
|
isFetchingNextPage = false,
|
|
hasNextPage = false,
|
|
fetchNextPage,
|
|
onReset,
|
|
sorting,
|
|
onSortingChange,
|
|
customActionsRenderer,
|
|
}: DataTableProps<TData, TValue>) {
|
|
const localize = useLocalize();
|
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
|
|
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
|
const scrollTimeoutRef = useRef<number | null>(null);
|
|
const scrollRAFRef = useRef<number | null>(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<number | null>(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<VisibilityState>({});
|
|
const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection();
|
|
const [error, setError] = useState<Error | null>(null);
|
|
const [searchTerm, setSearchTerm] = useState(filterValue);
|
|
const [internalSorting, setInternalSorting] = useState<SortingState>(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<TData, TValue>[] => {
|
|
if (!enableRowSelection || !showCheckboxes) {
|
|
return columns.map((col) => col as unknown as ColumnDef<TData, TValue>);
|
|
}
|
|
|
|
const selectColumn: ColumnDef<TData, TValue> = {
|
|
id: 'select',
|
|
header: () => {
|
|
const extraCheckboxProps = (isIndeterminate ? { indeterminate: true } : {}) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
return (
|
|
<div
|
|
className="flex h-full items-center justify-center"
|
|
aria-label={localize('com_ui_select_all')}
|
|
>
|
|
<SelectionCheckbox
|
|
checked={isAllSelected}
|
|
onChange={(value) => {
|
|
if (isAllSelected || !value) {
|
|
setOptimizedRowSelection({});
|
|
} else {
|
|
const allSelection = data.reduce<Record<string, boolean>>((acc, item, index) => {
|
|
acc[getRowId(item, index)] = true;
|
|
return acc;
|
|
}, {});
|
|
setOptimizedRowSelection(allSelection);
|
|
}
|
|
}}
|
|
ariaLabel={localize('com_ui_select_all')}
|
|
{...extraCheckboxProps}
|
|
/>
|
|
</div>
|
|
);
|
|
},
|
|
cell: ({ row }) => {
|
|
const rowDescription = row.original.name
|
|
? `named ${row.original.name}`
|
|
: `at position ${row.index + 1}`;
|
|
return (
|
|
<div
|
|
className="flex h-full items-center justify-center"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={localize(`com_ui_select_row`, { rowDescription })}
|
|
>
|
|
<SelectionCheckbox
|
|
checked={row.getIsSelected()}
|
|
onChange={(value) => row.toggleSelected(value)}
|
|
ariaLabel={localize(`com_ui_select_row`, { rowDescription })}
|
|
/>
|
|
</div>
|
|
);
|
|
},
|
|
meta: {
|
|
className: 'w-12',
|
|
},
|
|
};
|
|
|
|
return [selectColumn, ...columns.map((col) => col as unknown as ColumnDef<TData, TValue>)];
|
|
}, [columns, enableRowSelection, showCheckboxes, localize]);
|
|
|
|
const table = useReactTable<TData>({
|
|
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 (
|
|
<DataTableErrorBoundary onReset={handleReset}>
|
|
<div className="flex flex-col items-center justify-center p-8">
|
|
<p className="mb-4 text-red-500">{error.message}</p>
|
|
<Button onClick={handleReset}>{localize('com_ui_retry')}</Button>
|
|
</div>
|
|
</DataTableErrorBoundary>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'relative flex w-full flex-col overflow-hidden rounded-lg border border-border-light bg-background',
|
|
'h-[calc(100vh-8rem)] max-h-[80vh]',
|
|
className,
|
|
)}
|
|
role="region"
|
|
aria-label={localize('com_ui_data_table')}
|
|
>
|
|
<div className="flex w-full shrink-0 items-center gap-3 border-b border-border-light">
|
|
{shouldShowSearch && <DataTableSearch value={searchTerm} onChange={setSearchTerm} />}
|
|
{customActionsRenderer &&
|
|
customActionsRenderer({
|
|
selectedCount,
|
|
selectedRows,
|
|
table: table as unknown as TTable<ProcessedDataRow<TData>>,
|
|
})}
|
|
</div>
|
|
<div
|
|
ref={tableContainerRef}
|
|
className="overflow-anchor-none relative min-h-0 flex-1 overflow-auto will-change-scroll"
|
|
style={
|
|
{
|
|
WebkitOverflowScrolling: 'touch',
|
|
overscrollBehavior: 'contain',
|
|
} as React.CSSProperties
|
|
}
|
|
role="region"
|
|
aria-label={localize('com_ui_data_table_scroll_area')}
|
|
aria-describedby={showSkeletons ? 'loading-status' : undefined}
|
|
>
|
|
<Table role="table" aria-label={localize('com_ui_data_table')} aria-rowcount={data.length}>
|
|
<TableHeader className="sticky top-0 z-10 bg-surface-secondary">
|
|
{headerGroups.map((headerGroup) => (
|
|
<TableRow key={headerGroup.id}>
|
|
{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 (
|
|
<TableHead
|
|
key={header.id}
|
|
className={cn(
|
|
'border-b border-border-light py-2',
|
|
isSelectHeader ? 'px-0 text-center' : 'px-3',
|
|
canSort && 'cursor-pointer hover:bg-surface-tertiary',
|
|
meta?.className,
|
|
)}
|
|
onClick={header.column.getToggleSortingHandler()}
|
|
onKeyDown={handleSortingKeyDown}
|
|
role={canSort ? 'button' : undefined}
|
|
tabIndex={canSort ? 0 : undefined}
|
|
aria-label={sortAriaLabel}
|
|
aria-sort={
|
|
header.column.getIsSorted() as
|
|
| 'ascending'
|
|
| 'descending'
|
|
| 'none'
|
|
| undefined
|
|
}
|
|
>
|
|
{isSelectHeader ? (
|
|
flexRender(header.column.columnDef.header, header.getContext())
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
{canSort && (
|
|
<span className="text-text-primary" aria-hidden="true">
|
|
{{
|
|
asc: <ArrowUp className="size-4 text-text-primary" />,
|
|
desc: <ArrowDown className="size-4 text-text-primary" />,
|
|
}[header.column.getIsSorted() as string] ?? (
|
|
<ArrowDownUp className="size-4 text-text-primary" />
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</TableHead>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
))}
|
|
</TableHeader>
|
|
|
|
<TableBody>
|
|
{showSkeletons ? (
|
|
<SkeletonRows
|
|
count={skeletonCount}
|
|
columns={tableColumns as ColumnDef<Record<string, unknown>>[]}
|
|
/>
|
|
) : virtualizationActive ? (
|
|
<>
|
|
{paddingTop > 0 && (
|
|
<TableRow aria-hidden="true">
|
|
<TableCell
|
|
colSpan={tableColumns.length}
|
|
style={{ height: paddingTop, padding: 0, border: 0 }}
|
|
/>
|
|
</TableRow>
|
|
)}
|
|
{virtualRows.map((virtualRow) => {
|
|
const row = rows[virtualRow.index];
|
|
if (!row) return null;
|
|
return (
|
|
<MemoizedTableRow
|
|
key={virtualRow.key}
|
|
row={row as unknown as Row<TData>}
|
|
virtualIndex={virtualRow.index}
|
|
selected={row.getIsSelected()}
|
|
style={{ height: rowHeight }}
|
|
/>
|
|
);
|
|
})}
|
|
{paddingBottom > 0 && (
|
|
<TableRow aria-hidden="true">
|
|
<TableCell
|
|
colSpan={tableColumns.length}
|
|
style={{ height: paddingBottom, padding: 0, border: 0 }}
|
|
/>
|
|
</TableRow>
|
|
)}
|
|
</>
|
|
) : (
|
|
rows.map((row) => (
|
|
<MemoizedTableRow
|
|
key={getRowId(row.original as TData, row.index)}
|
|
row={row as unknown as Row<TData>}
|
|
virtualIndex={row.index}
|
|
selected={row.getIsSelected()}
|
|
style={{ height: rowHeight }}
|
|
/>
|
|
))
|
|
)}
|
|
{isFetchingNextPage && (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={tableColumns.length}
|
|
className="p-4 text-center"
|
|
id="loading-status"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<div className="flex items-center justify-center gap-2">
|
|
<Spinner className="h-5 w-5" aria-hidden="true" />
|
|
<span className="sr-only">{localize('com_ui_loading_more_data')}</span>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
{!isLoading && !showSkeletons && rows.length === 0 && (
|
|
<div
|
|
className="flex flex-col items-center justify-center py-12"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<Label className="text-center text-text-secondary">
|
|
{searchTerm ? localize('com_ui_no_search_results') : localize('com_ui_no_data')}
|
|
</Label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default DataTable;
|