From 0c8598bd1595d912fe87be42e64e660452680477 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 13 Sep 2025 23:53:08 +0200 Subject: [PATCH] feat: enhance DataTable with column pinning and improve sorting functionality --- .../Nav/SettingsTabs/Data/SharedLinks.tsx | 26 +- .../SettingsTabs/General/ArchivedChats.tsx | 75 +++-- packages/client/src/components/DataTable.tsx | 297 ++++++++++++++---- 3 files changed, 298 insertions(+), 100 deletions(-) diff --git a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx index 143ab126ad..754b64ae5d 100644 --- a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx @@ -1,5 +1,4 @@ import { useCallback, useState, useMemo } from 'react'; -import { useRecoilValue } from 'recoil'; import { Link } from 'react-router-dom'; import { TrashIcon, MessageSquare } from 'lucide-react'; import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider'; @@ -95,10 +94,6 @@ export default function SharedLinks() { ); if (validRows.length === 0) { - showToast({ - message: localize('com_ui_no_valid_items'), - severity: NotificationSeverity.WARNING, - }); return; } @@ -188,11 +183,7 @@ export default function SharedLinks() { }, { accessorKey: 'actions', - header: () => ( - - ), + header: () => , meta: { size: '7%', mobileSize: '25%', @@ -255,14 +246,25 @@ export default function SharedLinks() { columns={columns} data={allLinks} onDelete={handleDelete} - config={{ skeleton: { count: 10 }, search: { filterColumn: 'title' } }} + config={{ + skeleton: { count: 10 }, + search: { + filterColumn: 'title', + enableSearch: true, + debounce: 300, + }, + selection: { + enableRowSelection: true, + showCheckboxes: true, + }, + }} hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} isFetching={isFetching} fetchNextPage={handleFetchNextPage} onFilterChange={handleFilterChange} isLoading={isLoading} - onSortChange={handleSort} + onSortingChange={handleSort} sortBy={queryParams.sortBy} sortDirection={queryParams.sortDirection} /> diff --git a/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx b/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx index 153955de19..df7c1709a1 100644 --- a/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx +++ b/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx @@ -27,12 +27,15 @@ import { NotificationSeverity } from '~/common'; import { useLocalize } from '~/hooks'; import { formatDate } from '~/utils'; -const DEFAULT_PARAMS: ConversationListParams = { +const DEFAULT_PARAMS = { isArchived: true, sortBy: 'createdAt', sortDirection: 'desc', search: '', -}; +} as const satisfies ConversationListParams; + +type SortKey = 'createdAt' | 'title'; +const isSortKey = (v: string): v is SortKey => v === 'createdAt' || v === 'title'; const defaultSort: SortingState = [ { @@ -42,6 +45,7 @@ const defaultSort: SortingState = [ ]; // Define the table column type for better type safety +// (kept from your original code) type TableColumn = ColumnDef & { meta?: { size?: string | number; @@ -82,26 +86,36 @@ export default function ArchivedChatsTable() { })); }, []); + // Robust against stale state; keeps UI sort in sync with backend defaults const handleSortingChange = useCallback( (updater: SortingState | ((old: SortingState) => SortingState)) => { - const newSorting = typeof updater === 'function' ? updater(sorting) : updater; - setSorting(newSorting); - const sortDescriptor = newSorting[0]; - if (sortDescriptor) { - setQueryParams((prev) => ({ - ...prev, - sortBy: sortDescriptor.id as 'createdAt' | 'title', - sortDirection: sortDescriptor.desc ? 'desc' : 'asc', - })); - } else { - setQueryParams((prev) => ({ - ...prev, - sortBy: 'createdAt', - sortDirection: 'desc', - })); - } + setSorting((prev) => { + const next = typeof updater === 'function' ? updater(prev) : updater; + + // If user clears sorting, fall back to default both in UI and query + const coerced = next.length === 0 ? defaultSort : next; + const primary = coerced[0]; + + setQueryParams((p) => { + if (primary && isSortKey(primary.id)) { + return { + ...p, + sortBy: primary.id, + sortDirection: primary.desc ? 'desc' : 'asc', + }; + } + // Fallback if id isn't one of the permitted keys + return { + ...p, + sortBy: 'createdAt', + sortDirection: 'desc', + }; + }); + + return coerced; + }); }, - [sorting], + [setQueryParams, setSorting], ); const handleError = useCallback( @@ -317,6 +331,29 @@ export default function ArchivedChatsTable() { /> + + +
+ +
+ + } + selection={{ + selectHandler: confirmDelete, + selectClasses: `bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white ${ + deleteMutation.isLoading ? 'cursor-not-allowed opacity-80' : '' + }`, + selectText: deleteMutation.isLoading ? : localize('com_ui_delete'), + }} + /> +
); } diff --git a/packages/client/src/components/DataTable.tsx b/packages/client/src/components/DataTable.tsx index 6ba29d2022..5167e05b4e 100644 --- a/packages/client/src/components/DataTable.tsx +++ b/packages/client/src/components/DataTable.tsx @@ -7,11 +7,13 @@ import React, { memo, useMemo, startTransition, + CSSProperties, } from 'react'; import { ArrowUp, ArrowDownUp } from 'lucide-react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Row, + Column, ColumnDef, flexRender, SortingState, @@ -21,6 +23,7 @@ import { getSortedRowModel, ColumnFiltersState, getFilteredRowModel, + ColumnPinningState, } from '@tanstack/react-table'; import type { Table as TTable } from '@tanstack/react-table'; import { Table, TableRow, TableBody, TableCell, TableHead, TableHeader } from './Table'; @@ -76,6 +79,61 @@ export const DATA_TABLE_CONSTANTS = { // Static skeleton widths for performance - avoids random computation on every render const STATIC_SKELETON_WIDTHS = [20, 40, 60, 80, 100, 120, 140, 160, 180, 200, 220, 240]; +// Column pinning utility - applies sticky positioning for pinned columns +const getCommonPinningStyles = ( + column: Column, + table: TTable, +): CSSProperties => { + const isPinned = column.getIsPinned(); + + if (!isPinned) { + return { + position: 'relative', + width: column.getSize(), + zIndex: 0, + }; + } + + const leftPinnedColumns = table.getLeftLeafColumns(); + const rightPinnedColumns = table.getRightLeafColumns(); + + const isLastLeftPinnedColumn = + isPinned === 'left' && + leftPinnedColumns.length > 0 && + leftPinnedColumns[leftPinnedColumns.length - 1].id === column.id; + + const isFirstRightPinnedColumn = + isPinned === 'right' && rightPinnedColumns.length > 0 && rightPinnedColumns[0].id === column.id; + + let boxShadow: string | undefined; + if (isLastLeftPinnedColumn) { + boxShadow = '-4px 0 4px -4px rgba(0, 0, 0, 0.1) inset'; + } else if (isFirstRightPinnedColumn) { + boxShadow = '4px 0 4px -4px rgba(0, 0, 0, 0.1) inset'; + } + + // Calculate offset for pinned position + let offset = 0; + if (isPinned === 'left') { + const columnIndex = leftPinnedColumns.findIndex((col) => col.id === column.id); + offset = leftPinnedColumns.slice(0, columnIndex).reduce((sum, col) => sum + col.getSize(), 0); + } else if (isPinned === 'right') { + const columnIndex = rightPinnedColumns.findIndex((col) => col.id === column.id); + offset = rightPinnedColumns.slice(columnIndex + 1).reduce((sum, col) => sum + col.getSize(), 0); + } + + return { + boxShadow, + left: isPinned === 'left' ? `${offset}px` : undefined, + right: isPinned === 'right' ? `${offset}px` : undefined, + opacity: 0.95, + position: 'sticky', + width: column.getSize(), + zIndex: 1, + backgroundColor: 'var(--surface-secondary)', + }; +}; + type TableColumn = ColumnDef & { meta?: { size?: string | number; @@ -84,12 +142,12 @@ type TableColumn = ColumnDef & { }; }; -// Throttle utility for performance optimization -const throttle = any>(fn: T, delay: number): T => { - let timeoutId: NodeJS.Timeout | null = null; +// Throttle utility for performance optimization - Fixed: Use DOM-safe timeout type +const throttle = unknown>(fn: T, delay: number): T => { + let timeoutId: ReturnType | null = null; let lastExecTime = 0; - return ((...args: any[]) => { + return ((...args: Parameters) => { const currentTime = Date.now(); if (currentTime - lastExecTime > delay) { @@ -109,18 +167,21 @@ const throttle = any>(fn: T, delay: number): T => }; // Deep comparison utility for objects -const deepEqual = (a: any, b: any): boolean => { +const deepEqual = (a: unknown, b: unknown): boolean => { if (a === b) return true; if (a == null || b == null) return false; if (typeof a !== typeof b) return false; if (typeof a === 'object') { - const keysA = Object.keys(a); - const keysB = Object.keys(b); + const keysA = Object.keys(a as Record); + const keysB = Object.keys(b as Record); if (keysA.length !== keysB.length) return false; for (const key of keysA) { - if (!keysB.includes(key) || !deepEqual(a[key], b[key])) { + if ( + !keysB.includes(key) || + !deepEqual((a as Record)[key], (b as Record)[key]) + ) { return false; } } @@ -187,14 +248,16 @@ const useColumnStyles = ( }, [columns, isSmallScreen]); }; -const TableRowComponent = ({ +const TableRowComponent = ({ row, columnStyles, + table, index, virtualIndex, }: { row: Row; columnStyles: Record; + table: TTable; index: number; virtualIndex?: number; }) => { @@ -230,7 +293,10 @@ const TableRowComponent = ({
{flexRender(cell.column.columnDef.cell, cell.getContext())} @@ -345,11 +411,13 @@ SkeletonRows.displayName = 'SkeletonRows'; * selection: { enableRowSelection: true, showCheckboxes: false }, * search: { enableSearch: true, debounce: 500, filterColumn: 'name' }, * skeleton: { count: 5 }, - * virtualization: { overscan: 15 } + * virtualization: { overscan: 15 }, + * pinning: { enableColumnPinning: true } * }; * * Defaults: enableRowSelection: true, showCheckboxes: true, enableSearch: true, - * skeleton.count: 10, virtualization.overscan: 10, search.debounce: 300 + * skeleton.count: 10, virtualization.overscan: 10, search.debounce: 300, + * pinning.enableColumnPinning: false */ interface DataTableConfig { /** @@ -428,6 +496,20 @@ interface DataTableConfig { */ overscan?: number; }; + + /** + * Column pinning configuration for sticky column behavior. + * Controls whether columns can be pinned to left or right side of the table. + */ + pinning?: { + /** + * Enable column pinning functionality. + * When true, columns can be pinned to the left or right side of the table. + * Pinned columns remain visible during horizontal scrolling. + * @default false + */ + enableColumnPinning?: boolean; + }; } function useDebounced(value: T, delay: number) { @@ -523,6 +605,7 @@ export default function DataTable({ skeletonCount: config?.skeleton?.count ?? 10, overscan: config?.virtualization?.overscan ?? DATA_TABLE_CONSTANTS.OVERS_CAN, debounceDelay: config?.search?.debounce ?? DATA_TABLE_CONSTANTS.SEARCH_DEBOUNCE_MS, + enableColumnPinning: config?.pinning?.enableColumnPinning ?? false, }; }, [config]); @@ -534,6 +617,7 @@ export default function DataTable({ skeletonCount, overscan, debounceDelay, + enableColumnPinning, } = tableConfig; const localize = useLocalize(); @@ -543,6 +627,7 @@ export default function DataTable({ // State management const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); + const [columnPinning, setColumnPinning] = useState({}); const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection(); const [term, setTerm] = useState(''); const [isDeleting, setIsDeleting] = useState(false); @@ -563,6 +648,9 @@ export default function DataTable({ const [internalSorting, setInternalSorting] = useState(defaultSort); const finalSorting = sorting ?? internalSorting; + // Mount tracking for cleanup - Fixed: Declare mountedRef before any callback that uses it + const mountedRef = useRef(true); + // Sorting handler: call external callback if provided, otherwise use internal state const handleSortingChangeInternal = useCallback( (updater: SortingState | ((old: SortingState) => SortingState)) => { @@ -611,6 +699,13 @@ export default function DataTable({ filterValue, sortingLength: sorting?.length || 0, }; + + // Search UX warning for missing filterColumn + if (enableSearch && !filterColumn && onFilterChange) { + console.warn( + 'DataTable: enableSearch is true but filterColumn is missing. Search will be hidden.', + ); + } } const sanitizeError = useCallback((err: Error): string => { @@ -660,18 +755,18 @@ export default function DataTable({ }, [columns, enableRowSelection, showCheckboxes]); // Memoized column styles for performance - const columnStyles = useColumnStyles(tableColumns, isSmallScreen); + const columnStyles = useColumnStyles(tableColumns as TableColumn[], isSmallScreen); - // Set CSS variables for column sizing + // Set CSS variables for column sizing - Fixed: Add SSR guard useLayoutEffect(() => { - if (tableContainerRef.current) { - tableColumns.forEach((column, index) => { - if (column.id) { - const size = columnStyles[column.id]?.width || 'auto'; - tableContainerRef.current!.style.setProperty(`--col-${index}-size`, `${size}`); - } - }); - } + if (typeof window === 'undefined' || !tableContainerRef.current) return; + + tableColumns.forEach((column, index) => { + if (column.id) { + const size = columnStyles[column.id]?.width || 'auto'; + tableContainerRef.current!.style.setProperty(`--col-${index}-size`, `${size}`); + } + }); }, [tableColumns, columnStyles]); // Memoized row data with stable references @@ -679,7 +774,7 @@ export default function DataTable({ return data.map((item, index) => ({ ...item, _index: index, - _id: (item as any)?.id || index, + _id: (item as Record)?.id || index, })); }, [data]); @@ -692,31 +787,25 @@ export default function DataTable({ getFilteredRowModel: getFilteredRowModel(), enableRowSelection, enableMultiRowSelection: true, + enableColumnPinning, state: { sorting: finalSorting, columnFilters, columnVisibility, + columnPinning, rowSelection: optimizedRowSelection, }, onSortingChange: handleSortingChange, onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, + onColumnPinningChange: setColumnPinning, onRowSelectionChange: setOptimizedRowSelection, }); const { rows } = table.getRowModel(); - // Memoized header groups for performance - const memoizedHeaderGroups = useMemo( - () => table.getHeaderGroups(), - [ - table - .getAllColumns() - .map((c) => c.id) - .join(','), - finalSorting, - ], - ); + // Fixed: Simplify header groups - React Table already memoizes internally + const headerGroups = table.getHeaderGroups(); // Virtual scrolling setup with optimized height measurement const measuredHeightsRef = useRef([]); @@ -732,23 +821,26 @@ export default function DataTable({ return DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE; }, []), overscan, + // Fixed: Optimize measureElement to avoid duplicate getBoundingClientRect calls measureElement: (el) => { - if (el) { - const height = el.getBoundingClientRect().height; - // Memory management for measured heights - measuredHeightsRef.current = [ - ...measuredHeightsRef.current.slice(-DATA_TABLE_CONSTANTS.MEASURED_HEIGHTS_TRIM + 1), - height, - ]; + if (!el) return DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE; - // Trim if exceeds max - if (measuredHeightsRef.current.length > DATA_TABLE_CONSTANTS.MAX_MEASURED_HEIGHTS) { - measuredHeightsRef.current = measuredHeightsRef.current.slice( - -DATA_TABLE_CONSTANTS.MEASURED_HEIGHTS_TRIM, - ); - } + const height = el.getBoundingClientRect().height; + + // Memory management for measured heights + measuredHeightsRef.current = [ + ...measuredHeightsRef.current.slice(-DATA_TABLE_CONSTANTS.MEASURED_HEIGHTS_TRIM + 1), + height, + ]; + + // Trim if exceeds max + if (measuredHeightsRef.current.length > DATA_TABLE_CONSTANTS.MAX_MEASURED_HEIGHTS) { + measuredHeightsRef.current = measuredHeightsRef.current.slice( + -DATA_TABLE_CONSTANTS.MEASURED_HEIGHTS_TRIM, + ); } - return el?.getBoundingClientRect().height ?? DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE; + + return height; }, }); @@ -768,21 +860,19 @@ export default function DataTable({ [virtualRows, rows], ); - // Infinite scrolling with throttled handler + // Fixed: Infinite scrolling with simplified trigger logic and removed accidental local mountedRef const handleScrollInternal = useCallback(async () => { - const mountedRef = { current: true }; if (!mountedRef.current || !tableContainerRef.current) return; const scrollElement = tableContainerRef.current; - const element = scrollElement.getBoundingClientRect(); - const scrollTop = scrollElement.scrollTop; const clientHeight = scrollElement.clientHeight; const virtualEnd = virtualRows.length > 0 ? virtualRows[virtualRows.length - 1].end : 0; - if ( - totalSize - virtualEnd <= clientHeight * DATA_TABLE_CONSTANTS.INFINITE_SCROLL_THRESHOLD || - (element.bottom - window.innerHeight < 100 && scrollTop + clientHeight >= totalSize - 100) - ) { + // Simplified condition: check distance to virtual end + const nearEnd = + totalSize - virtualEnd <= clientHeight * DATA_TABLE_CONSTANTS.INFINITE_SCROLL_THRESHOLD; + + if (nearEnd) { try { await fetchNextPage?.(); } catch (err) { @@ -814,6 +904,8 @@ export default function DataTable({ }, [rowVirtualizer]); useEffect(() => { + if (typeof window === 'undefined') return; + const scrollElement = tableContainerRef.current; if (!scrollElement) return; @@ -832,8 +924,6 @@ export default function DataTable({ }; }, [rowVirtualizer, handleWindowResize, throttledHandleScroll]); - // Mount tracking for cleanup - const mountedRef = useRef(true); useLayoutEffect(() => { mountedRef.current = true; return () => { @@ -843,6 +933,8 @@ export default function DataTable({ // Dynamic measurement optimization useLayoutEffect(() => { + if (typeof window === 'undefined') return; + if (mountedRef.current && tableContainerRef.current && virtualRows.length > 0) { requestAnimationFrame(() => { if (mountedRef.current) { @@ -865,6 +957,7 @@ export default function DataTable({ isSmallScreen, virtualRowsWithStableKeys.length, virtualRowsWithStableKeys, + virtualRows.length, ]); // Search effect with optimized state updates @@ -899,6 +992,7 @@ export default function DataTable({ setError(null); try { + const selectedRowsLength = table.getFilteredSelectedRowModel().rows.length; let selectedRows = table.getFilteredSelectedRowModel().rows.map((r) => r.original); // Validation @@ -912,10 +1006,7 @@ export default function DataTable({ (row): row is TData => typeof row === 'object' && row !== null, ); - if ( - selectedRows.length !== table.getFilteredSelectedRowModel().rows.length && - process.env.NODE_ENV === 'development' - ) { + if (selectedRows.length !== selectedRowsLength && process.env.NODE_ENV === 'development') { console.warn('DataTable: Invalid row data detected and filtered out during deletion.'); } @@ -947,11 +1038,11 @@ export default function DataTable({ } }, [onReset, rowVirtualizer]); - // Memoized computed values - const selectedCount = useMemo( - () => table.getFilteredSelectedRowModel().rows.length, - [table.getFilteredSelectedRowModel().rows.length], - ); + // Fixed: Derive selected count from stable table state instead of re-calling getFilteredSelectedRowModel + const selectedCount = useMemo(() => { + const selection = table.getState().rowSelection; + return Object.keys(selection).length; + }, [table.getState().rowSelection]); const shouldShowSearch = useMemo( () => enableSearch && filterColumn && table.getColumn(filterColumn), @@ -971,6 +1062,15 @@ export default function DataTable({ role="region" aria-label="Data table" > + {/* Accessible live region for loading announcements */} +
+ {isFetchingNextPage + ? 'Loading more rows' + : hasNextPage + ? 'More rows available' + : 'All rows loaded'} +
+ {/* Error display - kept outside boundary for non-rendering errors */} {error && (
@@ -1030,9 +1130,10 @@ export default function DataTable({ > - {memoizedHeaderGroups.map((headerGroup) => ( + {headerGroups.map((headerGroup) => ( {headerGroup.headers.map((header) => { const sortDir = header.column.getIsSorted(); @@ -1053,12 +1154,26 @@ export default function DataTable({ { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + header.column.toggleSorting(); + } + } + : undefined + } className={cn( 'relative whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary transition-colors duration-200 sm:px-4', canSort && 'cursor-pointer hover:bg-surface-hover focus-visible:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', )} - style={columnStyles[header.column.id] || {}} + style={{ + ...columnStyles[header.column.id], + ...getCommonPinningStyles(header.column, table), + }} role="columnheader" tabIndex={canSort ? 0 : -1} aria-sort={ariaSort} @@ -1081,6 +1196,49 @@ export default function DataTable({ )} + {/* Column pinning controls */} + {enableColumnPinning && + !header.isPlaceholder && + header.column.getCanPin() && ( +
+ {header.column.getIsPinned() !== 'left' && ( + + )} + {header.column.getIsPinned() && ( + + )} + {header.column.getIsPinned() !== 'right' && ( + + )} +
+ )}
); })} @@ -1109,6 +1267,7 @@ export default function DataTable({ key={virtualRow.stableKey} row={row} columnStyles={columnStyles} + table={table} index={virtualRow.index} virtualIndex={virtualRow.index} />