'use client' import { useEffect, useMemo, useRef, useState, useCallback } from 'react' import { UnifiedTableProps, ColumnVisibilityState, SortState, SavedView } from './types' import { useSelection, usePagination, useFilters, useTableURL, useColumnReorder, useColumnResize } from './hooks' import { useTableKeyboard } from './hooks/useTableKeyboard' import { TableFilters } from './components/TableFilters' import { BulkActionBar } from './components/BulkActionBar' import { DataTableCore } from './components/DataTableCore' import { TablePagination } from './components/TablePagination' import { StandardTableToolbar } from './components/Toolbar' import { Loader2 } from 'lucide-react' import { cn } from '../../utils' export function UnifiedTable({ data, columns, tableId, getRowId, pagination: paginationConfig, selection: selectionConfig, filters: filtersConfig, bulkActions = [], rowActions = [], search: searchConfig, sorting: sortingConfig, columnVisibility: columnVisibilityConfig, columnReorder: columnReorderConfig, columnResize: columnResizeConfig, inlineEdit: inlineEditConfig, savedViews: savedViewsConfig, urlPersistence: urlPersistenceConfig, export: exportConfig, onRowClick, mobileConfig, loading = false, loadingRows = new Set(), className, emptyState, errorState, }: UnifiedTableProps) { const searchInputRef = useRef(null) const shouldRestoreFocusRef = useRef(false) const tableRef = useRef(null) // URL state persistence hook const urlState = useTableURL({ tableId, persistToUrl: urlPersistenceConfig?.enabled ?? false, debounceMs: urlPersistenceConfig?.debounceMs ?? 300, }) // Initialize state from URL if persistence is enabled const initialURLState = useMemo(() => { if (!urlPersistenceConfig?.enabled) return null return urlState.getURLState() }, []) // Only run once on mount // Column visibility state const [columnVisibility, setColumnVisibility] = useState(() => { // Try to load from localStorage if persistKey is set if (columnVisibilityConfig?.persistKey && typeof window !== 'undefined') { try { const stored = localStorage.getItem(`table-columns-${columnVisibilityConfig.persistKey}`) if (stored) { return JSON.parse(stored) } } catch { // Ignore localStorage errors } } // Initialize default visibility const initial: ColumnVisibilityState = {} columns.forEach(col => { if (columnVisibilityConfig?.defaultVisible) { initial[col.id] = columnVisibilityConfig.defaultVisible.includes(col.id) } else { initial[col.id] = true // Default all visible } }) return initial }) // Client-side sorting state (used when no external sorting config is provided) const [localSort, setLocalSort] = useState(() => { // Initialize from URL if available if (initialURLState) { return { sortBy: initialURLState.sortBy, sortDirection: initialURLState.sortDirection, } } return { sortBy: null, sortDirection: 'asc' } }) // Persist column visibility useEffect(() => { if (columnVisibilityConfig?.persistKey && typeof window !== 'undefined') { try { localStorage.setItem( `table-columns-${columnVisibilityConfig.persistKey}`, JSON.stringify(columnVisibility) ) } catch { // Ignore localStorage errors } } }, [columnVisibility, columnVisibilityConfig?.persistKey]) // Toggle column visibility const toggleColumnVisibility = useCallback((columnId: string) => { // Don't allow toggling always-visible columns if (columnVisibilityConfig?.alwaysVisible?.includes(columnId)) { return } setColumnVisibility(prev => ({ ...prev, [columnId]: !prev[columnId] })) }, [columnVisibilityConfig?.alwaysVisible]) // Filter visible columns const visibleColumns = useMemo(() => { if (!columnVisibilityConfig?.enabled) { return columns } return columns.filter(col => columnVisibility[col.id] !== false) }, [columns, columnVisibility, columnVisibilityConfig?.enabled]) // Get hideable columns for the dropdown const hideableColumns = useMemo(() => { return columns.filter(col => { // Can't hide if hideable is explicitly false if (col.hideable === false) return false // Can't hide if in alwaysVisible list if (columnVisibilityConfig?.alwaysVisible?.includes(col.id)) return false return true }) }, [columns, columnVisibilityConfig?.alwaysVisible]) // Column reordering hook const columnReorder = useColumnReorder({ columns: visibleColumns, initialOrder: columnReorderConfig?.initialOrder, enabled: columnReorderConfig?.enabled ?? false, onOrderChange: columnReorderConfig?.onOrderChange, }) // Final columns to display (ordered if reorder enabled) const displayColumns = columnReorderConfig?.enabled ? columnReorder.orderedColumns : visibleColumns // Column resize hook const columnResize = useColumnResize({ enabled: columnResizeConfig?.enabled ?? false, initialWidths: columnResizeConfig?.initialWidths, minWidth: columnResizeConfig?.minWidth ?? 50, onWidthChange: columnResizeConfig?.onWidthChange, }) // Determine sorting state and handler const sortingEnabled = sortingConfig?.enabled ?? true // Default to enabled const currentSortBy = sortingConfig?.value?.sortBy ?? localSort.sortBy const currentSortDirection = sortingConfig?.value?.sortDirection ?? localSort.sortDirection const handleSort = useCallback((columnId: string) => { const newDirection = currentSortBy === columnId && currentSortDirection === 'asc' ? 'desc' : 'asc' const newSort = { sortBy: columnId, sortDirection: newDirection as 'asc' | 'desc' } if (sortingConfig?.onChange) { sortingConfig.onChange(newSort) } else { setLocalSort(newSort) } // Sync to URL if enabled urlState.setSortToURL(newSort) }, [currentSortBy, currentSortDirection, sortingConfig, urlState]) // Ensure data is always an array const safeData = useMemo(() => Array.isArray(data) ? data : [], [data]) // Apply client-side sorting if no server-side sorting const sortedData = useMemo(() => { if (sortingConfig?.serverSide || !currentSortBy) { return safeData } const column = columns.find(c => c.id === currentSortBy) if (!column) return safeData return [...safeData].sort((a, b) => { let valueA: any let valueB: any if (column.sortingFn) { return currentSortDirection === 'asc' ? column.sortingFn(a, b) : column.sortingFn(b, a) } if (column.accessorFn) { valueA = column.accessorFn(a) valueB = column.accessorFn(b) } else if (column.accessorKey) { // Handle nested keys like 'firm.name' const keys = column.accessorKey.split('.') valueA = keys.reduce((obj, key) => obj?.[key], a as any) valueB = keys.reduce((obj, key) => obj?.[key], b as any) } else { return 0 } // Handle null/undefined if (valueA == null && valueB == null) return 0 if (valueA == null) return currentSortDirection === 'asc' ? 1 : -1 if (valueB == null) return currentSortDirection === 'asc' ? -1 : 1 // Compare if (typeof valueA === 'string' && typeof valueB === 'string') { const comparison = valueA.localeCompare(valueB, undefined, { sensitivity: 'base' }) return currentSortDirection === 'asc' ? comparison : -comparison } if (typeof valueA === 'number' && typeof valueB === 'number') { return currentSortDirection === 'asc' ? valueA - valueB : valueB - valueA } const comparison = String(valueA).localeCompare(String(valueB)) return currentSortDirection === 'asc' ? comparison : -comparison }) }, [safeData, columns, currentSortBy, currentSortDirection, sortingConfig?.serverSide]) // Pagination hook const pagination = usePagination({ totalCount: paginationConfig?.totalCount || sortedData.length, initialPageSize: paginationConfig?.pageSize || 25, initialPage: paginationConfig?.currentPage || (initialURLState?.page ?? 1), serverSide: paginationConfig?.serverSide || false, onPageChange: paginationConfig?.onPageChange, }) // Selection hook - supports both controlled and uncontrolled modes const selection = useSelection({ currentPageData: sortedData, totalCount: paginationConfig?.totalCount || sortedData.length, getRowId, onSelectionChange: selectionConfig?.onSelectionChange, onSelectAllPages: selectionConfig?.onSelectAllPages, // External controlled state externalSelectedIds: selectionConfig?.selectedIds, externalSelectAllPages: selectionConfig?.selectAllPages, }) // Filters hook const filters = useFilters({ initialFilters: filtersConfig?.value || (initialURLState?.filters ?? {}), onChange: filtersConfig?.onChange, }) // Get current view state for saving const getCurrentViewState = useCallback((): Omit => { return { columnVisibility: columnVisibilityConfig?.enabled ? columnVisibility : undefined, columnOrder: columnReorderConfig?.enabled ? columnReorder.columnOrder : undefined, sortBy: currentSortBy, sortDirection: currentSortDirection, filters: filtersConfig?.enabled ? filters.filters : undefined, pageSize: paginationConfig?.pageSize, } }, [ columnVisibility, columnVisibilityConfig?.enabled, columnReorder.columnOrder, columnReorderConfig?.enabled, currentSortBy, currentSortDirection, filters.filters, filtersConfig?.enabled, paginationConfig?.pageSize, ]) // Keyboard navigation hook const keyboard = useTableKeyboard({ data: sortedData, getRowId, selectedIds: selection.selectedIds, onToggleRow: selection.toggleRow, onToggleAll: selection.toggleAll, onRowClick, onDelete: bulkActions.find(action => action.id === 'delete')?.onClick ? (ids) => { const deleteAction = bulkActions.find(action => action.id === 'delete') if (deleteAction) { deleteAction.onClick(ids) } } : undefined, tableRef, enabled: true, }) // Restore focus to search input after state-driven rerenders (e.g., URL sync) useEffect(() => { if (!searchConfig?.enabled || !searchConfig.preserveFocus) return if (!shouldRestoreFocusRef.current) return if (searchInputRef.current) { searchInputRef.current.focus() const len = searchConfig.value?.length ?? 0 try { searchInputRef.current.setSelectionRange(len, len) } catch { // selection range may fail on some input types; ignore } } shouldRestoreFocusRef.current = false }, [searchConfig?.enabled, searchConfig?.preserveFocus, searchConfig?.value]) // External selection state is now synced in useSelection hook // Sync pagination changes to URL useEffect(() => { if (!urlPersistenceConfig?.enabled) return if (!paginationConfig?.enabled) return urlState.setPageToURL(pagination.currentPage) }, [pagination.currentPage, urlPersistenceConfig?.enabled, paginationConfig?.enabled, urlState]) // Sync filter changes to URL useEffect(() => { if (!urlPersistenceConfig?.enabled) return if (!filtersConfig?.enabled) return urlState.setFiltersToURL(filters.filters) }, [filters.filters, urlPersistenceConfig?.enabled, filtersConfig?.enabled, urlState]) // Initialize search from URL on mount useEffect(() => { if (!urlPersistenceConfig?.enabled) return if (!searchConfig?.enabled) return if (!initialURLState?.search) return // Only set if search value is empty (first mount) if (!searchConfig.value && initialURLState.search) { searchConfig.onChange(initialURLState.search) } }, []) // Only run on mount // Check if all current page rows are selected const allRowsSelected = useMemo(() => { if (sortedData.length === 0) return false return sortedData.every(row => selection.selectedIds.has(getRowId(row))) }, [sortedData, selection.selectedIds, getRowId]) // Prepare selected data for export const selectedDataForExport = useMemo(() => { if (!selectionConfig?.enabled || selection.selectedIds.size === 0) { return undefined } return sortedData.filter(row => selection.selectedIds.has(getRowId(row))) }, [sortedData, selection.selectedIds, getRowId, selectionConfig?.enabled]) // Handle bulk action execution const handleExecuteBulkAction = async (action: typeof bulkActions[0]) => { const selectedIds = selection.selectAllPages ? new Set(sortedData.map(getRowId)) // In real implementation, this would fetch all IDs : selection.selectedIds // Show confirmation if needed if (action.confirmMessage) { const message = action.confirmMessage.replace('{count}', selectedIds.size.toString()) if (!confirm(message)) { return } } // Check max selection if (action.maxSelection && selectedIds.size > action.maxSelection) { alert(`Maximum ${action.maxSelection} items can be selected for this action`) return } try { await action.onClick(selectedIds) // Clear selection after successful action selection.clearSelection() } catch (error) { console.error('Bulk action failed:', error) } } // Loading state if (loading && sortedData.length === 0) { return (
) } // Empty state if (!loading && sortedData.length === 0 && emptyState) { return
{emptyState}
} return (
{/* Standard Table Toolbar */} { if (searchConfig) { shouldRestoreFocusRef.current = true searchConfig.onChange(value) urlState.setSearchToURL(value) } }} searchInputRef={searchInputRef} searchAutoFocus={searchConfig?.autoFocus} columnVisibilityEnabled={columnVisibilityConfig?.enabled} hideableColumns={hideableColumns} columnVisibility={columnVisibility} onToggleColumnVisibility={toggleColumnVisibility} exportEnabled={exportConfig?.enabled} exportData={sortedData} exportColumns={displayColumns} exportProps={exportConfig?.enabled ? { filteredData: sortedData.length < safeData.length ? sortedData : undefined, selectedData: selectedDataForExport, baseFilename: exportConfig.baseFilename, showProgress: exportConfig.showProgress, onExportStart: exportConfig.onExportStart, onExportComplete: exportConfig.onExportComplete, onExportError: exportConfig.onExportError, } : undefined} savedViewsEnabled={savedViewsConfig?.enabled} savedViews={savedViewsConfig?.views} currentViewId={savedViewsConfig?.currentViewId} savedViewsProps={savedViewsConfig?.enabled ? { onSaveView: savedViewsConfig.onSaveView, onUpdateView: savedViewsConfig.onUpdateView, onDeleteView: savedViewsConfig.onDeleteView, onLoadView: savedViewsConfig.onLoadView || (() => {}), getCurrentViewState, } : undefined} /> {/* Filters */} {filtersConfig?.enabled && filtersConfig.config && ( )} {/* Bulk Actions Bar */} {selectionConfig?.enabled && bulkActions.length > 0 && ( )} {/* Selection summary - only show when no bulk actions (BulkActionBar handles this otherwise) */} {selectionConfig?.enabled && selection.getSelectedCount() > 0 && bulkActions.length === 0 && (
{selection.selectAllPages ? ( All {paginationConfig?.totalCount || sortedData.length} items selected ) : ( {selection.getSelectedCount()} item{selection.getSelectedCount() === 1 ? '' : 's'} selected{allRowsSelected ? ' on this page' : ''} )}
{/* Show "Select All Pages" button if not all pages selected and there are multiple pages */} {!selection.selectAllPages && allRowsSelected && paginationConfig && (paginationConfig.totalCount || 0) > sortedData.length && ( )}
)} {/* Select all pages option - shown below bulk action bar when applicable */} {selectionConfig?.enabled && bulkActions.length > 0 && !selection.selectAllPages && allRowsSelected && paginationConfig && (paginationConfig.totalCount || 0) > sortedData.length && (
)} {/* Data Table */} {/* Pagination */} {paginationConfig?.enabled && ( )}
) }