From 76b34775f052882f6365dcb3dc231a9214c8d135 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 23 Sep 2025 23:30:27 +0200 Subject: [PATCH] feat(DataTable): Implement new DataTable component with hooks and optimized features - Added DataTable component with support for virtual scrolling, row selection, and customizable columns. - Introduced hooks for debouncing search input, managing row selection, and calculating column styles. - Enhanced accessibility with keyboard navigation and selection checkboxes. - Implemented skeleton loading state for better user experience during data fetching. - Added DataTableSearch component for filtering data with debounced input. - Created utility logger for improved debugging in development. - Updated translations to support new UI elements and actions. --- .../Nav/SettingsTabs/Data/SharedLinks.tsx | 389 ++- .../SettingsTabs/General/ArchivedChats.tsx | 187 +- packages/client/rollup.config.js | 2 + packages/client/src/components/Checkbox.tsx | 2 +- packages/client/src/components/DataTable.tsx | 3077 ----------------- .../components/DataTable/DataTable.hooks.ts | 135 + .../src/components/DataTable/DataTable.tsx | 439 +++ .../components/DataTable/DataTable.types.ts | 68 + .../DataTable/DataTableComponents.tsx | 105 + .../components/DataTable/DataTableSearch.tsx | 37 + packages/client/src/components/index.ts | 3 +- .../client/src/locales/en/translation.json | 15 +- packages/client/src/utils/index.ts | 1 + packages/client/src/utils/logger.ts | 49 + 14 files changed, 1215 insertions(+), 3294 deletions(-) delete mode 100644 packages/client/src/components/DataTable.tsx create mode 100644 packages/client/src/components/DataTable/DataTable.hooks.ts create mode 100644 packages/client/src/components/DataTable/DataTable.tsx create mode 100644 packages/client/src/components/DataTable/DataTable.types.ts create mode 100644 packages/client/src/components/DataTable/DataTableComponents.tsx create mode 100644 packages/client/src/components/DataTable/DataTableSearch.tsx create mode 100644 packages/client/src/utils/logger.ts diff --git a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx index 754b64ae5d..665dae33e8 100644 --- a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx @@ -1,6 +1,7 @@ -import { useCallback, useState, useMemo } from 'react'; +import { useCallback, useState, useMemo, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { TrashIcon, MessageSquare } from 'lucide-react'; +import type { ColumnDef, SortingState } from '@tanstack/react-table'; import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider'; import { OGDialog, @@ -19,8 +20,8 @@ import { } from '@librechat/client'; import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider'; import { NotificationSeverity } from '~/common'; +import { formatDate, cn } from '~/utils'; import { useLocalize } from '~/hooks'; -import { formatDate } from '~/utils'; const DEFAULT_PARAMS: SharedLinksListParams = { pageSize: 25, @@ -30,14 +31,35 @@ const DEFAULT_PARAMS: SharedLinksListParams = { search: '', }; +type SortKey = 'createdAt' | 'title'; +const isSortKey = (v: string): v is SortKey => v === 'createdAt' || v === 'title'; + +const defaultSort: SortingState = [ + { + id: 'createdAt', + desc: true, + }, +]; + +type TableColumn = ColumnDef & { + meta?: { + className?: string; + hideOnMobile?: boolean; + }; +}; + export default function SharedLinks() { const localize = useLocalize(); const { showToast } = useToastContext(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); - const [queryParams, setQueryParams] = useState(DEFAULT_PARAMS); - const [deleteRow, setDeleteRow] = useState(null); - const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [deleteRow, setDeleteRow] = useState(null); + + const [queryParams, setQueryParams] = useState(DEFAULT_PARAMS); + const [sorting, setSorting] = useState(defaultSort); + const [searchValue, setSearchValue] = useState(''); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching, refetch, isLoading } = useSharedLinksQuery(queryParams, { @@ -48,38 +70,115 @@ export default function SharedLinks() { refetchOnMount: false, }); - const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => { + const [allKnownLinks, setAllKnownLinks] = useState([]); + + const handleSearchChange = useCallback((value: string) => { + const trimmedValue = value.trim(); + setSearchValue(trimmedValue); + setAllKnownLinks([]); setQueryParams((prev) => ({ ...prev, - sortBy: sortField as 'title' | 'createdAt', - sortDirection: sortOrder, + search: trimmedValue, })); }, []); - const handleFilterChange = useCallback((value: string) => { - const encodedValue = encodeURIComponent(value.trim()); - setQueryParams((prev) => ({ - ...prev, - search: encodedValue, - })); - }, []); + const handleSortingChange = useCallback( + (updater: SortingState | ((old: SortingState) => SortingState)) => { + setSorting((prev) => { + const next = typeof updater === 'function' ? updater(prev) : updater; - const allLinks = useMemo(() => { - if (!data?.pages) { - return []; + const coerced = next; + const primary = coerced[0]; + + // Seed allKnown with current data before changing params + if (data?.pages) { + const currentFlattened = data.pages.flatMap((page) => page?.links?.filter(Boolean) ?? []); + setAllKnownLinks(currentFlattened); + } + + setQueryParams((p) => { + let sortBy: SortKey; + let sortDirection: 'asc' | 'desc'; + + if (primary && isSortKey(primary.id)) { + sortBy = primary.id; + sortDirection = primary.desc ? 'desc' : 'asc'; + } else { + sortBy = 'createdAt'; + sortDirection = 'desc'; + } + + const newParams = { + ...p, + sortBy, + sortDirection, + }; + + return newParams; + }); + + return coerced; + }); + }, + [setQueryParams, data?.pages], + ); + + const handleError = useCallback( + (error: Error) => { + console.error('DataTable error:', error); + showToast({ + message: localize('com_ui_share_error'), + severity: NotificationSeverity.ERROR, + }); + }, + [showToast, localize], + ); + + useEffect(() => { + if (!data?.pages) return; + + const newFlattened = data.pages.flatMap((page) => page?.links?.filter(Boolean) ?? []); + + const toAdd = newFlattened.filter( + (link: SharedLinkItem) => !allKnownLinks.some((known) => known.shareId === link.shareId), + ); + + if (toAdd.length > 0) { + setAllKnownLinks((prev) => [...prev, ...toAdd]); } - - return data.pages.flatMap((page) => page.links.filter(Boolean)); }, [data?.pages]); + const displayData = useMemo(() => { + const primary = sorting[0]; + if (!primary || allKnownLinks.length === 0) return allKnownLinks; + + return [...allKnownLinks].sort((a: SharedLinkItem, b: SharedLinkItem) => { + let compare: number; + if (primary.id === 'createdAt') { + const aDate = new Date(a.createdAt || 0); + const bDate = new Date(b.createdAt || 0); + compare = aDate.getTime() - bDate.getTime(); + } else if (primary.id === 'title') { + compare = (a.title || '').localeCompare(b.title || ''); + } else { + return 0; + } + return primary.desc ? -compare : compare; + }); + }, [allKnownLinks, sorting]); + const deleteMutation = useDeleteSharedLinkMutation({ - onSuccess: async () => { + onSuccess: (data, variables) => { + const { shareId } = variables; + setAllKnownLinks((prev) => prev.filter((link) => link.shareId !== shareId)); + showToast({ + message: localize('com_ui_shared_link_delete_success'), + severity: NotificationSeverity.SUCCESS, + }); setIsDeleteOpen(false); - setDeleteRow(null); - await refetch(); + refetch(); }, - onError: (error) => { - console.error('Delete error:', error); + onError: () => { showToast({ message: localize('com_ui_share_delete_error'), severity: NotificationSeverity.ERROR, @@ -87,78 +186,47 @@ export default function SharedLinks() { }, }); - const handleDelete = useCallback( - async (selectedRows: SharedLinkItem[]) => { - const validRows = selectedRows.filter( - (row) => typeof row.shareId === 'string' && row.shareId.length > 0, - ); - - if (validRows.length === 0) { - return; - } - - try { - for (const row of validRows) { - await deleteMutation.mutateAsync({ shareId: row.shareId }); - } - - showToast({ - message: localize( - validRows.length === 1 - ? 'com_ui_shared_link_delete_success' - : 'com_ui_shared_link_bulk_delete_success', - ), - severity: NotificationSeverity.SUCCESS, - }); - } catch (error) { - console.error('Failed to delete shared links:', error); - showToast({ - message: localize('com_ui_share_delete_error'), - severity: NotificationSeverity.ERROR, - }); - } - }, - [deleteMutation, showToast, localize], - ); - const handleFetchNextPage = useCallback(async () => { - if (hasNextPage !== true || isFetchingNextPage) { - return; - } + if (!hasNextPage || isFetchingNextPage) return; + await fetchNextPage(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); - try { - await fetchNextPage(); - } catch (error) { - console.error('Failed to fetch next page:', error); - showToast({ - message: localize('com_ui_share_delete_error'), - severity: NotificationSeverity.ERROR, - }); - } - }, [fetchNextPage, hasNextPage, isFetchingNextPage, showToast, localize]); + const effectiveIsLoading = isLoading && displayData.length === 0; + const effectiveIsFetching = isFetchingNextPage; const confirmDelete = useCallback(() => { - if (deleteRow) { - handleDelete([deleteRow]); + if (!deleteRow?.shareId) { + showToast({ + message: localize('com_ui_share_delete_error'), + severity: NotificationSeverity.WARNING, + }); + return; } - setIsDeleteOpen(false); - }, [deleteRow, handleDelete]); + deleteMutation.mutate({ shareId: deleteRow.shareId }); + }, [deleteMutation, deleteRow, localize, showToast]); - const columns = useMemo( + const columns: TableColumn, unknown>[] = useMemo( () => [ { accessorKey: 'title', - header: () => {localize('com_ui_name')}, + accessorFn: (row: Record): unknown => { + const link = row as SharedLinkItem; + return link.title; + }, + header: () => ( + {localize('com_ui_name')} + ), cell: ({ row }) => { - const { title, shareId } = row.original; + const link = row.original as SharedLinkItem; + const { title, shareId } = link; return (
{title} @@ -166,64 +234,81 @@ export default function SharedLinks() { ); }, meta: { - size: '35%', - mobileSize: '50%', - enableSorting: true, + className: 'min-w-[150px] flex-1', }, + enableSorting: true, }, { accessorKey: 'createdAt', - header: () => {localize('com_ui_date')}, - cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen), - meta: { - size: '10%', - mobileSize: '20%', - enableSorting: true, + accessorFn: (row: Record): unknown => { + const link = row as SharedLinkItem; + return link.createdAt; }, + header: () => ( + {localize('com_ui_date')} + ), + cell: ({ row }) => { + const link = row.original as SharedLinkItem; + return formatDate(link.createdAt?.toString() ?? '', isSmallScreen); + }, + meta: { + className: 'w-32 sm:w-40', + hideOnMobile: true, + }, + enableSorting: true, }, { - accessorKey: 'actions', - header: () => , - meta: { - size: '7%', - mobileSize: '25%', - enableSorting: false, - }, - cell: ({ row }) => ( -
- { - window.open(`/c/${row.original.conversationId}`, '_blank'); - }} - title={localize('com_ui_view_source')} - > - - - } - /> - { - setDeleteRow(row.original); - setIsDeleteOpen(true); - }} - title={localize('com_ui_delete')} - > - - - } - /> -
+ id: 'actions', + accessorFn: (row: Record): unknown => null, + header: () => ( + + {localize('com_assistants_actions')} + ), + cell: ({ row }) => { + const link = row.original as SharedLinkItem; + const { title, conversationId, shareId } = link; + + return ( +
+ { + window.open(`/c/${conversationId}`, '_blank'); + }} + aria-label={localize('com_ui_view_source_conversation', { 0: title })} + > + + + } + /> + { + setDeleteRow(link); + setIsDeleteOpen(true); + }} + aria-label={localize('com_ui_delete_link_title', { 0: title })} + > + + + } + /> +
+ ); + }, + meta: { + className: 'w-24', + }, + enableSorting: false, }, ], [isSmallScreen, localize], @@ -233,40 +318,40 @@ export default function SharedLinks() {
- setIsOpen(true)}> + - + {localize('com_nav_shared_links')} @@ -276,15 +361,13 @@ export default function SharedLinks() { title={localize('com_ui_delete_shared_link')} className="w-11/12 max-w-md" main={ - <> -
-
- -
+
+
+
- +
} selection={{ selectHandler: confirmDelete, diff --git a/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx b/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx index 9cb38775ea..26970dc1c5 100644 --- a/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx +++ b/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useEffect } from 'react'; import { TrashIcon, ArchiveRestore } from 'lucide-react'; import type { ColumnDef, SortingState } from '@tanstack/react-table'; import { @@ -14,6 +14,7 @@ import { Spinner, useToastContext, useMediaQuery, + DataTable, } from '@librechat/client'; import type { ConversationListParams, TConversation } from 'librechat-data-provider'; import { @@ -23,9 +24,8 @@ import { } from '~/data-provider'; import { MinimalIcon } from '~/components/Endpoints'; import { NotificationSeverity } from '~/common'; +import { formatDate, cn } from '~/utils'; import { useLocalize } from '~/hooks'; -import { formatDate } from '~/utils'; -import DataTable from './DataTable'; const DEFAULT_PARAMS = { isArchived: true, @@ -76,9 +76,12 @@ export default function ArchivedChatsTable() { refetchOnMount: false, }); + const [allKnownConversations, setAllKnownConversations] = useState([]); + const handleSearchChange = useCallback((value: string) => { const trimmedValue = value.trim(); setSearchValue(trimmedValue); + setAllKnownConversations([]); setQueryParams((prev) => ({ ...prev, search: trimmedValue, @@ -93,25 +96,31 @@ export default function ArchivedChatsTable() { const coerced = next; const primary = coerced[0]; - setQueryParams((p) => { - const newParams = (() => { - if (primary && isSortKey(primary.id)) { - return { - ...p, - sortBy: primary.id, - sortDirection: primary.desc ? 'desc' : 'asc', - }; - } - return { - ...p, - sortBy: 'createdAt', - sortDirection: 'desc', - }; - })(); + // Seed allKnown with current data before changing params + if (data?.pages) { + const currentFlattened = data.pages.flatMap( + (page) => page?.conversations?.filter(Boolean) ?? [], + ); + setAllKnownConversations(currentFlattened); + } - setTimeout(() => { - refetch(); - }, 0); + setQueryParams((p) => { + let sortBy: SortKey; + let sortDirection: 'asc' | 'desc'; + + if (primary && isSortKey(primary.id)) { + sortBy = primary.id; + sortDirection = primary.desc ? 'desc' : 'asc'; + } else { + sortBy = 'createdAt'; + sortDirection = 'desc'; + } + + const newParams = { + ...p, + sortBy, + sortDirection, + }; return newParams; }); @@ -119,7 +128,7 @@ export default function ArchivedChatsTable() { return coerced; }); }, - [setQueryParams, setSorting, refetch], + [setQueryParams, data?.pages], ); const handleError = useCallback( @@ -133,14 +142,45 @@ export default function ArchivedChatsTable() { [showToast, localize], ); - const allConversations = useMemo(() => { - if (!data?.pages) return []; - return data.pages.flatMap((page) => page?.conversations?.filter(Boolean) ?? []); + useEffect(() => { + if (!data?.pages) return; + + const newFlattened = data.pages.flatMap((page) => page?.conversations?.filter(Boolean) ?? []); + + const toAdd = newFlattened.filter( + (convo: TConversation) => + !allKnownConversations.some((known) => known.conversationId === convo.conversationId), + ); + + if (toAdd.length > 0) { + setAllKnownConversations((prev) => [...prev, ...toAdd]); + } }, [data?.pages]); + const displayData = useMemo(() => { + const primary = sorting[0]; + if (!primary || allKnownConversations.length === 0) return allKnownConversations; + + return [...allKnownConversations].sort((a: TConversation, b: TConversation) => { + let compare: number; + if (primary.id === 'createdAt') { + const aDate = new Date(a.createdAt || 0); + const bDate = new Date(b.createdAt || 0); + compare = aDate.getTime() - bDate.getTime(); + } else if (primary.id === 'title') { + compare = (a.title || '').localeCompare(b.title || ''); + } else { + return 0; + } + return primary.desc ? -compare : compare; + }); + }, [allKnownConversations, sorting]); + const unarchiveMutation = useArchiveConvoMutation({ - onSuccess: async () => { - await refetch(); + onSuccess: (data, variables) => { + const { conversationId } = variables; + setAllKnownConversations((prev) => prev.filter((c) => c.conversationId !== conversationId)); + refetch(); }, onError: () => { showToast({ @@ -151,13 +191,15 @@ export default function ArchivedChatsTable() { }); const deleteMutation = useDeleteConversationMutation({ - onSuccess: async () => { + onSuccess: (data, variables) => { + const { conversationId } = variables; + setAllKnownConversations((prev) => prev.filter((c) => c.conversationId !== conversationId)); showToast({ message: localize('com_ui_archived_conversation_delete_success'), severity: NotificationSeverity.SUCCESS, }); setIsDeleteOpen(false); - await refetch(); + refetch(); }, onError: () => { showToast({ @@ -172,6 +214,9 @@ export default function ArchivedChatsTable() { await fetchNextPage(); }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + const effectiveIsLoading = isLoading && displayData.length === 0; + const effectiveIsFetching = isFetchingNextPage; + const confirmDelete = useCallback(() => { if (!deleteRow?.conversationId) { showToast({ @@ -183,19 +228,37 @@ export default function ArchivedChatsTable() { deleteMutation.mutate({ conversationId: deleteRow.conversationId }); }, [deleteMutation, deleteRow, localize, showToast]); - const columns: TableColumn[] = useMemo( + const handleUnarchive = useCallback( + (conversationId: string) => { + setUnarchivingId(conversationId); + unarchiveMutation.mutate( + { conversationId, isArchived: false }, + { onSettled: () => setUnarchivingId(null) }, + ); + }, + [unarchiveMutation], + ); + + const columns: TableColumn, unknown>[] = useMemo( () => [ { accessorKey: 'title', + accessorFn: (row: Record): unknown => { + const convo = row as TConversation; + return convo.title; + }, header: () => ( - {localize('com_nav_archive_name')} + + {localize('com_nav_archive_name')} + ), cell: ({ row }) => { - const { conversationId, title } = row.original; + const convo = row.original as TConversation; + const { conversationId, title } = convo; return (
): unknown => { + const convo = row as TConversation; + return convo.createdAt; + }, header: () => ( - {localize('com_nav_archive_created_at')} + + {localize('com_nav_archive_created_at')} + ), - cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen), + cell: ({ row }) => { + const convo = row.original as TConversation; + return formatDate(convo.createdAt?.toString() ?? '', isSmallScreen); + }, meta: { - priority: 2, - minWidth: '80px', + className: 'w-32 sm:w-40', + hideOnMobile: true, }, enableSorting: true, }, { id: 'actions', + accessorFn: (row: Record): unknown => null, header: () => ( - + ), cell: ({ row }) => { - const conversation = row.original; - const { title } = conversation; - const isRowUnarchiving = unarchivingId === conversation.conversationId; + const convo = row.original as TConversation; + const { title } = convo; + const isRowUnarchiving = unarchivingId === convo.conversationId; return (
@@ -252,13 +324,9 @@ export default function ArchivedChatsTable() { variant="ghost" className="h-8 w-8 p-0 hover:bg-surface-hover" onClick={() => { - const conversationId = conversation.conversationId; + const conversationId = convo.conversationId; if (!conversationId) return; - setUnarchivingId(conversationId); - unarchiveMutation.mutate( - { conversationId, isArchived: false }, - { onSettled: () => setUnarchivingId(null) }, - ); + handleUnarchive(conversationId); }} disabled={isRowUnarchiving} aria-label={localize('com_ui_unarchive_conversation_title', { 0: title })} @@ -272,9 +340,9 @@ export default function ArchivedChatsTable() { render={ - + {localize('com_nav_archived_chats')} { - const ratio = priority / totalPriority; - const baseWidth = Math.max(50, containerWidth * ratio * 0.8); // 80% of allocated space for visual balance - return `${Math.min(baseWidth, containerWidth * 0.3)}px`; // Cap at 30% of container to prevent overflow -}; - -type TableColumn = ColumnDef & { - accessorKey?: string | number; - meta?: { - size?: string | number; - mobileSize?: string | number; - minWidth?: string | number; - priority?: number; // 1-5 scale, higher = more width priority - }; -}; - -// 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: Parameters) => { - const currentTime = Date.now(); - - if (currentTime - lastExecTime > delay) { - fn(...args); - lastExecTime = currentTime; - } else { - if (timeoutId) clearTimeout(timeoutId); - timeoutId = setTimeout( - () => { - fn(...args); - lastExecTime = Date.now(); - }, - delay - (currentTime - lastExecTime), - ); - } - }) as T; -}; - -// Deep comparison utility for objects -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') { - // Handle arrays - if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (!deepEqual(a[i], b[i])) return false; - } - return true; - } - - // Handle non-array objects - if (!Array.isArray(a) && !Array.isArray(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 as Record)[key], (b as Record)[key]) - ) { - return false; - } - } - return true; - } - - return false; - } - - return false; -}; - -const SelectionCheckbox = memo( - ({ - checked, - onChange, - ariaLabel, - }: { - checked: boolean; - onChange: (value: boolean) => void; - ariaLabel: string; - }) => ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onChange(!checked); - } - e.stopPropagation(); - }} - className={`flex h-full items-center justify-center`} - style={{ width: DATA_TABLE_CONSTANTS.CHECKBOX_WIDTH }} - onClick={(e) => { - e.stopPropagation(); - onChange(!checked); - }} - > - -
- ), -); - -SelectionCheckbox.displayName = 'SelectionCheckbox'; - -/** - * Dynamic column width calculator using priority-based distribution - * Computes relative widths based on semantic priorities and container size - */ -const useDynamicColumnWidths = ( - columns: TableColumn[], - containerRef: React.RefObject, - isSmallScreen: boolean, -) => { - // Stabilize columns array to prevent infinite re-renders - const stableColumns = useMemo(() => { - return columns.map((c) => ({ - id: c.id, - accessorKey: c.accessorKey, - meta: c.meta, - })); - }, [columns]); - - return useMemo(() => { - // Get container width directly without state to prevent re-render loops - const containerWidth = containerRef.current?.clientWidth || 0; - - if (containerWidth === 0) { - return {}; - } - - // Calculate total priority - const totalPriority = stableColumns.reduce((sum, column) => { - const explicitPriority = column.meta?.priority; - const priority = explicitPriority ?? 1; // uniform default - return sum + priority; - }, 0); - - if (totalPriority === 0) { - return {}; - } - - const keyFor = (column: (typeof stableColumns)[0]): string => - String(column.id ?? column.accessorKey ?? ''); - - const widths: number[] = []; - const columnDetails: Array<{ - key: string; - id: string; - finalWidth: number; - source: string; - metaSize: string | number | undefined; - }> = []; - const result = stableColumns.reduce( - (acc, column) => { - const key = keyFor(column); - if (!key) { - return acc; - } - - const explicitPriority = column.meta?.priority; - const priority = explicitPriority ?? 1; // uniform default - - // Check for fixed size first - let finalWidth: number; - let source = 'calculated'; - const metaSize = column.meta?.size; - - if (metaSize && typeof metaSize === 'string' && metaSize.includes('px')) { - finalWidth = (parseFloat(metaSize) / containerWidth) * 100; // Convert px to % - source = 'fixed px'; - } else if (isSmallScreen && column.meta?.mobileSize) { - const mobileSize = column.meta.mobileSize; - if (typeof mobileSize === 'string' && mobileSize.includes('px')) { - finalWidth = (parseFloat(mobileSize) / containerWidth) * 100; - source = 'mobile px'; - } else { - finalWidth = parseFloat(mobileSize as string) || (priority / totalPriority) * 100; - source = 'mobile %'; - } - } else { - // Compute relative width as percentage - const ratio = priority / totalPriority; - finalWidth = Math.max(5, Math.min(60, ratio * 100)); // 5-60% range - source = 'calculated %'; - } - - // Determine minWidth - prefer explicit, fallback to standard - let minWidth: string | number | undefined; - if (column.meta?.minWidth) { - minWidth = column.meta.minWidth; - } else { - minWidth = 'min-content'; - } - - widths.push(finalWidth); - columnDetails.push({ key, id: column.id || '', finalWidth, source, metaSize }); - - acc[key] = { - width: `${finalWidth}%`, - minWidth, - maxWidth: column.meta?.size || 'none', - flex: `0 0 ${finalWidth}%`, // For flex fallback - // Ensure content doesn't break layout - overflow: 'hidden', - whiteSpace: 'nowrap', - } as React.CSSProperties; - - return acc; - }, - {} as Record, - ); - - // Diagnostic log: Check total width allocation - const totalWidth = widths.reduce((sum, w) => sum + w, 0); - - // Only log in development and when there are issues - if (process.env.NODE_ENV === 'development' && totalWidth > 100) { - console.warn( - '[DataTable Debug] WARNING: Column widths exceed 100% - potential horizontal overflow!', - { containerWidth, columnDetails, totalWidth }, - ); - } - - return result; - }, [stableColumns, isSmallScreen, containerRef]); -}; - -// Legacy support hook - wraps dynamic widths with backward compatibility -const useColumnStyles = ( - columns: TableColumn[], - isSmallScreen: boolean, - containerRef: React.RefObject, -): ReturnType => { - return useDynamicColumnWidths(columns, containerRef, isSmallScreen); -}; - -const TableRowComponent = ({ - row, - columnStyles, - index, - virtualIndex, -}: { - row: Row; - columnStyles: Record; - index: number; - virtualIndex?: number; -}) => { - const handleSelection = useCallback( - (value: boolean) => { - startTransition(() => { - row.toggleSelected(value); - }); - }, - [row], - ); - - return ( - - {row.getVisibleCells().map((cell) => { - if (cell.column.id === 'select') { - return ( - - - - ); - } - - return ( - -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
-
- ); - })} -
- ); -}; - -const MemoizedTableRow = memo(TableRowComponent) as typeof TableRowComponent; - -const DeleteButton = memo( - ({ - onDelete, - isDeleting, - disabled, - isSmallScreen, - }: { - onDelete?: () => Promise; - isDeleting: boolean; - disabled: boolean; - isSmallScreen: boolean; - }) => { - if (!onDelete) return null; - - return ( - - ); - }, -); - -DeleteButton.displayName = 'DeleteButton'; - -/** - * Dynamic skeleton rows that match final column proportions to prevent CLS - * Uses same priority-based width calculation as real content - */ -const SkeletonRows = memo(function ({ - count = 10, - columns, - columnStyles, - containerRef, -}: { - count?: number; - columns: TableColumn[]; - columnStyles: Record; - containerRef: React.RefObject; -}) { - if (!columns.length || !containerRef.current) { - return null; - } - - const containerWidth = containerRef.current.clientWidth; - - // Calculate total priority for skeleton width distribution - const totalPriority = columns.reduce((sum, column) => { - const explicitPriority = column.meta?.priority; - const priority = explicitPriority ?? 1; // uniform default - return sum + priority; - }, 0); - - if (totalPriority === 0) { - return null; - } - - // Helper function to get column key - same as in useDynamicColumnWidths - const keyFor = (column: TableColumn): string => - String(column.id ?? column.accessorKey ?? ''); - - return ( - <> - {Array.from({ length: count }, (_, index) => ( - - {columns.map((column, colIndex) => { - const columnKey = keyFor(column); - const baseStyle = columnStyles[columnKey] || {}; - const explicitPriority = column.meta?.priority; - const priority = explicitPriority ?? 1; // uniform default - // Use priority-based width for all skeleton cells - const skeletonWidth = getSkeletonWidth(priority, containerWidth, totalPriority); - const skeletonStyle: React.CSSProperties = { - ...baseStyle, - width: skeletonWidth, - minWidth: skeletonWidth, - height: '48px', - }; - - return ( - - - - ); - })} - - ))} - - ); -}); - -SkeletonRows.displayName = 'SkeletonRows'; - -/** - * Comprehensive configuration object for DataTable features. - * Consolidates all individual props into nested sections for better maintainability and type safety. - * - * @example - * const config = { - * selection: { enableRowSelection: true, showCheckboxes: false }, - * search: { enableSearch: true, debounce: 500, filterColumn: 'name' }, - * skeleton: { count: 5 }, - * virtualization: { overscan: 15 }, - * pinning: { enableColumnPinning: true } - * }; - * - * Defaults: enableRowSelection: true, showCheckboxes: true, enableSearch: true, - * skeleton.count: 10, virtualization.overscan: 10, search.debounce: 300, - * pinning.enableColumnPinning: false - */ -interface DataTableConfig { - /** - * Selection configuration for row selection features. - * Controls checkbox visibility and row selection behavior. - */ - selection?: { - /** - * Enable row selection functionality across the table. - * When true, rows can be selected via checkboxes or keyboard navigation. - * @default true - */ - enableRowSelection?: boolean; - - /** - * Show checkboxes in the first column for row selection. - * Requires enableRowSelection to be true. Affects both header (select all) and row-level checkboxes. - * @default true - */ - showCheckboxes?: boolean; - }; - - /** - * Search configuration for filtering functionality. - * Enables search input with debounced filtering on specified column. - */ - search?: { - /** - * Enable search input and filtering capabilities. - * When true, displays AnimatedSearchInput above the table for filtering data. - * Requires filterColumn to be specified for column-specific filtering. - * @default true - */ - enableSearch?: boolean; - - /** - * Debounce delay for search input in milliseconds. - * Controls how long to wait after user stops typing before filtering the table. - * Higher values reduce re-renders but may feel less responsive. - * @default 300 - */ - debounce?: number; - - /** - * Column key to filter search results on. - * Must match a column accessorKey. Search will filter rows where this column's value contains the search term. - * Required when enableSearch is true. - * @example 'name', 'email', 'id' - */ - filterColumn?: string; - }; - - /** - * Skeleton configuration for loading states. - * Controls the number of skeleton rows shown while data is loading. - */ - skeleton?: { - /** - * Number of skeleton rows to display during initial loading or data fetching. - * Skeleton rows provide visual feedback and maintain table layout consistency. - * @default 10 - */ - count?: number; - }; - - /** - * Virtualization configuration for scroll performance. - * Controls virtual scrolling behavior for large datasets. - */ - virtualization?: { - /** - * Number of additional rows to render outside the visible viewport. - * Higher values improve scroll smoothness but increase memory usage. - * Recommended range: 5-20 depending on row complexity and dataset size. - * @default 10 - */ - 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) { - const [debounced, setDebounced] = useState(value); - useEffect(() => { - const id = setTimeout(() => setDebounced(value), delay); - return () => clearTimeout(id); - }, [value, delay]); - return debounced; -} - -// Optimized row selection state with deep comparison -const useOptimizedRowSelection = (initialSelection: Record = {}) => { - const [selection, setSelection] = useState(initialSelection); - - const optimizedSelection = useMemo(() => { - return Object.keys(selection).length > 0 ? selection : {}; - }, [selection]); - - const setOptimizedSelection = useCallback( - ( - newSelection: - | Record - | ((prev: Record) => Record), - ) => { - setSelection((prev) => { - const next = typeof newSelection === 'function' ? newSelection(prev) : newSelection; - return deepEqual(prev, next) ? prev : next; - }); - }, - [], - ); - - return [optimizedSelection, setOptimizedSelection] as const; -}; - -interface DataTableProps { - columns: TableColumn[]; - data: TData[]; - className?: string; - isLoading?: boolean; - isFetching?: boolean; - /** - * Configuration object consolidating all feature props. - * See DataTableConfig for detailed structure and defaults. - */ - config?: DataTableConfig; - onDelete?: (selectedRows: TData[]) => Promise; - filterValue?: string; - onFilterChange?: (value: string) => void; - defaultSort?: SortingState; - isFetchingNextPage?: boolean; - hasNextPage?: boolean; - fetchNextPage?: () => Promise; - onError?: (error: Error) => void; - onReset?: () => void; - sorting?: SortingState; - onSortingChange?: (updater: SortingState | ((old: SortingState) => SortingState)) => void; -} - -/** - * DataTable renders a virtualized, searchable table with selection and infinite loading. - * Optimized for performance with memoization, virtual scrolling, and efficient state management. - */ -export default function DataTable({ - columns, - data, - className = '', - isLoading = false, - isFetching = false, - config, - onDelete, - filterValue = '', - onFilterChange, - defaultSort = [], - isFetchingNextPage = false, - hasNextPage = false, - fetchNextPage, - onError, - onReset, - sorting, - onSortingChange, -}: DataTableProps) { - const renderCountRef = useRef(0); - // const prevPropsRef = useRef(null); // Disabled with debug logging - - const tableConfig = useMemo(() => { - return { - enableRowSelection: config?.selection?.enableRowSelection ?? true, - showCheckboxes: config?.selection?.showCheckboxes ?? true, - enableSearch: config?.search?.enableSearch ?? true, - filterColumn: config?.search?.filterColumn, - skeletonCount: config?.skeleton?.count ?? 10, - overscan: config?.virtualization?.overscan ?? DATA_TABLE_CONSTANTS.OVERS_CAN, - debounceDelay: config?.search?.debounce ?? DATA_TABLE_CONSTANTS.SEARCH_DEBOUNCE_MS, - }; - }, [config]); - - const { - enableRowSelection, - showCheckboxes, - enableSearch, - // filterColumn, // Disabled with debug logging - skeletonCount, - overscan, - debounceDelay, - } = tableConfig; - - const localize = useLocalize(); - const isSmallScreen = useMediaQuery('(max-width: 768px)'); - const tableContainerRef = useRef(null); - - const [columnVisibility, setColumnVisibility] = useState({}); - const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection(); - const [rawTerm, setRawTerm] = useState(filterValue ?? ''); - const [isImmediateSearch, setIsImmediateSearch] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const [error, setError] = useState(null); - const [isSearching, setIsSearching] = useState(false); - - const debouncedTerm = useDebounced(rawTerm, debounceDelay); - - const isTransitioning = isFetching || isImmediateSearch; - - // Track when we're waiting for search results - const isWaitingForSearchResults = isSearching && isFetching; - const isFirstLoad = isLoading && data.length === 0; - const isRefreshing = isFetching && !isFirstLoad && !isFetchingNextPage && !isSearching; - - // Show skeletons during initial load, refresh, search, or transitioning - // Exclude isFetchingNextPage to prevent skeletons during infinite scroll - const showSkeletons = - isFirstLoad || - isRefreshing || - isWaitingForSearchResults || - (isTransitioning && !isFetchingNextPage) || // Don't show skeletons during infinite scroll - rawTerm !== debouncedTerm; // Show during typing - - // External sorting support: use provided sorting state, fall back to internal - 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); - - // Sync internal sorting with defaultSort changes - useEffect(() => { - if (!sorting) setInternalSorting(defaultSort); - }, [defaultSort, sorting]); - - // Keep search input in sync with external filterValue - useEffect(() => { - setRawTerm(filterValue ?? ''); - }, [filterValue]); - - // Sorting handler: call external callback if provided, otherwise use internal state - const handleSortingChangeInternal = useCallback( - (updater: SortingState | ((old: SortingState) => SortingState)) => { - startTransition(() => { - const newSorting = typeof updater === 'function' ? updater(finalSorting) : updater; - setInternalSorting(newSorting); - onSortingChange?.(newSorting); - }); - }, - [finalSorting, onSortingChange], - ); - const handleSortingChange = onSortingChange ?? handleSortingChangeInternal; - - renderCountRef.current += 1; - - const sanitizeError = useCallback((err: Error): string => { - const message = err.message; - if (message?.includes('auth') || message?.includes('token')) { - return 'Authentication failed. Please log in again.'; - } - return process.env.NODE_ENV === 'development' - ? message - : 'An error occurred. Please try again.'; - }, []); - - const tableColumns = useMemo(() => { - if (!enableRowSelection || !showCheckboxes) return columns; - - const selectColumn: TableColumn = { - id: 'select', - header: ({ table }: { table: TTable }) => ( -
- table.toggleAllPageRowsSelected(Boolean(value))} - aria-label="Select all rows" - /> -
- ), - cell: ({ row }) => ( - row.toggleSelected(value)} - ariaLabel={`Select row ${row.index + 1}`} - /> - ), - meta: { - size: DATA_TABLE_CONSTANTS.SELECT_COLUMN_SIZE, - minWidth: DATA_TABLE_CONSTANTS.SELECT_COLUMN_MIN_WIDTH, - }, - }; - - return [selectColumn, ...columns]; - }, [columns, enableRowSelection, showCheckboxes]); - - // Dynamic column styles with priority-based sizing - const columnStyles = useColumnStyles( - tableColumns as TableColumn[], - isSmallScreen, - tableContainerRef, - ); - - // Set CSS variables for column sizing with hash optimization - prevent re-render loops - const columnSizesHashRef = useRef(''); - const prevTableColumnsRef = useRef([]); - - useLayoutEffect(() => { - if (typeof window === 'undefined' || !tableContainerRef.current) return; - - // Only update if columns or styles actually changed - const columnsChanged = prevTableColumnsRef.current.length !== tableColumns.length; - - // Calculate hash of column sizes to avoid unnecessary DOM writes - const sizesHash = tableColumns - .map((col, index) => `${index}:${columnStyles[col.id!]?.width || 'auto'}`) - .join('|'); - - if (columnsChanged || sizesHash !== columnSizesHashRef.current) { - columnSizesHashRef.current = sizesHash; - prevTableColumnsRef.current = tableColumns; - - // Batch DOM updates to prevent layout thrashing - requestAnimationFrame(() => { - 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}`); - } - }); - } - }); - } - }, [tableColumns, columnStyles]); - - // Memoized row data with stable references - deep comparison to prevent unnecessary re-renders - const memoizedRowData = useMemo(() => { - return data.map((item, index) => ({ - ...item, - _index: index, - _id: (item as Record)?.id || index, - })); - }, [data]); - - // React Table instance - const tableData = memoizedRowData; - - const table = useReactTable({ - data: tableData, - columns: tableColumns, - getCoreRowModel: getCoreRowModel(), - enableRowSelection, - enableMultiRowSelection: true, - manualSorting: true, // Use manual sorting for server-side sorting - manualFiltering: true, - state: { - sorting: finalSorting, - columnVisibility, - rowSelection: optimizedRowSelection, - }, - onSortingChange: handleSortingChange, - onColumnVisibilityChange: setColumnVisibility, - onRowSelectionChange: setOptimizedRowSelection, - }); - - const { rows } = table.getRowModel(); - - // Fixed: Simplify header groups - React Table already memoizes internally - const headerGroups = table.getHeaderGroups(); - - // Virtual scrolling setup with optimized height measurement - const measuredHeightsRef = useRef([]); - const measureElementCallback = useCallback((el: Element | null) => { - if (!el) return DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE; - - const height = el.getBoundingClientRect().height; - - // Memory management for measured heights - only update if significantly different - const lastHeight = measuredHeightsRef.current[measuredHeightsRef.current.length - 1]; - if (!lastHeight || Math.abs(height - lastHeight) > 1) { - 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 height; - }, []); - - const rowVirtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: useCallback(() => { - // SSR safety: return null during SSR - if (typeof window === 'undefined') return null; - return tableContainerRef.current; - }, []), - estimateSize: useCallback(() => { - const heights = measuredHeightsRef.current; - if (heights.length > 0) { - const avg = heights.reduce((a, b) => a + b, 0) / heights.length; - return Math.max(avg, DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE); - } - return DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE; - }, []), - overscan, - // Fixed: Optimize measureElement to avoid duplicate getBoundingClientRect calls - measureElement: measureElementCallback, - }); - - 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; - - // Virtual rows with stable keys for better performance - const virtualRowsWithStableKeys = useMemo( - () => - virtualRows.map((vRow) => ({ - ...vRow, - stableKey: `${vRow.index}-${rows[vRow.index]?.id || vRow.index}`, - })), - [virtualRows, rows], - ); - - // Store scroll position before fetching to prevent jumping - const scrollPositionRef = useRef<{ top: number; timestamp: number } | null>(null); - const isRestoringScrollRef = useRef(false); - - // Fixed: Infinite scrolling with scroll position preservation - const handleScrollInternal = useCallback(async () => { - if (!mountedRef.current || !tableContainerRef.current) return; - - // Early return if conditions not met - if (!hasNextPage || isFetchingNextPage || isRestoringScrollRef.current) return; - - const scrollElement = tableContainerRef.current; - const { scrollTop, scrollHeight, clientHeight } = scrollElement; - - // More precise threshold calculation - const scrollThreshold = - scrollHeight - clientHeight * DATA_TABLE_CONSTANTS.INFINITE_SCROLL_THRESHOLD; - const nearEnd = scrollTop >= scrollThreshold; - - if (nearEnd) { - try { - // Store current scroll position before fetching - scrollPositionRef.current = { - top: scrollTop, - timestamp: Date.now(), - }; - - await fetchNextPage?.(); - } catch (err) { - // Clear stored position on error - scrollPositionRef.current = null; - const rawError = err instanceof Error ? err : new Error('Failed to fetch next page'); - const sanitizedMessage = sanitizeError(rawError); - const sanitizedError = new Error(sanitizedMessage); - setError(sanitizedError); - onError?.(sanitizedError); - } - } - }, [fetchNextPage, onError, sanitizeError, hasNextPage, isFetchingNextPage]); - - const throttledHandleScroll = useMemo( - () => throttle(handleScrollInternal, DATA_TABLE_CONSTANTS.SCROLL_THROTTLE_MS), - [handleScrollInternal], - ); - - // Scroll position restoration effect - prevents jumping when new data is added - useEffect(() => { - if (!isFetchingNextPage && scrollPositionRef.current && tableContainerRef.current) { - const { top, timestamp } = scrollPositionRef.current; - const isRecent = Date.now() - timestamp < 1000; // Only restore if within 1 second - - if (isRecent) { - isRestoringScrollRef.current = true; - - // Use requestAnimationFrame to ensure DOM has updated - requestAnimationFrame(() => { - if (tableContainerRef.current && mountedRef.current) { - const scrollElement = tableContainerRef.current; - const maxScroll = scrollElement.scrollHeight - scrollElement.clientHeight; - const targetScroll = Math.min(top, maxScroll); - - scrollElement.scrollTo({ - top: targetScroll, - behavior: 'auto', // Instant restoration - }); - - // Clear restoration flag after a brief delay - setTimeout(() => { - isRestoringScrollRef.current = false; - }, 100); - } - }); - } - - // Clear stored position - scrollPositionRef.current = null; - } - }, [isFetchingNextPage, data.length]); // Trigger when fetch completes or data changes - - // Always attach scroll listener with optimized event handling to reduce GC pressure - useEffect(() => { - const scrollElement = tableContainerRef.current; - if (!scrollElement) return; - - // Pre-bind the handler to avoid creating new functions on each scroll - const scrollHandler = throttledHandleScroll; - - // Use passive listener for better performance - const options = { passive: true }; - scrollElement.addEventListener('scroll', scrollHandler, options); - - return () => { - scrollElement.removeEventListener('scroll', scrollHandler); - }; - }, [throttledHandleScroll]); - - // Resize observer for virtualizer revalidation - heavily throttled to prevent rapid re-renders - const handleWindowResize = useCallback(() => { - // Debounce resize to prevent rapid re-renders - if (resizeTimeoutRef.current) { - clearTimeout(resizeTimeoutRef.current); - } - resizeTimeoutRef.current = setTimeout(() => { - if (mountedRef.current) { - rowVirtualizer.measure(); - } - }, 250); // Increased delay significantly - }, [rowVirtualizer]); - - const resizeTimeoutRef = useRef | null>(null); - - // Optimized resize observer with reduced layout thrashing - useEffect(() => { - if (typeof window === 'undefined') return; - - const scrollElement = tableContainerRef.current; - if (!scrollElement) return; - - // Increased throttling to reduce excessive re-renders and GC pressure - let resizeTimeout: ReturnType | null = null; - let lastResizeTime = 0; - const MIN_RESIZE_INTERVAL = 500; // Increased from 250ms to reduce GC pressure - - const resizeObserver = new ResizeObserver(() => { - const now = Date.now(); - if (now - lastResizeTime < MIN_RESIZE_INTERVAL) return; - - lastResizeTime = now; - if (resizeTimeout) clearTimeout(resizeTimeout); - - resizeTimeout = setTimeout(() => { - if (mountedRef.current && !isRestoringScrollRef.current) { - // Only measure if not currently restoring scroll position - rowVirtualizer.measure(); - } - }, MIN_RESIZE_INTERVAL); - }); - - resizeObserver.observe(scrollElement); - - // Optimized window resize handler with increased debouncing - const windowResizeHandler = () => { - if (resizeTimeoutRef.current) { - clearTimeout(resizeTimeoutRef.current); - } - resizeTimeoutRef.current = setTimeout(() => { - if (mountedRef.current && !isRestoringScrollRef.current) { - rowVirtualizer.measure(); - } - }, MIN_RESIZE_INTERVAL); - }; - - window.addEventListener('resize', windowResizeHandler, { passive: true }); - - return () => { - if (resizeTimeout) clearTimeout(resizeTimeout); - if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current); - resizeObserver.disconnect(); - window.removeEventListener('resize', windowResizeHandler); - }; - }, [rowVirtualizer, handleWindowResize]); - - useLayoutEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - - // Optimized dynamic measurement - prevent infinite loops with better guards and reduced frequency - const lastMeasurementRef = useRef<{ - dataLength: number; - isSmallScreen: boolean; - virtualRowsLength: number; - isTransitioning: boolean; - timestamp: number; - }>({ - dataLength: 0, - isSmallScreen: false, - virtualRowsLength: 0, - isTransitioning: false, - timestamp: 0, - }); - - useLayoutEffect(() => { - if (typeof window === 'undefined' || isTransitioning || isRestoringScrollRef.current) return; - - const current = { - dataLength: data.length, - isSmallScreen, - virtualRowsLength: virtualRowsWithStableKeys.length, - isTransitioning, - timestamp: Date.now(), - }; - - const timeSinceLastMeasurement = current.timestamp - lastMeasurementRef.current.timestamp; - - // Only measure if something meaningful changed AND enough time has passed - const hasChanged = - current.dataLength !== lastMeasurementRef.current.dataLength || - current.isSmallScreen !== lastMeasurementRef.current.isSmallScreen || - current.virtualRowsLength !== lastMeasurementRef.current.virtualRowsLength; - - // Increased throttle to 200ms to reduce GC pressure and improve performance - if (!hasChanged || timeSinceLastMeasurement < 200) return; - - lastMeasurementRef.current = current; - - if (mountedRef.current && tableContainerRef.current && virtualRows.length > 0) { - // Increased debounce delay to reduce re-renders - const measurementTimeout = setTimeout(() => { - if (mountedRef.current && !isRestoringScrollRef.current) { - rowVirtualizer.measure(); - } - }, 100); - - return () => clearTimeout(measurementTimeout); - } - }, [ - data.length, - rowVirtualizer, - isSmallScreen, - virtualRowsWithStableKeys.length, - virtualRows.length, - isTransitioning, - ]); - - // Search effect with optimized state updates - useEffect(() => { - if (rawTerm !== filterValue) { - setIsImmediateSearch(true); - } - }, [rawTerm, filterValue]); - - useEffect(() => { - if (debouncedTerm !== filterValue) { - setIsSearching(true); - startTransition(() => { - onFilterChange?.(debouncedTerm); - }); - } - }, [debouncedTerm, onFilterChange, filterValue]); - - useEffect(() => { - if (!isFetching && isImmediateSearch) { - setIsImmediateSearch(false); - } - }, [isFetching, isImmediateSearch]); - - useEffect(() => { - if (!isFetching && isSearching) { - setIsSearching(false); - } - }, [isFetching, isSearching]); - - // Optimized delete handler with batch operations - const handleDelete = useCallback(async () => { - if (!onDelete || isDeleting) return; - - setIsDeleting(true); - setError(null); - - try { - // Use getSelectedRowModel instead of getFilteredSelectedRowModel - const selectedRowsLength = table.getSelectedRowModel().rows.length; - let selectedRows = table.getSelectedRowModel().rows.map((r) => r.original); - - // Validation - if (selectedRows.length === 0) { - setIsDeleting(false); - return; - } - - // Filter out non-object entries - selectedRows = selectedRows.filter( - (row): row is TData => typeof row === 'object' && row !== null, - ); - - // TODO: Remove this - only for development - if (selectedRows.length !== selectedRowsLength && true) { - console.warn('DataTable: Invalid row data detected and filtered out during deletion.'); - } - - await onDelete(selectedRows); - - // Batch state updates - startTransition(() => { - table.resetRowSelection(); - setOptimizedRowSelection({}); - }); - } catch (err) { - const rawError = err instanceof Error ? err : new Error('Failed to delete items'); - const sanitizedMessage = sanitizeError(rawError); - const sanitizedError = new Error(sanitizedMessage); - setError(sanitizedError); - onError?.(sanitizedError); - } finally { - setIsDeleting(false); - } - }, [onDelete, table, isDeleting, onError, sanitizeError, setOptimizedRowSelection]); - - // Reset handler for error boundary retry - const handleBoundaryReset = useCallback(() => { - setError(null); - onReset?.(); - // Re-measure virtualizer after reset - if (tableContainerRef.current && rowVirtualizer) { - rowVirtualizer.measure(); - } - }, [onReset, rowVirtualizer]); - - const selectedCount = useMemo(() => { - const selection = optimizedRowSelection; - return Object.keys(selection).length; - }, [optimizedRowSelection]); - - const shouldShowSearch = useMemo( - () => enableSearch && !!onFilterChange, - [enableSearch, onFilterChange], - ); - - return ( -
- {/* Accessible live region for loading announcements */} -
- {(() => { - if (isSearching) return 'Searching...'; - if (isFetchingNextPage) return 'Loading more rows'; - if (hasNextPage) return 'More rows available'; - return 'All rows loaded'; - })()} -
- - {/* Error display - kept outside boundary for non-rendering errors */} - {error && ( -
- {sanitizeError(error)} - -
- )} - - {/* Isolated Toolbar - Outside scroll container */} -
-
- {enableRowSelection && showCheckboxes && ( - - )} - - {shouldShowSearch && ( -
- { - startTransition(() => setRawTerm(e.target.value)); - }} - isSearching={isWaitingForSearchResults} - placeholder="Search..." - aria-label="Search table data" - /> -
- )} - - {selectedCount > 0 && ( -
{`${selectedCount} selected`}
- )} -
-
- - {/* Error boundary wraps the scrollable table container */} - -
{ - if (isFetchingNextPage) { - e.preventDefault(); - return; - } - if (isTransitioning) { - e.deltaY *= 0.5; - } - }} - role="grid" - aria-label="Data grid" - aria-rowcount={data.length} - aria-busy={isLoading || isFetching || isFetchingNextPage} - > -
- - {/* Sticky Table Header - Fixed z-index and positioning */} - - {headerGroups.map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const sortDir = header.column.getIsSorted(); - const canSort = header.column.getCanSort(); - - let ariaSort: 'ascending' | 'descending' | 'none' | undefined; - if (!canSort) { - ariaSort = undefined; - } else if (sortDir === 'asc') { - ariaSort = 'ascending'; - } else if (sortDir === 'desc') { - ariaSort = 'descending'; - } else { - ariaSort = 'none'; - } - - return ( - { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - header.column.toggleSorting(); - } - } - : undefined - } - className={cn( - 'group relative box-border whitespace-nowrap bg-surface-secondary px-2 py-3 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], - backgroundColor: 'var(--surface-secondary)', - position: 'sticky', - top: 0, - boxSizing: 'border-box', - maxWidth: 'none', - }} - role="columnheader" - scope="col" - tabIndex={canSort ? 0 : -1} - aria-sort={ariaSort} - > -
- - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - {canSort && ( - - {!sortDir && ( - - )} - {sortDir === 'asc' && } - {sortDir === 'desc' && ( - - )} - - )} -
-
- ); - })} -
- ))} -
- - - {paddingTop > 0 && ( - - - )} - - {showSkeletons ? ( - []} - columnStyles={columnStyles} - containerRef={tableContainerRef} - /> - ) : ( - virtualRowsWithStableKeys.map((virtualRow) => { - const row = rows[virtualRow.index]; - return ( - - ); - }) - )} - - {data.length === 0 && !isLoading && ( - - - {debouncedTerm ? localize('com_ui_no_results_found') : 'No data available'} - - - )} - - {paddingBottom > 0 && ( - - - )} - - {/* Loading indicator for infinite scroll */} - {(isFetchingNextPage || hasNextPage) && !isLoading && ( - - -
- {isFetchingNextPage ? ( - - ) : ( - hasNextPage &&
- )} -
- - - )} - -
-
-
-
-
-
-
- ); -} - -// interface PrevProps { -// defaultSort: SortingState; -// dataLength: number; -// columnsLength: number; -// filterValue: string; -// sortingLength?: number; -// } - -// Constants extracted for maintainability -export const DATA_TABLE_CONSTANTS = { - // Selection and checkbox dimensions - CHECKBOX_WIDTH: '30px' as const, - SELECT_COLUMN_SIZE: '50px' as const, - SELECT_COLUMN_MIN_WIDTH: '50px' as const, - DELETE_BUTTON_MIN_WIDTH: '40px' as const, - - // Animation and timing - ANIMATION_DELAY_BASE: 15 as const, // ms per row - ANIMATION_DELAY_MAX: 300 as const, // ms cap - SEARCH_DEBOUNCE_MS: 300 as const, - ROW_TRANSITION_DURATION: '200ms' as const, - - // Virtual scrolling and sizing - OVERS_CAN: 10 as const, - ROW_HEIGHT_ESTIMATE: 50 as const, // px fallback - INFINITE_SCROLL_THRESHOLD: 2.5 as const, // multiplier of clientHeight - increased for earlier trigger - SCROLL_THROTTLE_MS: 100 as const, // throttle scroll events - - // Skeleton and layout - SKELETON_OFFSET: 150 as const, // px added to base widths - SEARCH_INPUT_MIN_WIDTH: '200px' as const, - TABLE_MIN_WIDTH: '300px' as const, - LOADING_INDICATOR_SIZE: 4 as const, // rem for spinner - TRASH_ICON_SIZE: 3.5 as const, // rem base, 4 for sm - - // Memory management - MAX_MEASURED_HEIGHTS: 100 as const, - MEASURED_HEIGHTS_TRIM: 50 as const, -} as const; - -/** - * Dynamic skeleton width calculator based on priorities - * Returns pixel value for skeleton sizing that matches final content proportions - */ -const getSkeletonWidth = ( - priority: number, - containerWidth: number, - totalPriority: number, -): string => { - const ratio = priority / totalPriority; - const baseWidth = Math.max(50, containerWidth * ratio * 0.8); // 80% of allocated space for visual balance - return `${Math.min(baseWidth, containerWidth * 0.3)}px`; // Cap at 30% of container to prevent overflow -}; - -type TableColumn = ColumnDef & { - accessorKey?: string | number; - meta?: { - size?: string | number; - mobileSize?: string | number; - minWidth?: string | number; - priority?: number; // 1-5 scale, higher = more width priority - }; -}; - -// 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: Parameters) => { - const currentTime = Date.now(); - - if (currentTime - lastExecTime > delay) { - fn(...args); - lastExecTime = currentTime; - } else { - if (timeoutId) clearTimeout(timeoutId); - timeoutId = setTimeout( - () => { - fn(...args); - lastExecTime = Date.now(); - }, - delay - (currentTime - lastExecTime), - ); - } - }) as T; -}; - -// Deep comparison utility for objects -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') { - // Handle arrays - if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (!deepEqual(a[i], b[i])) return false; - } - return true; - } - - // Handle non-array objects - if (!Array.isArray(a) && !Array.isArray(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 as Record)[key], (b as Record)[key]) - ) { - return false; - } - } - return true; - } - - return false; - } - - return false; -}; - -const SelectionCheckbox = memo( - ({ - checked, - onChange, - ariaLabel, - }: { - checked: boolean; - onChange: (value: boolean) => void; - ariaLabel: string; - }) => ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onChange(!checked); - } - e.stopPropagation(); - }} - className={`flex h-full items-center justify-center`} - style={{ width: DATA_TABLE_CONSTANTS.CHECKBOX_WIDTH }} - onClick={(e) => { - e.stopPropagation(); - onChange(!checked); - }} - > - -
- ), -); - -SelectionCheckbox.displayName = 'SelectionCheckbox'; - -/** - * Dynamic column width calculator using priority-based distribution - * Computes relative widths based on semantic priorities and container size - */ -const useDynamicColumnWidths = ( - columns: TableColumn[], - containerRef: React.RefObject, - isSmallScreen: boolean, -) => { - // Stabilize columns array to prevent infinite re-renders - const stableColumns = useMemo(() => { - return columns.map((c) => ({ - id: c.id, - accessorKey: c.accessorKey, - meta: c.meta, - })); - }, [columns]); - - return useMemo(() => { - // Get container width directly without state to prevent re-render loops - const containerWidth = containerRef.current?.clientWidth || 0; - - if (containerWidth === 0) { - return {}; - } - - // Calculate total priority - const totalPriority = stableColumns.reduce((sum, column) => { - const explicitPriority = column.meta?.priority; - const priority = explicitPriority ?? 1; // uniform default - return sum + priority; - }, 0); - - if (totalPriority === 0) { - return {}; - } - - const keyFor = (column: (typeof stableColumns)[0]): string => - String(column.id ?? column.accessorKey ?? ''); - - const widths: number[] = []; - const columnDetails: Array<{ - key: string; - id: string; - finalWidth: number; - source: string; - metaSize: string | number | undefined; - }> = []; - const result = stableColumns.reduce( - (acc, column) => { - const key = keyFor(column); - if (!key) { - return acc; - } - - const explicitPriority = column.meta?.priority; - const priority = explicitPriority ?? 1; // uniform default - - // Check for fixed size first - let finalWidth: number; - let source = 'calculated'; - const metaSize = column.meta?.size; - - if (metaSize && typeof metaSize === 'string' && metaSize.includes('px')) { - finalWidth = (parseFloat(metaSize) / containerWidth) * 100; // Convert px to % - source = 'fixed px'; - } else if (isSmallScreen && column.meta?.mobileSize) { - const mobileSize = column.meta.mobileSize; - if (typeof mobileSize === 'string' && mobileSize.includes('px')) { - finalWidth = (parseFloat(mobileSize) / containerWidth) * 100; - source = 'mobile px'; - } else { - finalWidth = parseFloat(mobileSize as string) || (priority / totalPriority) * 100; - source = 'mobile %'; - } - } else { - // Compute relative width as percentage - const ratio = priority / totalPriority; - finalWidth = Math.max(5, Math.min(60, ratio * 100)); // 5-60% range - source = 'calculated %'; - } - - // Determine minWidth - prefer explicit, fallback to standard - let minWidth: string | number | undefined; - if (column.meta?.minWidth) { - minWidth = column.meta.minWidth; - } else { - minWidth = 'min-content'; - } - - widths.push(finalWidth); - columnDetails.push({ key, id: column.id || '', finalWidth, source, metaSize }); - - acc[key] = { - width: `${finalWidth}%`, - minWidth, - maxWidth: column.meta?.size || 'none', - flex: `0 0 ${finalWidth}%`, // For flex fallback - // Ensure content doesn't break layout - overflow: 'hidden', - whiteSpace: 'nowrap', - } as React.CSSProperties; - - return acc; - }, - {} as Record, - ); - - // Diagnostic log: Check total width allocation - const totalWidth = widths.reduce((sum, w) => sum + w, 0); - - // Only log in development and when there are issues - if (process.env.NODE_ENV === 'development' && totalWidth > 100) { - console.warn( - '[DataTable Debug] WARNING: Column widths exceed 100% - potential horizontal overflow!', - { containerWidth, columnDetails, totalWidth }, - ); - } - - return result; - }, [stableColumns, isSmallScreen, containerRef]); -}; - -// Legacy support hook - wraps dynamic widths with backward compatibility -const useColumnStyles = ( - columns: TableColumn[], - isSmallScreen: boolean, - containerRef: React.RefObject, -): ReturnType => { - return useDynamicColumnWidths(columns, containerRef, isSmallScreen); -}; - -const TableRowComponent = ({ - row, - columnStyles, - index, - virtualIndex, -}: { - row: Row; - columnStyles: Record; - index: number; - virtualIndex?: number; -}) => { - const handleSelection = useCallback( - (value: boolean) => { - startTransition(() => { - row.toggleSelected(value); - }); - }, - [row], - ); - - return ( - - {row.getVisibleCells().map((cell) => { - if (cell.column.id === 'select') { - return ( - - - - ); - } - - return ( - -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
-
- ); - })} -
- ); -}; - -const MemoizedTableRow = memo(TableRowComponent) as typeof TableRowComponent; - -const DeleteButton = memo( - ({ - onDelete, - isDeleting, - disabled, - isSmallScreen, - }: { - onDelete?: () => Promise; - isDeleting: boolean; - disabled: boolean; - isSmallScreen: boolean; - }) => { - if (!onDelete) return null; - - return ( - - ); - }, -); - -DeleteButton.displayName = 'DeleteButton'; - -/** - * Dynamic skeleton rows that match final column proportions to prevent CLS - * Uses same priority-based width calculation as real content - */ -const SkeletonRows = memo(function ({ - count = 10, - columns, - columnStyles, - containerRef, -}: { - count?: number; - columns: TableColumn[]; - columnStyles: Record; - containerRef: React.RefObject; -}) { - if (!columns.length || !containerRef.current) { - return null; - } - - const containerWidth = containerRef.current.clientWidth; - - // Calculate total priority for skeleton width distribution - const totalPriority = columns.reduce((sum, column) => { - const explicitPriority = column.meta?.priority; - const priority = explicitPriority ?? 1; // uniform default - return sum + priority; - }, 0); - - if (totalPriority === 0) { - return null; - } - - // Helper function to get column key - same as in useDynamicColumnWidths - const keyFor = (column: TableColumn): string => - String(column.id ?? column.accessorKey ?? ''); - - return ( - <> - {Array.from({ length: count }, (_, index) => ( - - {columns.map((column, colIndex) => { - const columnKey = keyFor(column); - const baseStyle = columnStyles[columnKey] || {}; - const explicitPriority = column.meta?.priority; - const priority = explicitPriority ?? 1; // uniform default - // Use priority-based width for all skeleton cells - const skeletonWidth = getSkeletonWidth(priority, containerWidth, totalPriority); - const skeletonStyle: React.CSSProperties = { - ...baseStyle, - width: skeletonWidth, - minWidth: skeletonWidth, - height: '48px', - }; - - return ( - - - - ); - })} - - ))} - - ); -}); - -SkeletonRows.displayName = 'SkeletonRows'; - -/** - * Comprehensive configuration object for DataTable features. - * Consolidates all individual props into nested sections for better maintainability and type safety. - * - * @example - * const config = { - * selection: { enableRowSelection: true, showCheckboxes: false }, - * search: { enableSearch: true, debounce: 500, filterColumn: 'name' }, - * skeleton: { count: 5 }, - * virtualization: { overscan: 15 }, - * pinning: { enableColumnPinning: true } - * }; - * - * Defaults: enableRowSelection: true, showCheckboxes: true, enableSearch: true, - * skeleton.count: 10, virtualization.overscan: 10, search.debounce: 300, - * pinning.enableColumnPinning: false - */ -interface DataTableConfig { - /** - * Selection configuration for row selection features. - * Controls checkbox visibility and row selection behavior. - */ - selection?: { - /** - * Enable row selection functionality across the table. - * When true, rows can be selected via checkboxes or keyboard navigation. - * @default true - */ - enableRowSelection?: boolean; - - /** - * Show checkboxes in the first column for row selection. - * Requires enableRowSelection to be true. Affects both header (select all) and row-level checkboxes. - * @default true - */ - showCheckboxes?: boolean; - }; - - /** - * Search configuration for filtering functionality. - * Enables search input with debounced filtering on specified column. - */ - search?: { - /** - * Enable search input and filtering capabilities. - * When true, displays AnimatedSearchInput above the table for filtering data. - * Requires filterColumn to be specified for column-specific filtering. - * @default true - */ - enableSearch?: boolean; - - /** - * Debounce delay for search input in milliseconds. - * Controls how long to wait after user stops typing before filtering the table. - * Higher values reduce re-renders but may feel less responsive. - * @default 300 - */ - debounce?: number; - - /** - * Column key to filter search results on. - * Must match a column accessorKey. Search will filter rows where this column's value contains the search term. - * Required when enableSearch is true. - * @example 'name', 'email', 'id' - */ - filterColumn?: string; - }; - - /** - * Skeleton configuration for loading states. - * Controls the number of skeleton rows shown while data is loading. - */ - skeleton?: { - /** - * Number of skeleton rows to display during initial loading or data fetching. - * Skeleton rows provide visual feedback and maintain table layout consistency. - * @default 10 - */ - count?: number; - }; - - /** - * Virtualization configuration for scroll performance. - * Controls virtual scrolling behavior for large datasets. - */ - virtualization?: { - /** - * Number of additional rows to render outside the visible viewport. - * Higher values improve scroll smoothness but increase memory usage. - * Recommended range: 5-20 depending on row complexity and dataset size. - * @default 10 - */ - 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) { - const [debounced, setDebounced] = useState(value); - useEffect(() => { - const id = setTimeout(() => setDebounced(value), delay); - return () => clearTimeout(id); - }, [value, delay]); - return debounced; -} - -// Optimized row selection state with deep comparison -const useOptimizedRowSelection = (initialSelection: Record = {}) => { - const [selection, setSelection] = useState(initialSelection); - - const optimizedSelection = useMemo(() => { - return Object.keys(selection).length > 0 ? selection : {}; - }, [selection]); - - const setOptimizedSelection = useCallback( - ( - newSelection: - | Record - | ((prev: Record) => Record), - ) => { - setSelection((prev) => { - const next = typeof newSelection === 'function' ? newSelection(prev) : newSelection; - return deepEqual(prev, next) ? prev : next; - }); - }, - [], - ); - - return [optimizedSelection, setOptimizedSelection] as const; -}; - -interface DataTableProps { - columns: TableColumn[]; - data: TData[]; - className?: string; - isLoading?: boolean; - isFetching?: boolean; - /** - * Configuration object consolidating all feature props. - * See DataTableConfig for detailed structure and defaults. - */ - config?: DataTableConfig; - onDelete?: (selectedRows: TData[]) => Promise; - filterValue?: string; - onFilterChange?: (value: string) => void; - defaultSort?: SortingState; - isFetchingNextPage?: boolean; - hasNextPage?: boolean; - fetchNextPage?: () => Promise; - onError?: (error: Error) => void; - onReset?: () => void; - sorting?: SortingState; - onSortingChange?: (updater: SortingState | ((old: SortingState) => SortingState)) => void; -} - -/** - * DataTable renders a virtualized, searchable table with selection and infinite loading. - * Optimized for performance with memoization, virtual scrolling, and efficient state management. - */ -export default function DataTable({ - columns, - data, - className = '', - isLoading = false, - isFetching = false, - config, - onDelete, - filterValue = '', - onFilterChange, - defaultSort = [], - isFetchingNextPage = false, - hasNextPage = false, - fetchNextPage, - onError, - onReset, - sorting, - onSortingChange, -}: DataTableProps) { - const renderCountRef = useRef(0); - // const prevPropsRef = useRef(null); // Disabled with debug logging - - const tableConfig = useMemo(() => { - return { - enableRowSelection: config?.selection?.enableRowSelection ?? true, - showCheckboxes: config?.selection?.showCheckboxes ?? true, - enableSearch: config?.search?.enableSearch ?? true, - filterColumn: config?.search?.filterColumn, - skeletonCount: config?.skeleton?.count ?? 10, - overscan: config?.virtualization?.overscan ?? DATA_TABLE_CONSTANTS.OVERS_CAN, - debounceDelay: config?.search?.debounce ?? DATA_TABLE_CONSTANTS.SEARCH_DEBOUNCE_MS, - }; - }, [config]); - - const { - enableRowSelection, - showCheckboxes, - enableSearch, - // filterColumn, // Disabled with debug logging - skeletonCount, - overscan, - debounceDelay, - } = tableConfig; - - const localize = useLocalize(); - const isSmallScreen = useMediaQuery('(max-width: 768px)'); - const tableContainerRef = useRef(null); - - const [columnVisibility, setColumnVisibility] = useState({}); - const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection(); - const [rawTerm, setRawTerm] = useState(filterValue ?? ''); - const [isImmediateSearch, setIsImmediateSearch] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const [error, setError] = useState(null); - const [isSearching, setIsSearching] = useState(false); - - const debouncedTerm = useDebounced(rawTerm, debounceDelay); - - const isTransitioning = isFetching || isImmediateSearch; - - // Track when we're waiting for search results - const isWaitingForSearchResults = isSearching && isFetching; - const isFirstLoad = isLoading && data.length === 0; - const isRefreshing = isFetching && !isFirstLoad && !isFetchingNextPage && !isSearching; - - // Show skeletons during initial load, refresh, search, or transitioning - // Exclude isFetchingNextPage to prevent skeletons during infinite scroll - const showSkeletons = - isFirstLoad || - isRefreshing || - isWaitingForSearchResults || - (isTransitioning && !isFetchingNextPage) || // Don't show skeletons during infinite scroll - rawTerm !== debouncedTerm; // Show during typing - - // External sorting support: use provided sorting state, fall back to internal - 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); - - // Sync internal sorting with defaultSort changes - useEffect(() => { - if (!sorting) setInternalSorting(defaultSort); - }, [defaultSort, sorting]); - - // Keep search input in sync with external filterValue - useEffect(() => { - setRawTerm(filterValue ?? ''); - }, [filterValue]); - - // Sorting handler: call external callback if provided, otherwise use internal state - const handleSortingChangeInternal = useCallback( - (updater: SortingState | ((old: SortingState) => SortingState)) => { - startTransition(() => { - const newSorting = typeof updater === 'function' ? updater(finalSorting) : updater; - setInternalSorting(newSorting); - onSortingChange?.(newSorting); - }); - }, - [finalSorting, onSortingChange], - ); - const handleSortingChange = onSortingChange ?? handleSortingChangeInternal; - - renderCountRef.current += 1; - - const sanitizeError = useCallback((err: Error): string => { - const message = err.message; - if (message?.includes('auth') || message?.includes('token')) { - return 'Authentication failed. Please log in again.'; - } - return process.env.NODE_ENV === 'development' - ? message - : 'An error occurred. Please try again.'; - }, []); - - const tableColumns = useMemo(() => { - if (!enableRowSelection || !showCheckboxes) return columns; - - const selectColumn: TableColumn = { - id: 'select', - header: ({ table }: { table: TTable }) => ( -
- table.toggleAllPageRowsSelected(Boolean(value))} - aria-label="Select all rows" - /> -
- ), - cell: ({ row }) => ( - row.toggleSelected(value)} - ariaLabel={`Select row ${row.index + 1}`} - /> - ), - meta: { - size: DATA_TABLE_CONSTANTS.SELECT_COLUMN_SIZE, - minWidth: DATA_TABLE_CONSTANTS.SELECT_COLUMN_MIN_WIDTH, - }, - }; - - return [selectColumn, ...columns]; - }, [columns, enableRowSelection, showCheckboxes]); - - // Dynamic column styles with priority-based sizing - const columnStyles = useColumnStyles( - tableColumns as TableColumn[], - isSmallScreen, - tableContainerRef, - ); - - // Set CSS variables for column sizing with hash optimization - prevent re-render loops - const columnSizesHashRef = useRef(''); - const prevTableColumnsRef = useRef([]); - - useLayoutEffect(() => { - if (typeof window === 'undefined' || !tableContainerRef.current) return; - - // Only update if columns or styles actually changed - const columnsChanged = prevTableColumnsRef.current.length !== tableColumns.length; - - // Calculate hash of column sizes to avoid unnecessary DOM writes - const sizesHash = tableColumns - .map((col, index) => `${index}:${columnStyles[col.id!]?.width || 'auto'}`) - .join('|'); - - if (columnsChanged || sizesHash !== columnSizesHashRef.current) { - columnSizesHashRef.current = sizesHash; - prevTableColumnsRef.current = tableColumns; - - // Batch DOM updates to prevent layout thrashing - requestAnimationFrame(() => { - 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}`); - } - }); - } - }); - } - }, [tableColumns, columnStyles]); - - // Memoized row data with stable references - deep comparison to prevent unnecessary re-renders - const memoizedRowData = useMemo(() => { - return data.map((item, index) => ({ - ...item, - _index: index, - _id: (item as Record)?.id || index, - })); - }, [data]); - - // React Table instance - const tableData = memoizedRowData; - - const table = useReactTable({ - data: tableData, - columns: tableColumns, - getCoreRowModel: getCoreRowModel(), - enableRowSelection, - enableMultiRowSelection: true, - manualSorting: true, // Use manual sorting for server-side sorting - manualFiltering: true, - state: { - sorting: finalSorting, - columnVisibility, - rowSelection: optimizedRowSelection, - }, - onSortingChange: handleSortingChange, - onColumnVisibilityChange: setColumnVisibility, - onRowSelectionChange: setOptimizedRowSelection, - }); - - const { rows } = table.getRowModel(); - - // Fixed: Simplify header groups - React Table already memoizes internally - const headerGroups = table.getHeaderGroups(); - - // Virtual scrolling setup with optimized height measurement - const measuredHeightsRef = useRef([]); - const measureElementCallback = useCallback((el: Element | null) => { - if (!el) return DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE; - - const height = el.getBoundingClientRect().height; - - // Memory management for measured heights - only update if significantly different - const lastHeight = measuredHeightsRef.current[measuredHeightsRef.current.length - 1]; - if (!lastHeight || Math.abs(height - lastHeight) > 1) { - 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 height; - }, []); - - const rowVirtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: useCallback(() => { - // SSR safety: return null during SSR - if (typeof window === 'undefined') return null; - return tableContainerRef.current; - }, []), - estimateSize: useCallback(() => { - const heights = measuredHeightsRef.current; - if (heights.length > 0) { - const avg = heights.reduce((a, b) => a + b, 0) / heights.length; - return Math.max(avg, DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE); - } - return DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE; - }, []), - overscan, - // Fixed: Optimize measureElement to avoid duplicate getBoundingClientRect calls - measureElement: measureElementCallback, - }); - - 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; - - // Virtual rows with stable keys for better performance - const virtualRowsWithStableKeys = useMemo( - () => - virtualRows.map((vRow) => ({ - ...vRow, - stableKey: `${vRow.index}-${rows[vRow.index]?.id || vRow.index}`, - })), - [virtualRows, rows], - ); - - // Store scroll position before fetching to prevent jumping - const scrollPositionRef = useRef<{ top: number; timestamp: number } | null>(null); - const isRestoringScrollRef = useRef(false); - - // Fixed: Infinite scrolling with scroll position preservation - const handleScrollInternal = useCallback(async () => { - if (!mountedRef.current || !tableContainerRef.current) return; - - // Early return if conditions not met - if (!hasNextPage || isFetchingNextPage || isRestoringScrollRef.current) return; - - const scrollElement = tableContainerRef.current; - const { scrollTop, scrollHeight, clientHeight } = scrollElement; - - // More precise threshold calculation - const scrollThreshold = - scrollHeight - clientHeight * DATA_TABLE_CONSTANTS.INFINITE_SCROLL_THRESHOLD; - const nearEnd = scrollTop >= scrollThreshold; - - if (nearEnd) { - try { - // Store current scroll position before fetching - scrollPositionRef.current = { - top: scrollTop, - timestamp: Date.now(), - }; - - await fetchNextPage?.(); - } catch (err) { - // Clear stored position on error - scrollPositionRef.current = null; - const rawError = err instanceof Error ? err : new Error('Failed to fetch next page'); - const sanitizedMessage = sanitizeError(rawError); - const sanitizedError = new Error(sanitizedMessage); - setError(sanitizedError); - onError?.(sanitizedError); - } - } - }, [fetchNextPage, onError, sanitizeError, hasNextPage, isFetchingNextPage]); - - const throttledHandleScroll = useMemo( - () => throttle(handleScrollInternal, DATA_TABLE_CONSTANTS.SCROLL_THROTTLE_MS), - [handleScrollInternal], - ); - - // Scroll position restoration effect - prevents jumping when new data is added - useEffect(() => { - if (!isFetchingNextPage && scrollPositionRef.current && tableContainerRef.current) { - const { top, timestamp } = scrollPositionRef.current; - const isRecent = Date.now() - timestamp < 1000; // Only restore if within 1 second - - if (isRecent) { - isRestoringScrollRef.current = true; - - // Use requestAnimationFrame to ensure DOM has updated - requestAnimationFrame(() => { - if (tableContainerRef.current && mountedRef.current) { - const scrollElement = tableContainerRef.current; - const maxScroll = scrollElement.scrollHeight - scrollElement.clientHeight; - const targetScroll = Math.min(top, maxScroll); - - scrollElement.scrollTo({ - top: targetScroll, - behavior: 'auto', // Instant restoration - }); - - // Clear restoration flag after a brief delay - setTimeout(() => { - isRestoringScrollRef.current = false; - }, 100); - } - }); - } - - // Clear stored position - scrollPositionRef.current = null; - } - }, [isFetchingNextPage, data.length]); // Trigger when fetch completes or data changes - - // Always attach scroll listener with optimized event handling to reduce GC pressure - useEffect(() => { - const scrollElement = tableContainerRef.current; - if (!scrollElement) return; - - // Pre-bind the handler to avoid creating new functions on each scroll - const scrollHandler = throttledHandleScroll; - - // Use passive listener for better performance - const options = { passive: true }; - scrollElement.addEventListener('scroll', scrollHandler, options); - - return () => { - scrollElement.removeEventListener('scroll', scrollHandler); - }; - }, [throttledHandleScroll]); - - // Resize observer for virtualizer revalidation - heavily throttled to prevent rapid re-renders - const handleWindowResize = useCallback(() => { - // Debounce resize to prevent rapid re-renders - if (resizeTimeoutRef.current) { - clearTimeout(resizeTimeoutRef.current); - } - resizeTimeoutRef.current = setTimeout(() => { - if (mountedRef.current) { - rowVirtualizer.measure(); - } - }, 250); // Increased delay significantly - }, [rowVirtualizer]); - - const resizeTimeoutRef = useRef | null>(null); - - // Optimized resize observer with reduced layout thrashing - useEffect(() => { - if (typeof window === 'undefined') return; - - const scrollElement = tableContainerRef.current; - if (!scrollElement) return; - - // Increased throttling to reduce excessive re-renders and GC pressure - let resizeTimeout: ReturnType | null = null; - let lastResizeTime = 0; - const MIN_RESIZE_INTERVAL = 500; // Increased from 250ms to reduce GC pressure - - const resizeObserver = new ResizeObserver(() => { - const now = Date.now(); - if (now - lastResizeTime < MIN_RESIZE_INTERVAL) return; - - lastResizeTime = now; - if (resizeTimeout) clearTimeout(resizeTimeout); - - resizeTimeout = setTimeout(() => { - if (mountedRef.current && !isRestoringScrollRef.current) { - // Only measure if not currently restoring scroll position - rowVirtualizer.measure(); - } - }, MIN_RESIZE_INTERVAL); - }); - - resizeObserver.observe(scrollElement); - - // Optimized window resize handler with increased debouncing - const windowResizeHandler = () => { - if (resizeTimeoutRef.current) { - clearTimeout(resizeTimeoutRef.current); - } - resizeTimeoutRef.current = setTimeout(() => { - if (mountedRef.current && !isRestoringScrollRef.current) { - rowVirtualizer.measure(); - } - }, MIN_RESIZE_INTERVAL); - }; - - window.addEventListener('resize', windowResizeHandler, { passive: true }); - - return () => { - if (resizeTimeout) clearTimeout(resizeTimeout); - if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current); - resizeObserver.disconnect(); - window.removeEventListener('resize', windowResizeHandler); - }; - }, [rowVirtualizer, handleWindowResize]); - - useLayoutEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - - // Optimized dynamic measurement - prevent infinite loops with better guards and reduced frequency - const lastMeasurementRef = useRef<{ - dataLength: number; - isSmallScreen: boolean; - virtualRowsLength: number; - isTransitioning: boolean; - timestamp: number; - }>({ - dataLength: 0, - isSmallScreen: false, - virtualRowsLength: 0, - isTransitioning: false, - timestamp: 0, - }); - - useLayoutEffect(() => { - if (typeof window === 'undefined' || isTransitioning || isRestoringScrollRef.current) return; - - const current = { - dataLength: data.length, - isSmallScreen, - virtualRowsLength: virtualRowsWithStableKeys.length, - isTransitioning, - timestamp: Date.now(), - }; - - const timeSinceLastMeasurement = current.timestamp - lastMeasurementRef.current.timestamp; - - // Only measure if something meaningful changed AND enough time has passed - const hasChanged = - current.dataLength !== lastMeasurementRef.current.dataLength || - current.isSmallScreen !== lastMeasurementRef.current.isSmallScreen || - current.virtualRowsLength !== lastMeasurementRef.current.virtualRowsLength; - - // Increased throttle to 200ms to reduce GC pressure and improve performance - if (!hasChanged || timeSinceLastMeasurement < 200) return; - - lastMeasurementRef.current = current; - - if (mountedRef.current && tableContainerRef.current && virtualRows.length > 0) { - // Increased debounce delay to reduce re-renders - const measurementTimeout = setTimeout(() => { - if (mountedRef.current && !isRestoringScrollRef.current) { - rowVirtualizer.measure(); - } - }, 100); - - return () => clearTimeout(measurementTimeout); - } - }, [ - data.length, - rowVirtualizer, - isSmallScreen, - virtualRowsWithStableKeys.length, - virtualRows.length, - isTransitioning, - ]); - - // Search effect with optimized state updates - useEffect(() => { - if (rawTerm !== filterValue) { - setIsImmediateSearch(true); - } - }, [rawTerm, filterValue]); - - useEffect(() => { - if (debouncedTerm !== filterValue) { - setIsSearching(true); - startTransition(() => { - onFilterChange?.(debouncedTerm); - }); - } - }, [debouncedTerm, onFilterChange, filterValue]); - - useEffect(() => { - if (!isFetching && isImmediateSearch) { - setIsImmediateSearch(false); - } - }, [isFetching, isImmediateSearch]); - - useEffect(() => { - if (!isFetching && isSearching) { - setIsSearching(false); - } - }, [isFetching, isSearching]); - - // Optimized delete handler with batch operations - const handleDelete = useCallback(async () => { - if (!onDelete || isDeleting) return; - - setIsDeleting(true); - setError(null); - - try { - // Use getSelectedRowModel instead of getFilteredSelectedRowModel - const selectedRowsLength = table.getSelectedRowModel().rows.length; - let selectedRows = table.getSelectedRowModel().rows.map((r) => r.original); - - // Validation - if (selectedRows.length === 0) { - setIsDeleting(false); - return; - } - - // Filter out non-object entries - selectedRows = selectedRows.filter( - (row): row is TData => typeof row === 'object' && row !== null, - ); - - // TODO: Remove this - only for development - if (selectedRows.length !== selectedRowsLength && true) { - console.warn('DataTable: Invalid row data detected and filtered out during deletion.'); - } - - await onDelete(selectedRows); - - // Batch state updates - startTransition(() => { - table.resetRowSelection(); - setOptimizedRowSelection({}); - }); - } catch (err) { - const rawError = err instanceof Error ? err : new Error('Failed to delete items'); - const sanitizedMessage = sanitizeError(rawError); - const sanitizedError = new Error(sanitizedMessage); - setError(sanitizedError); - onError?.(sanitizedError); - } finally { - setIsDeleting(false); - } - }, [onDelete, table, isDeleting, onError, sanitizeError, setOptimizedRowSelection]); - - // Reset handler for error boundary retry - const handleBoundaryReset = useCallback(() => { - setError(null); - onReset?.(); - // Re-measure virtualizer after reset - if (tableContainerRef.current && rowVirtualizer) { - rowVirtualizer.measure(); - } - }, [onReset, rowVirtualizer]); - - const selectedCount = useMemo(() => { - const selection = optimizedRowSelection; - return Object.keys(selection).length; - }, [optimizedRowSelection]); - - const shouldShowSearch = useMemo( - () => enableSearch && !!onFilterChange, - [enableSearch, onFilterChange], - ); - - return ( -
- {/* Accessible live region for loading announcements */} -
- {(() => { - if (isSearching) return 'Searching...'; - if (isFetchingNextPage) return 'Loading more rows'; - if (hasNextPage) return 'More rows available'; - return 'All rows loaded'; - })()} -
- - {/* Error display - kept outside boundary for non-rendering errors */} - {error && ( -
- {sanitizeError(error)} - -
- )} - - {/* Isolated Toolbar - Outside scroll container */} -
-
- {enableRowSelection && showCheckboxes && ( - - )} - - {shouldShowSearch && ( -
- { - startTransition(() => setRawTerm(e.target.value)); - }} - isSearching={isWaitingForSearchResults} - placeholder="Search..." - aria-label="Search table data" - /> -
- )} - - {selectedCount > 0 && ( -
{`${selectedCount} selected`}
- )} -
-
- - {/* Error boundary wraps the scrollable table container */} - -
{ - if (isFetchingNextPage) { - e.preventDefault(); - return; - } - if (isTransitioning) { - e.deltaY *= 0.5; - } - }} - role="grid" - aria-label="Data grid" - aria-rowcount={data.length} - aria-busy={isLoading || isFetching || isFetchingNextPage} - > -
- - {/* Sticky Table Header - Fixed z-index and positioning */} - - {headerGroups.map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const sortDir = header.column.getIsSorted(); - const canSort = header.column.getCanSort(); - - let ariaSort: 'ascending' | 'descending' | 'none' | undefined; - if (!canSort) { - ariaSort = undefined; - } else if (sortDir === 'asc') { - ariaSort = 'ascending'; - } else if (sortDir === 'desc') { - ariaSort = 'descending'; - } else { - ariaSort = 'none'; - } - - return ( - { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - header.column.toggleSorting(); - } - } - : undefined - } - className={cn( - 'group relative box-border whitespace-nowrap bg-surface-secondary px-2 py-3 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], - backgroundColor: 'var(--surface-secondary)', - position: 'sticky', - top: 0, - boxSizing: 'border-box', - maxWidth: 'none', - }} - role="columnheader" - scope="col" - tabIndex={canSort ? 0 : -1} - aria-sort={ariaSort} - > -
- - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - {canSort && ( - - {!sortDir && ( - - )} - {sortDir === 'asc' && } - {sortDir === 'desc' && ( - - )} - - )} -
-
- ); - })} -
- ))} -
- - - {paddingTop > 0 && ( - - - )} - - {showSkeletons ? ( - []} - columnStyles={columnStyles} - containerRef={tableContainerRef} - /> - ) : ( - virtualRowsWithStableKeys.map((virtualRow) => { - const row = rows[virtualRow.index]; - return ( - - ); - }) - )} - - {data.length === 0 && !isLoading && ( - - - {debouncedTerm ? localize('com_ui_no_results_found') : 'No data available'} - - - )} - - {paddingBottom > 0 && ( - - - )} - - {/* Loading indicator for infinite scroll */} - {(isFetchingNextPage || hasNextPage) && !isLoading && ( - - -
- {isFetchingNextPage ? ( - - ) : ( - hasNextPage &&
- )} -
- - - )} - -
-
-
-
-
-
-
- ); -} diff --git a/packages/client/src/components/DataTable/DataTable.hooks.ts b/packages/client/src/components/DataTable/DataTable.hooks.ts new file mode 100644 index 0000000000..d2941fb438 --- /dev/null +++ b/packages/client/src/components/DataTable/DataTable.hooks.ts @@ -0,0 +1,135 @@ +import { useState, useEffect, useMemo } from 'react'; +import type { TableColumn } from './DataTable.types'; + +export function useDebounced(value: T, delay: number) { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(id); + }, [value, delay]); + + return debounced; +} + +export const useOptimizedRowSelection = (initialSelection: Record = {}) => { + const [selection, setSelection] = useState(initialSelection); + return [selection, setSelection] as const; +}; + +export const useColumnStyles = ( + columns: TableColumn[], + isSmallScreen: boolean, + containerRef: React.RefObject, +) => { + const [containerWidth, setContainerWidth] = useState(0); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const updateWidth = () => { + setContainerWidth(container.clientWidth); + }; + + const resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(container); + updateWidth(); + + return () => resizeObserver.disconnect(); + }, [containerRef]); + + return useMemo(() => { + if (containerWidth === 0) { + return {}; + } + + const styles: Record = {}; + let totalFixedWidth = 0; + const flexibleColumns: (TableColumn & { priority: number })[] = []; + + columns.forEach((column) => { + const key = String(column.id ?? column.accessorKey ?? ''); + const size = isSmallScreen ? column.meta?.mobileSize : column.meta?.size; + + if (size) { + const width = parseInt(String(size), 10); + totalFixedWidth += width; + styles[key] = { + width: size, + minWidth: column.meta?.minWidth || size, + }; + } else { + flexibleColumns.push({ ...column, priority: column.meta?.priority ?? 1 }); + } + }); + + const availableWidth = containerWidth - totalFixedWidth; + const totalPriority = flexibleColumns.reduce((sum, col) => sum + col.priority, 0); + + if (availableWidth > 0 && totalPriority > 0) { + flexibleColumns.forEach((column) => { + const key = String(column.id ?? column.accessorKey ?? ''); + const proportion = column.priority / totalPriority; + const width = Math.max(Math.floor(availableWidth * proportion), 80); // min width of 80px + styles[key] = { + width: `${width}px`, + minWidth: column.meta?.minWidth ?? `${isSmallScreen ? 60 : 80}px`, + }; + }); + } + + return styles; + }, [columns, containerWidth, isSmallScreen]); +}; + +export const useDynamicColumnWidths = useColumnStyles; + +export const useKeyboardNavigation = ( + tableRef: React.RefObject, + rowCount: number, + onRowSelect?: (index: number) => void, +) => { + const [focusedRowIndex, setFocusedRowIndex] = useState(-1); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!tableRef.current?.contains(event.target as Node)) return; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setFocusedRowIndex((prev) => Math.min(prev + 1, rowCount - 1)); + break; + case 'ArrowUp': + event.preventDefault(); + setFocusedRowIndex((prev) => Math.max(prev - 1, 0)); + break; + case 'Home': + event.preventDefault(); + setFocusedRowIndex(0); + break; + case 'End': + event.preventDefault(); + setFocusedRowIndex(rowCount - 1); + break; + case 'Enter': + case ' ': + if (focusedRowIndex >= 0 && onRowSelect) { + event.preventDefault(); + onRowSelect(focusedRowIndex); + } + break; + case 'Escape': + setFocusedRowIndex(-1); + (event.target as HTMLElement).blur(); + break; + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [tableRef, rowCount, focusedRowIndex, onRowSelect]); + + return { focusedRowIndex, setFocusedRowIndex }; +}; diff --git a/packages/client/src/components/DataTable/DataTable.tsx b/packages/client/src/components/DataTable/DataTable.tsx new file mode 100644 index 0000000000..8ae4ba808a --- /dev/null +++ b/packages/client/src/components/DataTable/DataTable.tsx @@ -0,0 +1,439 @@ +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; diff --git a/packages/client/src/components/DataTable/DataTable.types.ts b/packages/client/src/components/DataTable/DataTable.types.ts new file mode 100644 index 0000000000..996113ede8 --- /dev/null +++ b/packages/client/src/components/DataTable/DataTable.types.ts @@ -0,0 +1,68 @@ +import type { ColumnDef, SortingState, Table } from '@tanstack/react-table'; +import type React from 'react'; + +export type TableColumn = ColumnDef & { + accessorKey?: string | number; + meta?: { + size?: string | number; + mobileSize?: string | number; + minWidth?: string | number; + priority?: number; + }; +}; + +export interface DataTableConfig { + selection?: { + enableRowSelection?: boolean; + showCheckboxes?: boolean; + }; + search?: { + enableSearch?: boolean; + debounce?: number; + filterColumn?: string; + }; + skeleton?: { + count?: number; + }; + virtualization?: { + overscan?: number; + }; + pinning?: { + enableColumnPinning?: boolean; + }; +} + +export interface DataTableProps, TValue> { + columns: TableColumn[]; + data: TData[]; + className?: string; + isLoading?: boolean; + isFetching?: boolean; + config?: DataTableConfig; + onDelete?: (selectedRows: TData[]) => Promise; + filterValue?: string; + onFilterChange?: (value: string) => void; + defaultSort?: SortingState; + isFetchingNextPage?: boolean; + hasNextPage?: boolean; + fetchNextPage?: () => Promise; + onError?: (error: Error) => void; + onReset?: () => void; + sorting?: SortingState; + onSortingChange?: (updater: SortingState | ((old: SortingState) => SortingState)) => void; + conversationIndex?: number; + customActionsRenderer?: (params: { + selectedCount: number; + selectedRows: TData[]; + table: Table; + showToast: (message: string) => void; + }) => React.ReactNode; +} + +export interface DataTableSearchProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + disabled?: boolean; +} diff --git a/packages/client/src/components/DataTable/DataTableComponents.tsx b/packages/client/src/components/DataTable/DataTableComponents.tsx new file mode 100644 index 0000000000..93dce72839 --- /dev/null +++ b/packages/client/src/components/DataTable/DataTableComponents.tsx @@ -0,0 +1,105 @@ +import { memo } from 'react'; +import { flexRender } from '@tanstack/react-table'; +import type { Row, ColumnDef } from '@tanstack/react-table'; +import type { TableColumn } from './DataTable.types'; +import { Checkbox, TableCell, TableRow, Skeleton } from '~/components'; +import { cn } from '~/utils'; + +export const SelectionCheckbox = memo( + ({ + checked, + onChange, + ariaLabel, + }: { + checked: boolean; + onChange: (value: boolean) => void; + ariaLabel: string; + }) => ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onChange(!checked); + } + e.stopPropagation(); + }} + className="flex h-full w-[30px] items-center justify-center" + onClick={(e) => { + e.stopPropagation(); + onChange(!checked); + }} + > + +
+ ), +); + +SelectionCheckbox.displayName = 'SelectionCheckbox'; + +const TableRowComponent = >({ + row, + virtualIndex, +}: { + row: Row; + columns: ColumnDef[]; + index: number; + virtualIndex?: number; +}) => ( + + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta as { className?: string } | undefined; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + +); + +export const MemoizedTableRow = memo( + TableRowComponent, + (prev, next) => + prev.row.original === next.row.original && + prev.row.getIsSelected() === next.row.getIsSelected() && + prev.columns === next.columns, +); + +export const SkeletonRows = memo( + , TValue>({ + count = 10, + columns, + }: { + count?: number; + columns: TableColumn[]; + }) => ( + <> + {Array.from({ length: count }, (_, index) => ( + + {columns.map((column) => { + const columnKey = String( + column.id ?? ('accessorKey' in column && column.accessorKey) ?? '', + ); + const meta = column.meta as { className?: string } | undefined; + return ( + + + + ); + })} + + ))} + + ), +); + +SkeletonRows.displayName = 'SkeletonRows'; diff --git a/packages/client/src/components/DataTable/DataTableSearch.tsx b/packages/client/src/components/DataTable/DataTableSearch.tsx new file mode 100644 index 0000000000..1d64943827 --- /dev/null +++ b/packages/client/src/components/DataTable/DataTableSearch.tsx @@ -0,0 +1,37 @@ +import { memo } from 'react'; +import { startTransition } from 'react'; +import type { DataTableSearchProps } from './DataTable.types'; +import { Input } from '~/components'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +export const DataTableSearch = memo( + ({ value, onChange, placeholder, className, disabled = false }: DataTableSearchProps) => { + const localize = useLocalize(); + + return ( +
+ + { + startTransition(() => onChange(e.target.value)); + }} + disabled={disabled} + aria-label={localize('com_ui_search_table')} + aria-describedby="search-description" + placeholder={placeholder || localize('com_ui_search')} + className={cn('h-12 rounded-b-none border-0 bg-surface-secondary', className)} + /> + + {localize('com_ui_search_table_description')} + +
+ ); + }, +); + +DataTableSearch.displayName = 'DataTableSearch'; diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts index 23a49117cb..2caaa03c2a 100644 --- a/packages/client/src/components/index.ts +++ b/packages/client/src/components/index.ts @@ -31,14 +31,12 @@ export * from './InputOTP'; export * from './MultiSearch'; export * from './Resizable'; export * from './Select'; -export { default as DataTableErrorBoundary } from './DataTable/DataTableErrorBoundary'; export { default as Radio } from './Radio'; export { default as Badge } from './Badge'; export { default as Avatar } from './Avatar'; export { default as Combobox } from './Combobox'; export { default as Dropdown } from './Dropdown'; export { default as SplitText } from './SplitText'; -export { default as DataTable } from './DataTable'; export { default as FormInput } from './FormInput'; export { default as PixelCard } from './PixelCard'; export { default as FileUpload } from './FileUpload'; @@ -47,6 +45,7 @@ export { default as DropdownPopup } from './DropdownPopup'; export { default as DelayedRender } from './DelayedRender'; export { default as ThemeSelector } from './ThemeSelector'; export { default as InfoHoverCard } from './InfoHoverCard'; +export { default as DataTable } from './DataTable/DataTable'; export { default as CheckboxButton } from './CheckboxButton'; export { default as DialogTemplate } from './DialogTemplate'; export { default as SelectDropDown } from './SelectDropDown'; diff --git a/packages/client/src/locales/en/translation.json b/packages/client/src/locales/en/translation.json index 5914ccb574..b31ae2ed1b 100644 --- a/packages/client/src/locales/en/translation.json +++ b/packages/client/src/locales/en/translation.json @@ -2,5 +2,18 @@ "com_ui_cancel": "Cancel", "com_ui_no_options": "No options available", "com_ui_no_results_found": "No results found", - "com_ui_no_data_available": "No data available" + "com_ui_no_data_available": "No data available", + "com_ui_select_all": "Select All", + "com_ui_select_row": "Select Row", + "com_ui_no_selection": "No selection", + "com_ui_confirm_bulk_delete": "Are you sure you want to delete the selected items? This action cannot be undone.", + "com_ui_delete_success": "Items deleted successfully", + "com_ui_retry": "Retry", + "com_ui_selected_count": "{count} selected", + "com_ui_data_table": "Data Table", + "com_ui_no_data": "No data", + "com_ui_delete_selected": "Delete Selected", + "com_ui_search_table": "Search table", + "com_ui_search_table_description": "Type to filter results", + "com_ui_search": "Search" } diff --git a/packages/client/src/utils/index.ts b/packages/client/src/utils/index.ts index 94696af633..dfa740defd 100644 --- a/packages/client/src/utils/index.ts +++ b/packages/client/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './utils'; export * from './theme'; +export { default as logger } from './logger'; diff --git a/packages/client/src/utils/logger.ts b/packages/client/src/utils/logger.ts new file mode 100644 index 0000000000..73e4fac5a5 --- /dev/null +++ b/packages/client/src/utils/logger.ts @@ -0,0 +1,49 @@ +const isDevelopment = process.env.NODE_ENV === 'development'; +const isLoggerEnabled = process.env.VITE_ENABLE_LOGGER === 'true'; +const loggerFilter = process.env.VITE_LOGGER_FILTER || ''; + +type LogFunction = (...args: unknown[]) => void; + +const createLogFunction = ( + consoleMethod: LogFunction, + type?: 'log' | 'warn' | 'error' | 'info' | 'debug' | 'dir', +): LogFunction => { + return (...args: unknown[]) => { + if (isDevelopment || isLoggerEnabled) { + const tag = typeof args[0] === 'string' ? args[0] : ''; + if (shouldLog(tag)) { + if (tag && typeof args[1] === 'string' && type === 'error') { + consoleMethod(`[${tag}] ${args[1]}`, ...args.slice(2)); + } else if (tag && args.length > 1) { + consoleMethod(`[${tag}]`, ...args.slice(1)); + } else { + consoleMethod(...args); + } + } + } + }; +}; + +const logger = { + log: createLogFunction(console.log, 'log'), + dir: createLogFunction(console.dir, 'dir'), + warn: createLogFunction(console.warn, 'warn'), + info: createLogFunction(console.info, 'info'), + error: createLogFunction(console.error, 'error'), + debug: createLogFunction(console.debug, 'debug'), +}; + +function shouldLog(tag: string): boolean { + if (!loggerFilter) { + return true; + } + /* If no tag is provided, always log */ + if (!tag) { + return true; + } + return loggerFilter + .split(',') + .some((filter) => tag.toLowerCase().includes(filter.trim().toLowerCase())); +} + +export default logger;