@startsimpli/ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +537 -0
- package/package.json +80 -0
- package/src/components/index.ts +50 -0
- package/src/components/navigation/sidebar.tsx +178 -0
- package/src/components/ui/accordion.tsx +58 -0
- package/src/components/ui/alert.tsx +59 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/calendar.tsx +70 -0
- package/src/components/ui/card.tsx +68 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +12 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/index.ts +24 -0
- package/src/components/ui/input.tsx +25 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/popover.tsx +31 -0
- package/src/components/ui/progress.tsx +28 -0
- package/src/components/ui/scroll-area.tsx +48 -0
- package/src/components/ui/select.tsx +160 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/table.tsx +117 -0
- package/src/components/ui/tabs.tsx +55 -0
- package/src/components/ui/textarea.tsx +24 -0
- package/src/components/ui/tooltip.tsx +30 -0
- package/src/components/unified-table/UnifiedTable.tsx +553 -0
- package/src/components/unified-table/__tests__/components/BulkActionBar.test.tsx +477 -0
- package/src/components/unified-table/__tests__/components/ExportButton.test.tsx +467 -0
- package/src/components/unified-table/__tests__/components/InlineEditCell.test.tsx +159 -0
- package/src/components/unified-table/__tests__/components/SavedViewsDropdown.test.tsx +128 -0
- package/src/components/unified-table/__tests__/components/TablePagination.test.tsx +374 -0
- package/src/components/unified-table/__tests__/hooks/useColumnReorder.test.ts +191 -0
- package/src/components/unified-table/__tests__/hooks/useColumnResize.test.ts +122 -0
- package/src/components/unified-table/__tests__/hooks/useColumnVisibility.test.ts +594 -0
- package/src/components/unified-table/__tests__/hooks/useFilters.test.ts +460 -0
- package/src/components/unified-table/__tests__/hooks/usePagination.test.ts +439 -0
- package/src/components/unified-table/__tests__/hooks/useResponsive.test.ts +421 -0
- package/src/components/unified-table/__tests__/hooks/useSelection.test.ts +367 -0
- package/src/components/unified-table/__tests__/hooks/useTableKeyboard.test.ts +803 -0
- package/src/components/unified-table/__tests__/hooks/useTableState.test.ts +210 -0
- package/src/components/unified-table/__tests__/integration/table-with-selection.test.tsx +624 -0
- package/src/components/unified-table/__tests__/utils/export.test.ts +427 -0
- package/src/components/unified-table/components/BulkActionBar/index.tsx +119 -0
- package/src/components/unified-table/components/DataTableCore/index.tsx +473 -0
- package/src/components/unified-table/components/InlineEditCell/index.tsx +159 -0
- package/src/components/unified-table/components/MobileView/Card.tsx +218 -0
- package/src/components/unified-table/components/MobileView/CardActions.tsx +126 -0
- package/src/components/unified-table/components/MobileView/README.md +411 -0
- package/src/components/unified-table/components/MobileView/index.tsx +77 -0
- package/src/components/unified-table/components/MobileView/types.ts +77 -0
- package/src/components/unified-table/components/TableFilters/index.tsx +298 -0
- package/src/components/unified-table/components/TablePagination/index.tsx +157 -0
- package/src/components/unified-table/components/Toolbar/ExportButton.tsx +229 -0
- package/src/components/unified-table/components/Toolbar/SavedViewsDropdown.tsx +251 -0
- package/src/components/unified-table/components/Toolbar/StandardTableToolbar.tsx +146 -0
- package/src/components/unified-table/components/Toolbar/index.tsx +3 -0
- package/src/components/unified-table/hooks/index.ts +21 -0
- package/src/components/unified-table/hooks/useColumnReorder.ts +90 -0
- package/src/components/unified-table/hooks/useColumnResize.ts +123 -0
- package/src/components/unified-table/hooks/useColumnVisibility.ts +92 -0
- package/src/components/unified-table/hooks/useFilters.ts +53 -0
- package/src/components/unified-table/hooks/usePagination.ts +120 -0
- package/src/components/unified-table/hooks/useResponsive.ts +50 -0
- package/src/components/unified-table/hooks/useSelection.ts +152 -0
- package/src/components/unified-table/hooks/useTableKeyboard.ts +206 -0
- package/src/components/unified-table/hooks/useTablePreferences.ts +198 -0
- package/src/components/unified-table/hooks/useTableState.ts +103 -0
- package/src/components/unified-table/hooks/useTableURL.test.tsx +921 -0
- package/src/components/unified-table/hooks/useTableURL.ts +301 -0
- package/src/components/unified-table/index.ts +16 -0
- package/src/components/unified-table/types.ts +393 -0
- package/src/components/unified-table/utils/export.ts +236 -0
- package/src/components/unified-table/utils/index.ts +4 -0
- package/src/components/unified-table/utils/renderers.ts +105 -0
- package/src/components/unified-table/utils/themes.ts +87 -0
- package/src/components/unified-table/utils/validation.ts +122 -0
- package/src/index.ts +6 -0
- package/src/lib/utils.ts +1 -0
- package/src/theme/contract.ts +46 -0
- package/src/theme/index.ts +9 -0
- package/src/theme/tailwind.config.js +70 -0
- package/src/theme/tailwind.preset.ts +93 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/index.ts +91 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
|
4
|
+
import { UnifiedTableProps, ColumnVisibilityState, SortState, SavedView } from './types'
|
|
5
|
+
import { useSelection, usePagination, useFilters, useTableURL, useColumnReorder, useColumnResize } from './hooks'
|
|
6
|
+
import { useTableKeyboard } from './hooks/useTableKeyboard'
|
|
7
|
+
import { TableFilters } from './components/TableFilters'
|
|
8
|
+
import { BulkActionBar } from './components/BulkActionBar'
|
|
9
|
+
import { DataTableCore } from './components/DataTableCore'
|
|
10
|
+
import { TablePagination } from './components/TablePagination'
|
|
11
|
+
import { StandardTableToolbar } from './components/Toolbar'
|
|
12
|
+
import { Loader2 } from 'lucide-react'
|
|
13
|
+
import { cn } from '../../utils'
|
|
14
|
+
|
|
15
|
+
export function UnifiedTable<TData>({
|
|
16
|
+
data,
|
|
17
|
+
columns,
|
|
18
|
+
tableId,
|
|
19
|
+
getRowId,
|
|
20
|
+
pagination: paginationConfig,
|
|
21
|
+
selection: selectionConfig,
|
|
22
|
+
filters: filtersConfig,
|
|
23
|
+
bulkActions = [],
|
|
24
|
+
rowActions = [],
|
|
25
|
+
search: searchConfig,
|
|
26
|
+
sorting: sortingConfig,
|
|
27
|
+
columnVisibility: columnVisibilityConfig,
|
|
28
|
+
columnReorder: columnReorderConfig,
|
|
29
|
+
columnResize: columnResizeConfig,
|
|
30
|
+
inlineEdit: inlineEditConfig,
|
|
31
|
+
savedViews: savedViewsConfig,
|
|
32
|
+
urlPersistence: urlPersistenceConfig,
|
|
33
|
+
export: exportConfig,
|
|
34
|
+
onRowClick,
|
|
35
|
+
mobileConfig,
|
|
36
|
+
loading = false,
|
|
37
|
+
loadingRows = new Set(),
|
|
38
|
+
className,
|
|
39
|
+
emptyState,
|
|
40
|
+
errorState,
|
|
41
|
+
}: UnifiedTableProps<TData>) {
|
|
42
|
+
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
|
43
|
+
const shouldRestoreFocusRef = useRef(false)
|
|
44
|
+
const tableRef = useRef<HTMLDivElement>(null)
|
|
45
|
+
|
|
46
|
+
// URL state persistence hook
|
|
47
|
+
const urlState = useTableURL({
|
|
48
|
+
tableId,
|
|
49
|
+
persistToUrl: urlPersistenceConfig?.enabled ?? false,
|
|
50
|
+
debounceMs: urlPersistenceConfig?.debounceMs ?? 300,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Initialize state from URL if persistence is enabled
|
|
54
|
+
const initialURLState = useMemo(() => {
|
|
55
|
+
if (!urlPersistenceConfig?.enabled) return null
|
|
56
|
+
return urlState.getURLState()
|
|
57
|
+
}, []) // Only run once on mount
|
|
58
|
+
|
|
59
|
+
// Column visibility state
|
|
60
|
+
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>(() => {
|
|
61
|
+
// Try to load from localStorage if persistKey is set
|
|
62
|
+
if (columnVisibilityConfig?.persistKey && typeof window !== 'undefined') {
|
|
63
|
+
try {
|
|
64
|
+
const stored = localStorage.getItem(`table-columns-${columnVisibilityConfig.persistKey}`)
|
|
65
|
+
if (stored) {
|
|
66
|
+
return JSON.parse(stored)
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Ignore localStorage errors
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Initialize default visibility
|
|
74
|
+
const initial: ColumnVisibilityState = {}
|
|
75
|
+
columns.forEach(col => {
|
|
76
|
+
if (columnVisibilityConfig?.defaultVisible) {
|
|
77
|
+
initial[col.id] = columnVisibilityConfig.defaultVisible.includes(col.id)
|
|
78
|
+
} else {
|
|
79
|
+
initial[col.id] = true // Default all visible
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
return initial
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Client-side sorting state (used when no external sorting config is provided)
|
|
86
|
+
const [localSort, setLocalSort] = useState<SortState>(() => {
|
|
87
|
+
// Initialize from URL if available
|
|
88
|
+
if (initialURLState) {
|
|
89
|
+
return {
|
|
90
|
+
sortBy: initialURLState.sortBy,
|
|
91
|
+
sortDirection: initialURLState.sortDirection,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { sortBy: null, sortDirection: 'asc' }
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Persist column visibility
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (columnVisibilityConfig?.persistKey && typeof window !== 'undefined') {
|
|
100
|
+
try {
|
|
101
|
+
localStorage.setItem(
|
|
102
|
+
`table-columns-${columnVisibilityConfig.persistKey}`,
|
|
103
|
+
JSON.stringify(columnVisibility)
|
|
104
|
+
)
|
|
105
|
+
} catch {
|
|
106
|
+
// Ignore localStorage errors
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}, [columnVisibility, columnVisibilityConfig?.persistKey])
|
|
110
|
+
|
|
111
|
+
// Toggle column visibility
|
|
112
|
+
const toggleColumnVisibility = useCallback((columnId: string) => {
|
|
113
|
+
// Don't allow toggling always-visible columns
|
|
114
|
+
if (columnVisibilityConfig?.alwaysVisible?.includes(columnId)) {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
setColumnVisibility(prev => ({
|
|
118
|
+
...prev,
|
|
119
|
+
[columnId]: !prev[columnId]
|
|
120
|
+
}))
|
|
121
|
+
}, [columnVisibilityConfig?.alwaysVisible])
|
|
122
|
+
|
|
123
|
+
// Filter visible columns
|
|
124
|
+
const visibleColumns = useMemo(() => {
|
|
125
|
+
if (!columnVisibilityConfig?.enabled) {
|
|
126
|
+
return columns
|
|
127
|
+
}
|
|
128
|
+
return columns.filter(col => columnVisibility[col.id] !== false)
|
|
129
|
+
}, [columns, columnVisibility, columnVisibilityConfig?.enabled])
|
|
130
|
+
|
|
131
|
+
// Get hideable columns for the dropdown
|
|
132
|
+
const hideableColumns = useMemo(() => {
|
|
133
|
+
return columns.filter(col => {
|
|
134
|
+
// Can't hide if hideable is explicitly false
|
|
135
|
+
if (col.hideable === false) return false
|
|
136
|
+
// Can't hide if in alwaysVisible list
|
|
137
|
+
if (columnVisibilityConfig?.alwaysVisible?.includes(col.id)) return false
|
|
138
|
+
return true
|
|
139
|
+
})
|
|
140
|
+
}, [columns, columnVisibilityConfig?.alwaysVisible])
|
|
141
|
+
|
|
142
|
+
// Column reordering hook
|
|
143
|
+
const columnReorder = useColumnReorder({
|
|
144
|
+
columns: visibleColumns,
|
|
145
|
+
initialOrder: columnReorderConfig?.initialOrder,
|
|
146
|
+
enabled: columnReorderConfig?.enabled ?? false,
|
|
147
|
+
onOrderChange: columnReorderConfig?.onOrderChange,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// Final columns to display (ordered if reorder enabled)
|
|
151
|
+
const displayColumns = columnReorderConfig?.enabled
|
|
152
|
+
? columnReorder.orderedColumns
|
|
153
|
+
: visibleColumns
|
|
154
|
+
|
|
155
|
+
// Column resize hook
|
|
156
|
+
const columnResize = useColumnResize({
|
|
157
|
+
enabled: columnResizeConfig?.enabled ?? false,
|
|
158
|
+
initialWidths: columnResizeConfig?.initialWidths,
|
|
159
|
+
minWidth: columnResizeConfig?.minWidth ?? 50,
|
|
160
|
+
onWidthChange: columnResizeConfig?.onWidthChange,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// Determine sorting state and handler
|
|
164
|
+
const sortingEnabled = sortingConfig?.enabled ?? true // Default to enabled
|
|
165
|
+
const currentSortBy = sortingConfig?.value?.sortBy ?? localSort.sortBy
|
|
166
|
+
const currentSortDirection = sortingConfig?.value?.sortDirection ?? localSort.sortDirection
|
|
167
|
+
|
|
168
|
+
const handleSort = useCallback((columnId: string) => {
|
|
169
|
+
const newDirection = currentSortBy === columnId && currentSortDirection === 'asc' ? 'desc' : 'asc'
|
|
170
|
+
const newSort = { sortBy: columnId, sortDirection: newDirection as 'asc' | 'desc' }
|
|
171
|
+
|
|
172
|
+
if (sortingConfig?.onChange) {
|
|
173
|
+
sortingConfig.onChange(newSort)
|
|
174
|
+
} else {
|
|
175
|
+
setLocalSort(newSort)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Sync to URL if enabled
|
|
179
|
+
urlState.setSortToURL(newSort)
|
|
180
|
+
}, [currentSortBy, currentSortDirection, sortingConfig, urlState])
|
|
181
|
+
|
|
182
|
+
// Ensure data is always an array
|
|
183
|
+
const safeData = useMemo(() => Array.isArray(data) ? data : [], [data])
|
|
184
|
+
|
|
185
|
+
// Apply client-side sorting if no server-side sorting
|
|
186
|
+
const sortedData = useMemo(() => {
|
|
187
|
+
if (sortingConfig?.serverSide || !currentSortBy) {
|
|
188
|
+
return safeData
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const column = columns.find(c => c.id === currentSortBy)
|
|
192
|
+
if (!column) return safeData
|
|
193
|
+
|
|
194
|
+
return [...safeData].sort((a, b) => {
|
|
195
|
+
let valueA: any
|
|
196
|
+
let valueB: any
|
|
197
|
+
|
|
198
|
+
if (column.sortingFn) {
|
|
199
|
+
return currentSortDirection === 'asc'
|
|
200
|
+
? column.sortingFn(a, b)
|
|
201
|
+
: column.sortingFn(b, a)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (column.accessorFn) {
|
|
205
|
+
valueA = column.accessorFn(a)
|
|
206
|
+
valueB = column.accessorFn(b)
|
|
207
|
+
} else if (column.accessorKey) {
|
|
208
|
+
// Handle nested keys like 'firm.name'
|
|
209
|
+
const keys = column.accessorKey.split('.')
|
|
210
|
+
valueA = keys.reduce((obj, key) => obj?.[key], a as any)
|
|
211
|
+
valueB = keys.reduce((obj, key) => obj?.[key], b as any)
|
|
212
|
+
} else {
|
|
213
|
+
return 0
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Handle null/undefined
|
|
217
|
+
if (valueA == null && valueB == null) return 0
|
|
218
|
+
if (valueA == null) return currentSortDirection === 'asc' ? 1 : -1
|
|
219
|
+
if (valueB == null) return currentSortDirection === 'asc' ? -1 : 1
|
|
220
|
+
|
|
221
|
+
// Compare
|
|
222
|
+
if (typeof valueA === 'string' && typeof valueB === 'string') {
|
|
223
|
+
const comparison = valueA.localeCompare(valueB, undefined, { sensitivity: 'base' })
|
|
224
|
+
return currentSortDirection === 'asc' ? comparison : -comparison
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (typeof valueA === 'number' && typeof valueB === 'number') {
|
|
228
|
+
return currentSortDirection === 'asc' ? valueA - valueB : valueB - valueA
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const comparison = String(valueA).localeCompare(String(valueB))
|
|
232
|
+
return currentSortDirection === 'asc' ? comparison : -comparison
|
|
233
|
+
})
|
|
234
|
+
}, [safeData, columns, currentSortBy, currentSortDirection, sortingConfig?.serverSide])
|
|
235
|
+
|
|
236
|
+
// Pagination hook
|
|
237
|
+
const pagination = usePagination({
|
|
238
|
+
totalCount: paginationConfig?.totalCount || sortedData.length,
|
|
239
|
+
initialPageSize: paginationConfig?.pageSize || 25,
|
|
240
|
+
initialPage: paginationConfig?.currentPage || (initialURLState?.page ?? 1),
|
|
241
|
+
serverSide: paginationConfig?.serverSide || false,
|
|
242
|
+
onPageChange: paginationConfig?.onPageChange,
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// Selection hook - supports both controlled and uncontrolled modes
|
|
246
|
+
const selection = useSelection({
|
|
247
|
+
currentPageData: sortedData,
|
|
248
|
+
totalCount: paginationConfig?.totalCount || sortedData.length,
|
|
249
|
+
getRowId,
|
|
250
|
+
onSelectionChange: selectionConfig?.onSelectionChange,
|
|
251
|
+
onSelectAllPages: selectionConfig?.onSelectAllPages,
|
|
252
|
+
// External controlled state
|
|
253
|
+
externalSelectedIds: selectionConfig?.selectedIds,
|
|
254
|
+
externalSelectAllPages: selectionConfig?.selectAllPages,
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// Filters hook
|
|
258
|
+
const filters = useFilters({
|
|
259
|
+
initialFilters: filtersConfig?.value || (initialURLState?.filters ?? {}),
|
|
260
|
+
onChange: filtersConfig?.onChange,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// Get current view state for saving
|
|
264
|
+
const getCurrentViewState = useCallback((): Omit<SavedView, 'id' | 'name' | 'createdAt'> => {
|
|
265
|
+
return {
|
|
266
|
+
columnVisibility: columnVisibilityConfig?.enabled ? columnVisibility : undefined,
|
|
267
|
+
columnOrder: columnReorderConfig?.enabled ? columnReorder.columnOrder : undefined,
|
|
268
|
+
sortBy: currentSortBy,
|
|
269
|
+
sortDirection: currentSortDirection,
|
|
270
|
+
filters: filtersConfig?.enabled ? filters.filters : undefined,
|
|
271
|
+
pageSize: paginationConfig?.pageSize,
|
|
272
|
+
}
|
|
273
|
+
}, [
|
|
274
|
+
columnVisibility,
|
|
275
|
+
columnVisibilityConfig?.enabled,
|
|
276
|
+
columnReorder.columnOrder,
|
|
277
|
+
columnReorderConfig?.enabled,
|
|
278
|
+
currentSortBy,
|
|
279
|
+
currentSortDirection,
|
|
280
|
+
filters.filters,
|
|
281
|
+
filtersConfig?.enabled,
|
|
282
|
+
paginationConfig?.pageSize,
|
|
283
|
+
])
|
|
284
|
+
|
|
285
|
+
// Keyboard navigation hook
|
|
286
|
+
const keyboard = useTableKeyboard({
|
|
287
|
+
data: sortedData,
|
|
288
|
+
getRowId,
|
|
289
|
+
selectedIds: selection.selectedIds,
|
|
290
|
+
onToggleRow: selection.toggleRow,
|
|
291
|
+
onToggleAll: selection.toggleAll,
|
|
292
|
+
onRowClick,
|
|
293
|
+
onDelete: bulkActions.find(action => action.id === 'delete')?.onClick
|
|
294
|
+
? (ids) => {
|
|
295
|
+
const deleteAction = bulkActions.find(action => action.id === 'delete')
|
|
296
|
+
if (deleteAction) {
|
|
297
|
+
deleteAction.onClick(ids)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
: undefined,
|
|
301
|
+
tableRef,
|
|
302
|
+
enabled: true,
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// Restore focus to search input after state-driven rerenders (e.g., URL sync)
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
if (!searchConfig?.enabled || !searchConfig.preserveFocus) return
|
|
308
|
+
if (!shouldRestoreFocusRef.current) return
|
|
309
|
+
if (searchInputRef.current) {
|
|
310
|
+
searchInputRef.current.focus()
|
|
311
|
+
const len = searchConfig.value?.length ?? 0
|
|
312
|
+
try {
|
|
313
|
+
searchInputRef.current.setSelectionRange(len, len)
|
|
314
|
+
} catch {
|
|
315
|
+
// selection range may fail on some input types; ignore
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
shouldRestoreFocusRef.current = false
|
|
319
|
+
}, [searchConfig?.enabled, searchConfig?.preserveFocus, searchConfig?.value])
|
|
320
|
+
|
|
321
|
+
// External selection state is now synced in useSelection hook
|
|
322
|
+
|
|
323
|
+
// Sync pagination changes to URL
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
if (!urlPersistenceConfig?.enabled) return
|
|
326
|
+
if (!paginationConfig?.enabled) return
|
|
327
|
+
|
|
328
|
+
urlState.setPageToURL(pagination.currentPage)
|
|
329
|
+
}, [pagination.currentPage, urlPersistenceConfig?.enabled, paginationConfig?.enabled, urlState])
|
|
330
|
+
|
|
331
|
+
// Sync filter changes to URL
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
if (!urlPersistenceConfig?.enabled) return
|
|
334
|
+
if (!filtersConfig?.enabled) return
|
|
335
|
+
|
|
336
|
+
urlState.setFiltersToURL(filters.filters)
|
|
337
|
+
}, [filters.filters, urlPersistenceConfig?.enabled, filtersConfig?.enabled, urlState])
|
|
338
|
+
|
|
339
|
+
// Initialize search from URL on mount
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
if (!urlPersistenceConfig?.enabled) return
|
|
342
|
+
if (!searchConfig?.enabled) return
|
|
343
|
+
if (!initialURLState?.search) return
|
|
344
|
+
|
|
345
|
+
// Only set if search value is empty (first mount)
|
|
346
|
+
if (!searchConfig.value && initialURLState.search) {
|
|
347
|
+
searchConfig.onChange(initialURLState.search)
|
|
348
|
+
}
|
|
349
|
+
}, []) // Only run on mount
|
|
350
|
+
|
|
351
|
+
// Check if all current page rows are selected
|
|
352
|
+
const allRowsSelected = useMemo(() => {
|
|
353
|
+
if (sortedData.length === 0) return false
|
|
354
|
+
return sortedData.every(row => selection.selectedIds.has(getRowId(row)))
|
|
355
|
+
}, [sortedData, selection.selectedIds, getRowId])
|
|
356
|
+
|
|
357
|
+
// Prepare selected data for export
|
|
358
|
+
const selectedDataForExport = useMemo(() => {
|
|
359
|
+
if (!selectionConfig?.enabled || selection.selectedIds.size === 0) {
|
|
360
|
+
return undefined
|
|
361
|
+
}
|
|
362
|
+
return sortedData.filter(row => selection.selectedIds.has(getRowId(row)))
|
|
363
|
+
}, [sortedData, selection.selectedIds, getRowId, selectionConfig?.enabled])
|
|
364
|
+
|
|
365
|
+
// Handle bulk action execution
|
|
366
|
+
const handleExecuteBulkAction = async (action: typeof bulkActions[0]) => {
|
|
367
|
+
const selectedIds = selection.selectAllPages
|
|
368
|
+
? new Set(sortedData.map(getRowId)) // In real implementation, this would fetch all IDs
|
|
369
|
+
: selection.selectedIds
|
|
370
|
+
|
|
371
|
+
// Show confirmation if needed
|
|
372
|
+
if (action.confirmMessage) {
|
|
373
|
+
const message = action.confirmMessage.replace('{count}', selectedIds.size.toString())
|
|
374
|
+
if (!confirm(message)) {
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Check max selection
|
|
380
|
+
if (action.maxSelection && selectedIds.size > action.maxSelection) {
|
|
381
|
+
alert(`Maximum ${action.maxSelection} items can be selected for this action`)
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
await action.onClick(selectedIds)
|
|
387
|
+
// Clear selection after successful action
|
|
388
|
+
selection.clearSelection()
|
|
389
|
+
} catch (error) {
|
|
390
|
+
console.error('Bulk action failed:', error)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Loading state
|
|
395
|
+
if (loading && sortedData.length === 0) {
|
|
396
|
+
return (
|
|
397
|
+
<div className={cn('flex items-center justify-center p-12', className)}>
|
|
398
|
+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
399
|
+
</div>
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Empty state
|
|
404
|
+
if (!loading && sortedData.length === 0 && emptyState) {
|
|
405
|
+
return <div className={className}>{emptyState}</div>
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return (
|
|
409
|
+
<div ref={tableRef} className={cn('space-y-4', className)} tabIndex={0}>
|
|
410
|
+
{/* Standard Table Toolbar */}
|
|
411
|
+
<StandardTableToolbar
|
|
412
|
+
searchEnabled={searchConfig?.enabled}
|
|
413
|
+
searchPlaceholder={searchConfig?.placeholder}
|
|
414
|
+
searchValue={searchConfig?.value}
|
|
415
|
+
onSearchChange={(value) => {
|
|
416
|
+
if (searchConfig) {
|
|
417
|
+
shouldRestoreFocusRef.current = true
|
|
418
|
+
searchConfig.onChange(value)
|
|
419
|
+
urlState.setSearchToURL(value)
|
|
420
|
+
}
|
|
421
|
+
}}
|
|
422
|
+
searchInputRef={searchInputRef}
|
|
423
|
+
searchAutoFocus={searchConfig?.autoFocus}
|
|
424
|
+
columnVisibilityEnabled={columnVisibilityConfig?.enabled}
|
|
425
|
+
hideableColumns={hideableColumns}
|
|
426
|
+
columnVisibility={columnVisibility}
|
|
427
|
+
onToggleColumnVisibility={toggleColumnVisibility}
|
|
428
|
+
exportEnabled={exportConfig?.enabled}
|
|
429
|
+
exportData={sortedData}
|
|
430
|
+
exportColumns={displayColumns}
|
|
431
|
+
exportProps={exportConfig?.enabled ? {
|
|
432
|
+
filteredData: sortedData.length < safeData.length ? sortedData : undefined,
|
|
433
|
+
selectedData: selectedDataForExport,
|
|
434
|
+
baseFilename: exportConfig.baseFilename,
|
|
435
|
+
showProgress: exportConfig.showProgress,
|
|
436
|
+
onExportStart: exportConfig.onExportStart,
|
|
437
|
+
onExportComplete: exportConfig.onExportComplete,
|
|
438
|
+
onExportError: exportConfig.onExportError,
|
|
439
|
+
} : undefined}
|
|
440
|
+
savedViewsEnabled={savedViewsConfig?.enabled}
|
|
441
|
+
savedViews={savedViewsConfig?.views}
|
|
442
|
+
currentViewId={savedViewsConfig?.currentViewId}
|
|
443
|
+
savedViewsProps={savedViewsConfig?.enabled ? {
|
|
444
|
+
onSaveView: savedViewsConfig.onSaveView,
|
|
445
|
+
onUpdateView: savedViewsConfig.onUpdateView,
|
|
446
|
+
onDeleteView: savedViewsConfig.onDeleteView,
|
|
447
|
+
onLoadView: savedViewsConfig.onLoadView || (() => {}),
|
|
448
|
+
getCurrentViewState,
|
|
449
|
+
} : undefined}
|
|
450
|
+
/>
|
|
451
|
+
|
|
452
|
+
{/* Filters */}
|
|
453
|
+
{filtersConfig?.enabled && filtersConfig.config && (
|
|
454
|
+
<TableFilters config={filtersConfig.config} filters={filters} />
|
|
455
|
+
)}
|
|
456
|
+
|
|
457
|
+
{/* Bulk Actions Bar */}
|
|
458
|
+
{selectionConfig?.enabled && bulkActions.length > 0 && (
|
|
459
|
+
<BulkActionBar
|
|
460
|
+
selectedCount={selection.getSelectedCount()}
|
|
461
|
+
selectAllPages={selection.selectAllPages}
|
|
462
|
+
totalCount={paginationConfig?.totalCount || sortedData.length}
|
|
463
|
+
bulkActions={bulkActions}
|
|
464
|
+
onClearSelection={selection.clearSelection}
|
|
465
|
+
onExecuteAction={handleExecuteBulkAction}
|
|
466
|
+
/>
|
|
467
|
+
)}
|
|
468
|
+
|
|
469
|
+
{/* Selection summary - only show when no bulk actions (BulkActionBar handles this otherwise) */}
|
|
470
|
+
{selectionConfig?.enabled && selection.getSelectedCount() > 0 && bulkActions.length === 0 && (
|
|
471
|
+
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
|
472
|
+
<div>
|
|
473
|
+
{selection.selectAllPages ? (
|
|
474
|
+
<span className="font-medium text-blue-600">
|
|
475
|
+
All {paginationConfig?.totalCount || sortedData.length} items selected
|
|
476
|
+
</span>
|
|
477
|
+
) : (
|
|
478
|
+
<span>
|
|
479
|
+
{selection.getSelectedCount()} item{selection.getSelectedCount() === 1 ? '' : 's'} selected{allRowsSelected ? ' on this page' : ''}
|
|
480
|
+
</span>
|
|
481
|
+
)}
|
|
482
|
+
</div>
|
|
483
|
+
|
|
484
|
+
{/* Show "Select All Pages" button if not all pages selected and there are multiple pages */}
|
|
485
|
+
{!selection.selectAllPages &&
|
|
486
|
+
allRowsSelected &&
|
|
487
|
+
paginationConfig &&
|
|
488
|
+
(paginationConfig.totalCount || 0) > sortedData.length && (
|
|
489
|
+
<button
|
|
490
|
+
onClick={selection.selectAllPagesToggle}
|
|
491
|
+
className="text-blue-600 hover:text-blue-800 font-medium"
|
|
492
|
+
>
|
|
493
|
+
Select all {paginationConfig.totalCount} items
|
|
494
|
+
</button>
|
|
495
|
+
)}
|
|
496
|
+
</div>
|
|
497
|
+
)}
|
|
498
|
+
|
|
499
|
+
{/* Select all pages option - shown below bulk action bar when applicable */}
|
|
500
|
+
{selectionConfig?.enabled &&
|
|
501
|
+
bulkActions.length > 0 &&
|
|
502
|
+
!selection.selectAllPages &&
|
|
503
|
+
allRowsSelected &&
|
|
504
|
+
paginationConfig &&
|
|
505
|
+
(paginationConfig.totalCount || 0) > sortedData.length && (
|
|
506
|
+
<div className="flex items-center justify-center p-2 bg-blue-50 border border-blue-200 rounded-lg">
|
|
507
|
+
<button
|
|
508
|
+
onClick={selection.selectAllPagesToggle}
|
|
509
|
+
className="text-blue-600 hover:text-blue-800 font-medium text-sm"
|
|
510
|
+
>
|
|
511
|
+
Select all {paginationConfig.totalCount} items across all pages
|
|
512
|
+
</button>
|
|
513
|
+
</div>
|
|
514
|
+
)}
|
|
515
|
+
|
|
516
|
+
{/* Data Table */}
|
|
517
|
+
<DataTableCore
|
|
518
|
+
data={sortedData}
|
|
519
|
+
columns={displayColumns}
|
|
520
|
+
getRowId={getRowId}
|
|
521
|
+
selectionEnabled={selectionConfig?.enabled}
|
|
522
|
+
selectedIds={selection.selectedIds}
|
|
523
|
+
onToggleRow={selection.toggleRow}
|
|
524
|
+
onToggleAll={selection.toggleAll}
|
|
525
|
+
allRowsSelected={allRowsSelected}
|
|
526
|
+
renderSelectionCell={selectionConfig?.renderSelectionCell}
|
|
527
|
+
sortingEnabled={sortingEnabled}
|
|
528
|
+
sortBy={currentSortBy}
|
|
529
|
+
sortDirection={currentSortDirection}
|
|
530
|
+
onSort={handleSort}
|
|
531
|
+
columnReorderEnabled={columnReorderConfig?.enabled}
|
|
532
|
+
onColumnDragEnd={columnReorder.handleDragEnd}
|
|
533
|
+
columnResizeEnabled={columnResizeConfig?.enabled}
|
|
534
|
+
columnWidths={columnResize.columnWidths}
|
|
535
|
+
onColumnResizeStart={columnResize.startResize}
|
|
536
|
+
isResizing={columnResize.isResizing}
|
|
537
|
+
inlineEditEnabled={inlineEditConfig?.enabled}
|
|
538
|
+
onCellEdit={inlineEditConfig?.onSave}
|
|
539
|
+
rowActions={rowActions}
|
|
540
|
+
onRowClick={onRowClick}
|
|
541
|
+
loadingRows={loadingRows}
|
|
542
|
+
/>
|
|
543
|
+
|
|
544
|
+
{/* Pagination */}
|
|
545
|
+
{paginationConfig?.enabled && (
|
|
546
|
+
<TablePagination
|
|
547
|
+
pagination={pagination}
|
|
548
|
+
totalFilteredCount={paginationConfig.totalCount}
|
|
549
|
+
/>
|
|
550
|
+
)}
|
|
551
|
+
</div>
|
|
552
|
+
)
|
|
553
|
+
}
|