@object-ui/plugin-grid 3.1.5 → 3.3.1

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +21 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +649 -623
  5. package/dist/index.umd.cjs +8 -8
  6. package/package.json +45 -13
  7. package/.turbo/turbo-build.log +0 -32
  8. package/src/FormulaBar.tsx +0 -151
  9. package/src/GroupRow.tsx +0 -69
  10. package/src/ImportWizard.tsx +0 -412
  11. package/src/InlineEditing.tsx +0 -235
  12. package/src/ListColumnExtensions.test.tsx +0 -373
  13. package/src/ListColumnSchema.test.ts +0 -88
  14. package/src/ObjectGrid.EdgeCases.stories.tsx +0 -147
  15. package/src/ObjectGrid.msw.test.tsx +0 -130
  16. package/src/ObjectGrid.stories.tsx +0 -139
  17. package/src/ObjectGrid.tsx +0 -1596
  18. package/src/SplitPaneGrid.tsx +0 -120
  19. package/src/VirtualGrid.tsx +0 -183
  20. package/src/__tests__/GroupRow.test.tsx +0 -206
  21. package/src/__tests__/ImportPreview.test.tsx +0 -171
  22. package/src/__tests__/InlineEditing.test.tsx +0 -360
  23. package/src/__tests__/VirtualGrid.test.tsx +0 -438
  24. package/src/__tests__/accessibility.test.tsx +0 -254
  25. package/src/__tests__/accessorKey-inference.test.tsx +0 -132
  26. package/src/__tests__/airtable-style.test.tsx +0 -508
  27. package/src/__tests__/column-features.test.tsx +0 -490
  28. package/src/__tests__/grid-export.test.tsx +0 -121
  29. package/src/__tests__/mobile-card-view.test.tsx +0 -355
  30. package/src/__tests__/objectdef-enrichment.test.tsx +0 -566
  31. package/src/__tests__/performance-benchmark.test.tsx +0 -182
  32. package/src/__tests__/phase11-features.test.tsx +0 -418
  33. package/src/__tests__/row-bulk-actions.test.tsx +0 -413
  34. package/src/__tests__/row-height.test.tsx +0 -160
  35. package/src/__tests__/useGroupedData.test.ts +0 -165
  36. package/src/__tests__/view-states.test.tsx +0 -203
  37. package/src/components/BulkActionBar.tsx +0 -66
  38. package/src/components/RowActionMenu.tsx +0 -91
  39. package/src/index.test.tsx +0 -29
  40. package/src/index.tsx +0 -99
  41. package/src/useCellClipboard.ts +0 -136
  42. package/src/useColumnSummary.ts +0 -128
  43. package/src/useGradientColor.ts +0 -103
  44. package/src/useGroupReorder.ts +0 -123
  45. package/src/useGroupedData.ts +0 -187
  46. package/src/useRowColor.ts +0 -74
  47. package/tsconfig.json +0 -9
  48. package/vite.config.ts +0 -57
  49. package/vitest.config.ts +0 -13
  50. package/vitest.setup.ts +0 -1
  51. /package/dist/{plugin-grid → packages/plugin-grid}/src/FormulaBar.d.ts +0 -0
  52. /package/dist/{plugin-grid → packages/plugin-grid}/src/GroupRow.d.ts +0 -0
  53. /package/dist/{plugin-grid → packages/plugin-grid}/src/ImportWizard.d.ts +0 -0
  54. /package/dist/{plugin-grid → packages/plugin-grid}/src/InlineEditing.d.ts +0 -0
  55. /package/dist/{plugin-grid → packages/plugin-grid}/src/ObjectGrid.d.ts +0 -0
  56. /package/dist/{plugin-grid → packages/plugin-grid}/src/SplitPaneGrid.d.ts +0 -0
  57. /package/dist/{plugin-grid → packages/plugin-grid}/src/VirtualGrid.d.ts +0 -0
  58. /package/dist/{plugin-grid → packages/plugin-grid}/src/components/BulkActionBar.d.ts +0 -0
  59. /package/dist/{plugin-grid → packages/plugin-grid}/src/components/RowActionMenu.d.ts +0 -0
  60. /package/dist/{plugin-grid → packages/plugin-grid}/src/index.d.ts +0 -0
  61. /package/dist/{plugin-grid → packages/plugin-grid}/src/useCellClipboard.d.ts +0 -0
  62. /package/dist/{plugin-grid → packages/plugin-grid}/src/useColumnSummary.d.ts +0 -0
  63. /package/dist/{plugin-grid → packages/plugin-grid}/src/useGradientColor.d.ts +0 -0
  64. /package/dist/{plugin-grid → packages/plugin-grid}/src/useGroupReorder.d.ts +0 -0
  65. /package/dist/{plugin-grid → packages/plugin-grid}/src/useGroupedData.d.ts +0 -0
  66. /package/dist/{plugin-grid → packages/plugin-grid}/src/useRowColor.d.ts +0 -0
@@ -1,1596 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- /**
10
- * ObjectGrid Component
11
- *
12
- * A specialized grid component built on top of data-table.
13
- * Auto-generates columns from ObjectQL schema with type-aware rendering.
14
- * Implements the grid view type from @objectstack/spec view.zod ListView schema.
15
- *
16
- * Features:
17
- * - Traditional table/grid with CRUD operations
18
- * - Search, filters, pagination
19
- * - Column resizing, sorting
20
- * - Row selection
21
- * - Inline editing support
22
- */
23
-
24
- import React, { useEffect, useState, useCallback, useMemo } from 'react';
25
- import type { ObjectGridSchema, DataSource, ListColumn, ViewData } from '@object-ui/types';
26
- import type { I18nLabel } from '@objectstack/spec/ui';
27
- import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useObjectTranslation, useSafeFieldLabel } from '@object-ui/react';
28
- import { getCellRenderer, formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel } from '@object-ui/fields';
29
- import {
30
- Badge, Button, NavigationOverlay,
31
- Popover, PopoverContent, PopoverTrigger,
32
- } from '@object-ui/components';
33
- import { usePullToRefresh } from '@object-ui/mobile';
34
- import { evaluatePlainCondition, buildExpandFields } from '@object-ui/core';
35
- import { ChevronRight, ChevronDown, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock } from 'lucide-react';
36
- import { useRowColor } from './useRowColor';
37
- import { useGroupedData } from './useGroupedData';
38
- import { GroupRow } from './GroupRow';
39
- import { useColumnSummary } from './useColumnSummary';
40
- import { RowActionMenu, formatActionLabel } from './components/RowActionMenu';
41
- import { BulkActionBar } from './components/BulkActionBar';
42
-
43
- // Default English fallback translations for the grid
44
- const GRID_DEFAULT_TRANSLATIONS: Record<string, string> = {
45
- 'grid.actions': 'Actions',
46
- 'grid.edit': 'Edit',
47
- 'grid.delete': 'Delete',
48
- 'grid.export': 'Export',
49
- 'grid.exportAs': 'Export as {{format}}',
50
- 'grid.loading': 'Loading grid...',
51
- 'grid.errorLoading': 'Error loading grid',
52
- 'grid.pullToRefresh': 'Pull to refresh',
53
- 'grid.refreshing': 'Refreshing…',
54
- 'grid.openRecord': 'Open record',
55
- };
56
-
57
- /**
58
- * Safe wrapper for useObjectTranslation that falls back to English defaults
59
- * when I18nProvider is not available (e.g., standalone usage).
60
- */
61
- function useGridTranslation() {
62
- try {
63
- const result = useObjectTranslation();
64
- const testValue = result.t('grid.actions');
65
- if (testValue === 'grid.actions') {
66
- return {
67
- t: (key: string, options?: Record<string, unknown>) => {
68
- let value = GRID_DEFAULT_TRANSLATIONS[key] || key;
69
- if (options) {
70
- for (const [k, v] of Object.entries(options)) {
71
- value = value.replace(`{{${k}}}`, String(v));
72
- }
73
- }
74
- return value;
75
- },
76
- };
77
- }
78
- return { t: result.t };
79
- } catch {
80
- return {
81
- t: (key: string, options?: Record<string, unknown>) => {
82
- let value = GRID_DEFAULT_TRANSLATIONS[key] || key;
83
- if (options) {
84
- for (const [k, v] of Object.entries(options)) {
85
- value = value.replace(`{{${k}}}`, String(v));
86
- }
87
- }
88
- return value;
89
- },
90
- };
91
- }
92
- }
93
-
94
- /** Resolve an I18nLabel (string | {key, defaultValue}) to a plain string. */
95
- function resolveColumnLabel(label: string | I18nLabel | undefined): string | undefined {
96
- if (label == null) return undefined;
97
- if (typeof label === 'string') return label;
98
- return label.defaultValue || label.key;
99
- }
100
-
101
- export interface ObjectGridProps {
102
- schema: ObjectGridSchema;
103
- dataSource?: DataSource;
104
- className?: string;
105
- onRowClick?: (record: any) => void;
106
- onEdit?: (record: any) => void;
107
- onDelete?: (record: any) => void;
108
- onBulkDelete?: (records: any[]) => void;
109
- onCellChange?: (rowIndex: number, columnKey: string, newValue: any, row: any) => void;
110
- onRowSave?: (rowIndex: number, changes: Record<string, any>, row: any) => void | Promise<void>;
111
- onBatchSave?: (changes: Array<{ rowIndex: number; changes: Record<string, any>; row: any }>) => void | Promise<void>;
112
- onRowSelect?: (selectedRows: any[]) => void;
113
- onAddRecord?: () => void;
114
- }
115
-
116
- /**
117
- * Helper to get data configuration from schema
118
- * Handles both new ViewData format and legacy inline data
119
- */
120
- function getDataConfig(schema: ObjectGridSchema): ViewData | null {
121
- // New format: explicit data configuration
122
- if (schema.data) {
123
- // Check if data is an array (shorthand format) or already a ViewData object
124
- if (Array.isArray(schema.data)) {
125
- // Convert array shorthand to proper ViewData format
126
- return {
127
- provider: 'value',
128
- items: schema.data,
129
- };
130
- }
131
- // Already in ViewData format
132
- return schema.data;
133
- }
134
-
135
- // Legacy format: staticData field
136
- if (schema.staticData) {
137
- return {
138
- provider: 'value',
139
- items: schema.staticData,
140
- };
141
- }
142
-
143
- // Default: use object provider with objectName
144
- if (schema.objectName) {
145
- return {
146
- provider: 'object',
147
- object: schema.objectName,
148
- };
149
- }
150
-
151
- return null;
152
- }
153
-
154
- /**
155
- * Helper to normalize columns configuration
156
- * Handles both string[] and ListColumn[] formats
157
- */
158
- function normalizeColumns(
159
- columns: string[] | ListColumn[] | undefined
160
- ): ListColumn[] | string[] | undefined {
161
- if (!columns || columns.length === 0) return undefined;
162
-
163
- // Already in ListColumn format - check for object type with optional chaining
164
- if (typeof columns[0] === 'object' && columns[0] !== null) {
165
- return columns as ListColumn[];
166
- }
167
-
168
- // String array format
169
- return columns as string[];
170
- }
171
-
172
- export const ObjectGrid: React.FC<ObjectGridProps> = ({
173
- schema,
174
- dataSource,
175
- onEdit,
176
- onDelete,
177
- onRowSelect,
178
- onRowClick,
179
- onCellChange,
180
- onRowSave,
181
- onBatchSave,
182
- onAddRecord,
183
- ...rest
184
- }) => {
185
- const [data, setData] = useState<any[]>([]);
186
- const [loading, setLoading] = useState(true);
187
- const [error, setError] = useState<Error | null>(null);
188
- const { t } = useGridTranslation();
189
- const { fieldLabel: resolveFieldLabel } = useSafeFieldLabel();
190
- const [objectSchema, setObjectSchema] = useState<any>(null);
191
- const [useCardView, setUseCardView] = useState(false);
192
- const [refreshKey, setRefreshKey] = useState(0);
193
- const [showExport, setShowExport] = useState(false);
194
- const [rowHeightMode, setRowHeightMode] = useState<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'>(schema.rowHeight ?? 'compact');
195
- const [selectedRows, setSelectedRows] = useState<any[]>([]);
196
-
197
- // Column state persistence (order and widths)
198
- const columnStorageKey = React.useMemo(() => {
199
- return schema.id
200
- ? `grid-columns-${schema.objectName}-${schema.id}`
201
- : `grid-columns-${schema.objectName}`;
202
- }, [schema.objectName, schema.id]);
203
-
204
- const [columnState, setColumnState] = useState<{
205
- order?: string[];
206
- widths?: Record<string, number>;
207
- }>(() => {
208
- try {
209
- const saved = localStorage.getItem(columnStorageKey);
210
- return saved ? JSON.parse(saved) : {};
211
- } catch {
212
- return {};
213
- }
214
- });
215
-
216
- const saveColumnState = useCallback((state: typeof columnState) => {
217
- setColumnState(state);
218
- try {
219
- localStorage.setItem(columnStorageKey, JSON.stringify(state));
220
- } catch (e) {
221
- console.warn('Failed to persist column state:', e);
222
- }
223
- }, [columnStorageKey]);
224
-
225
- const handlePullRefresh = useCallback(async () => {
226
- setRefreshKey(k => k + 1);
227
- }, []);
228
-
229
- const { ref: pullRef, isRefreshing, pullDistance } = usePullToRefresh<HTMLDivElement>({
230
- onRefresh: handlePullRefresh,
231
- enabled: !!dataSource && !!schema.objectName,
232
- });
233
-
234
- useEffect(() => {
235
- const checkWidth = () => setUseCardView(window.innerWidth < 480);
236
- checkWidth();
237
- window.addEventListener('resize', checkWidth);
238
- return () => window.removeEventListener('resize', checkWidth);
239
- }, []);
240
-
241
- // Check if data is passed directly (from ListView)
242
- const passedData = (rest as any).data;
243
-
244
- // Resolve bound data if 'bind' property exists
245
- const boundData = useDataScope(schema.bind);
246
-
247
- // Get data configuration (supports both new and legacy formats)
248
- const rawDataConfig = getDataConfig(schema);
249
- // Memoize dataConfig using deep comparison to prevent infinite loops
250
- const dataConfig = React.useMemo(() => {
251
- // If we have passed data (highest priority), treat it as value provider
252
- if (passedData && Array.isArray(passedData)) {
253
- return {
254
- provider: 'value',
255
- items: passedData
256
- };
257
- }
258
-
259
- // If we have bound data, it takes precedence as inline value
260
- if (boundData && Array.isArray(boundData)) {
261
- return {
262
- provider: 'value',
263
- items: boundData
264
- };
265
- }
266
- return rawDataConfig;
267
- }, [JSON.stringify(rawDataConfig), boundData, passedData]);
268
-
269
- const hasInlineData = dataConfig?.provider === 'value';
270
-
271
- // Extract stable primitive/reference-stable values from schema for dependency arrays.
272
- // This prevents infinite re-render loops when schema is a new object on each render
273
- // (e.g. when rendered through SchemaRenderer which creates a fresh evaluatedSchema).
274
- const objectName = dataConfig?.provider === 'object' && dataConfig && 'object' in dataConfig
275
- ? (dataConfig as any).object
276
- : schema.objectName;
277
- const schemaFields = schema.fields;
278
- const schemaColumns = schema.columns;
279
- const schemaFilter = schema.filter;
280
- const schemaSort = schema.sort;
281
- const schemaPagination = schema.pagination;
282
- const schemaPageSize = schema.pageSize;
283
-
284
- // --- Inline data effect (synchronous, no fetch needed) ---
285
- useEffect(() => {
286
- if (hasInlineData && dataConfig?.provider === 'value') {
287
- // Only update if data is different to avoid infinite loop
288
- setData(prev => {
289
- const newItems = dataConfig.items as any[];
290
- if (JSON.stringify(prev) !== JSON.stringify(newItems)) {
291
- return newItems;
292
- }
293
- return prev;
294
- });
295
- setLoading(false);
296
- }
297
- }, [hasInlineData, dataConfig]);
298
-
299
- // --- Inline data: still fetch objectSchema for type-aware rendering ---
300
- // When data is inline (provider: 'value'), we skip the data fetch but still need
301
- // the object schema to resolve field types (lookup, select, currency, etc.) and
302
- // enable proper CellRenderer selection.
303
- useEffect(() => {
304
- if (!hasInlineData) return;
305
- if (!objectName || !dataSource) return;
306
-
307
- let cancelled = false;
308
-
309
- const fetchSchema = async () => {
310
- try {
311
- const schemaData = await dataSource.getObjectSchema(objectName);
312
- if (!cancelled) {
313
- setObjectSchema(schemaData);
314
- }
315
- } catch (err) {
316
- // Schema fetch failure for inline data is non-fatal; columns will
317
- // still fall back to heuristic inference.
318
- console.warn(`[ObjectGrid] Failed to fetch objectSchema for inline data (objectName: ${objectName}):`, err);
319
- }
320
- };
321
-
322
- fetchSchema();
323
-
324
- return () => { cancelled = true; };
325
- }, [hasInlineData, objectName, dataSource]);
326
-
327
- // --- Unified async data loading effect ---
328
- // Combines schema fetch + data fetch into a single async flow with AbortController.
329
- // This avoids the fragile "chained effects" pattern where Effect 1 sets objectSchema,
330
- // triggering Effect 2 to call fetchData — a pattern prone to infinite loops when
331
- // fetchData's reference is unstable.
332
- useEffect(() => {
333
- if (hasInlineData) return;
334
-
335
- let cancelled = false;
336
-
337
- const loadSchemaAndData = async () => {
338
- setLoading(true);
339
- setError(null);
340
- try {
341
- // --- Step 1: Resolve object schema ---
342
- let resolvedSchema: any = null;
343
- const cols = normalizeColumns(schemaColumns) || schemaFields;
344
-
345
- if (objectName && dataSource) {
346
- // Always fetch full schema for field type metadata (enables rich type-aware rendering)
347
- const schemaData = await dataSource.getObjectSchema(objectName);
348
- if (cancelled) return;
349
- resolvedSchema = schemaData;
350
- } else if (cols && objectName) {
351
- // Fallback: minimal schema stub when no dataSource available
352
- resolvedSchema = { name: objectName, fields: {} };
353
- } else if (!objectName) {
354
- throw new Error('Object name required for data fetching');
355
- } else {
356
- throw new Error('DataSource required');
357
- }
358
-
359
- if (!cancelled) {
360
- setObjectSchema(resolvedSchema);
361
- }
362
-
363
- // --- Step 2: Fetch data ---
364
- if (dataSource && objectName) {
365
- const getSelectFields = () => {
366
- if (schemaFields) return schemaFields;
367
- if (schemaColumns && Array.isArray(schemaColumns)) {
368
- return schemaColumns.map((c: any) => typeof c === 'string' ? c : c.field);
369
- }
370
- return undefined;
371
- };
372
-
373
- const params: any = {
374
- $select: getSelectFields(),
375
- $top: (schemaPagination as any)?.pageSize || schemaPageSize || 50,
376
- };
377
-
378
- // Support new filter format
379
- if (schemaFilter && Array.isArray(schemaFilter)) {
380
- params.$filter = schemaFilter;
381
- } else if (schema.defaultFilters) {
382
- // Legacy support
383
- params.$filter = schema.defaultFilters;
384
- }
385
-
386
- // Support new sort format
387
- if (schemaSort) {
388
- if (typeof schemaSort === 'string') {
389
- params.$orderby = schemaSort;
390
- } else if (Array.isArray(schemaSort)) {
391
- params.$orderby = schemaSort
392
- .map((s: any) => `${s.field} ${s.order}`)
393
- .join(', ');
394
- }
395
- } else if (schema.defaultSort) {
396
- // Legacy support
397
- params.$orderby = `${(schema.defaultSort as any).field} ${(schema.defaultSort as any).order}`;
398
- }
399
-
400
- // Auto-inject $expand for lookup/master_detail fields
401
- const expand = buildExpandFields(resolvedSchema?.fields, schemaColumns ?? schemaFields);
402
- if (expand.length > 0) {
403
- params.$expand = expand;
404
- }
405
-
406
- const result = await dataSource.find(objectName, params);
407
- if (cancelled) return;
408
- setData(result.data || []);
409
- }
410
- } catch (err) {
411
- if (!cancelled) {
412
- setError(err as Error);
413
- }
414
- } finally {
415
- if (!cancelled) {
416
- setLoading(false);
417
- }
418
- }
419
- };
420
-
421
- loadSchemaAndData();
422
-
423
- return () => {
424
- cancelled = true;
425
- };
426
- }, [objectName, schemaFields, schemaColumns, schemaFilter, schemaSort, schemaPagination, schemaPageSize, dataSource, hasInlineData, dataConfig, refreshKey]);
427
-
428
- // --- NavigationConfig support ---
429
- // Must be called before any early returns to satisfy React hooks rules
430
- const navigation = useNavigationOverlay({
431
- navigation: schema.navigation,
432
- objectName: schema.objectName,
433
- onNavigate: schema.onNavigate,
434
- onRowClick,
435
- });
436
-
437
- // --- Action support for action columns ---
438
- const { execute: executeAction } = useAction();
439
-
440
- // --- Row color support ---
441
- const getRowClassName = useRowColor(schema.rowColor);
442
-
443
- // --- Conditional formatting support ---
444
- const getRowStyle = useCallback((row: Record<string, unknown>): React.CSSProperties | undefined => {
445
- const rules = schema.conditionalFormatting;
446
- if (!rules || rules.length === 0) return undefined;
447
- for (const rule of rules) {
448
- let match = false;
449
- const expression =
450
- ('condition' in rule ? (rule as any).condition : undefined)
451
- || ('expression' in rule ? (rule as any).expression : undefined)
452
- || undefined;
453
- if (expression) {
454
- match = evaluatePlainCondition(expression, row as Record<string, any>);
455
- } else if ('field' in rule && 'operator' in rule && (rule as any).field && (rule as any).operator) {
456
- const r = rule as any;
457
- const fieldValue = row[r.field];
458
- switch (r.operator) {
459
- case 'equals': match = fieldValue === r.value; break;
460
- case 'not_equals': match = fieldValue !== r.value; break;
461
- case 'contains': match = typeof fieldValue === 'string' && typeof r.value === 'string' && fieldValue.includes(r.value); break;
462
- case 'greater_than': match = typeof fieldValue === 'number' && typeof r.value === 'number' && fieldValue > r.value; break;
463
- case 'less_than': match = typeof fieldValue === 'number' && typeof r.value === 'number' && fieldValue < r.value; break;
464
- case 'in': match = Array.isArray(r.value) && r.value.includes(fieldValue); break;
465
- }
466
- }
467
- if (match) {
468
- const style: React.CSSProperties = {};
469
- if ('style' in rule && (rule as any).style) Object.assign(style, (rule as any).style);
470
- if ('backgroundColor' in rule && (rule as any).backgroundColor) style.backgroundColor = (rule as any).backgroundColor;
471
- if ('textColor' in rule && (rule as any).textColor) style.color = (rule as any).textColor;
472
- if ('borderColor' in rule && (rule as any).borderColor) style.borderColor = (rule as any).borderColor;
473
- return style;
474
- }
475
- }
476
- return undefined;
477
- }, [schema.conditionalFormatting]);
478
-
479
- // --- Grouping support ---
480
- const { groups, isGrouped, toggleGroup } = useGroupedData(schema.grouping, data);
481
-
482
- // --- Column summary support ---
483
- const summaryColumns = React.useMemo(() => {
484
- const cols = normalizeColumns(schema.columns);
485
- if (cols && cols.length > 0 && typeof cols[0] === 'object') {
486
- return cols as ListColumn[];
487
- }
488
- return undefined;
489
- }, [schema.columns]);
490
- const { summaries, hasSummary } = useColumnSummary(summaryColumns, data);
491
-
492
- const generateColumns = useCallback(() => {
493
- // Map field type to column header icon (Airtable-style)
494
- const getTypeIcon = (fieldType: string | null): React.ReactNode => {
495
- if (!fieldType) return <Type className="h-3.5 w-3.5" />;
496
- const iconMap: Record<string, React.ReactNode> = {
497
- text: <Type className="h-3.5 w-3.5" />,
498
- number: <Hash className="h-3.5 w-3.5" />,
499
- currency: <Hash className="h-3.5 w-3.5" />,
500
- percent: <Hash className="h-3.5 w-3.5" />,
501
- date: <Calendar className="h-3.5 w-3.5" />,
502
- datetime: <Clock className="h-3.5 w-3.5" />,
503
- boolean: <CheckSquare className="h-3.5 w-3.5" />,
504
- user: <User className="h-3.5 w-3.5" />,
505
- select: <Tag className="h-3.5 w-3.5" />,
506
- };
507
- return iconMap[fieldType] || <Type className="h-3.5 w-3.5" />;
508
- };
509
-
510
- // Auto-infer column type from field name and data values (Airtable-style)
511
- const inferColumnType = (col: ListColumn): string | null => {
512
- if (col.type) return col.type; // Explicit type takes priority
513
-
514
- const fieldLower = col.field.toLowerCase();
515
-
516
- // Infer boolean fields
517
- const booleanFields = ['completed', 'is_completed', 'done', 'active', 'enabled', 'archived'];
518
- if (booleanFields.some(f => fieldLower === f || fieldLower === `is_${f}`)) {
519
- return 'boolean';
520
- }
521
-
522
- // Infer datetime fields (fields with time component: created_time, modified_time, *_at patterns)
523
- const datetimePatterns = ['created_time', 'modified_time', 'updated_time', 'created_at', 'updated_at', 'modified_at', 'last_login', 'logged_at'];
524
- if (datetimePatterns.some(p => fieldLower === p || fieldLower.endsWith(`_${p}`))) {
525
- return 'datetime';
526
- }
527
-
528
- // Infer date fields from name patterns
529
- const datePatterns = ['date', 'due', 'created', 'updated', 'deadline', 'start', 'end', 'expires'];
530
- if (datePatterns.some(p => fieldLower.includes(p))) {
531
- // Verify with data: check if sample values look like dates
532
- if (data.length > 0) {
533
- const sample = data.find(row => row[col.field] != null)?.[col.field];
534
- if (typeof sample === 'string' && !isNaN(Date.parse(sample))) {
535
- return 'date';
536
- }
537
- }
538
- return 'date';
539
- }
540
-
541
- // Infer percent fields from name patterns
542
- const percentFields = ['probability', 'percent', 'percentage', 'completion', 'progress', 'rate'];
543
- if (percentFields.some(f => fieldLower.includes(f))) {
544
- if (data.length > 0) {
545
- const sample = data.find(row => row[col.field] != null)?.[col.field];
546
- if (typeof sample === 'number') {
547
- return 'percent';
548
- }
549
- }
550
- }
551
-
552
- // Infer select/badge fields (status, priority, category, etc.)
553
- const selectFields = ['status', 'priority', 'category', 'stage', 'type', 'severity', 'level'];
554
- if (selectFields.some(f => fieldLower.includes(f))) {
555
- if (data.length > 0) {
556
- const uniqueValues = new Set(data.map(row => row[col.field]).filter(Boolean));
557
- if (uniqueValues.size > 0 && uniqueValues.size <= 10) {
558
- return 'select';
559
- }
560
- }
561
- }
562
-
563
- // Infer user/assignee fields
564
- const userFields = ['assignee', 'owner', 'author', 'reporter', 'creator', 'user'];
565
- if (userFields.some(f => fieldLower.includes(f))) {
566
- return 'user';
567
- }
568
-
569
- // Infer currency/amount fields
570
- const currencyFields = ['amount', 'price', 'total', 'revenue', 'cost', 'budget', 'salary'];
571
- if (currencyFields.some(f => fieldLower.includes(f))) {
572
- if (data.length > 0) {
573
- const sample = data.find(row => row[col.field] != null)?.[col.field];
574
- if (typeof sample === 'number') {
575
- return 'currency';
576
- }
577
- }
578
- }
579
-
580
- // Fallback: detect ISO date strings in data values (catch-all for unmatched field names)
581
- if (data.length > 0) {
582
- const sample = data.find(row => row[col.field] != null)?.[col.field];
583
- if (typeof sample === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(sample)) {
584
- return 'datetime';
585
- }
586
- }
587
-
588
- return null;
589
- };
590
-
591
- // Use normalized columns (support both new and legacy)
592
- const cols = normalizeColumns(schemaColumns);
593
-
594
- if (cols) {
595
- // Check if columns are already in data-table format (have 'accessorKey')
596
- // vs ListColumn format (have 'field')
597
- if (cols.length > 0 && typeof cols[0] === 'object' && cols[0] !== null) {
598
- const firstCol = cols[0] as any;
599
-
600
- // Already in data-table format - apply type inference for columns without custom cell renderers
601
- if ('accessorKey' in firstCol) {
602
- return (cols as any[]).map((col) => {
603
- if (col.cell) return col; // already has custom renderer
604
-
605
- const syntheticCol: ListColumn = { field: col.accessorKey, label: col.header, type: col.type };
606
- const inferredType = inferColumnType(syntheticCol);
607
- if (!inferredType) return col;
608
-
609
- const CellRenderer = getCellRenderer(inferredType);
610
- const fieldMeta: Record<string, any> = { name: col.accessorKey, type: inferredType };
611
-
612
- if (inferredType === 'select') {
613
- const uniqueValues = Array.from(new Set(data.map(row => row[col.accessorKey]).filter(Boolean)));
614
- fieldMeta.options = uniqueValues.map((v: any) => ({ value: v, label: humanizeLabel(String(v)) }));
615
- }
616
-
617
- return {
618
- ...col,
619
- headerIcon: getTypeIcon(inferredType),
620
- cell: (value: any) => <CellRenderer value={value} field={fieldMeta as any} />,
621
- };
622
- });
623
- }
624
-
625
- // ListColumn format - convert to data-table format with full feature support
626
- if ('field' in firstCol) {
627
- return (cols as ListColumn[])
628
- .filter((col) => col?.field && typeof col.field === 'string' && !col.hidden)
629
- .map((col, colIndex) => {
630
- const rawHeader = resolveColumnLabel(col.label) || col.field.charAt(0).toUpperCase() + col.field.slice(1).replace(/_/g, ' ');
631
- const header = schema.objectName ? resolveFieldLabel(schema.objectName, col.field, rawHeader) : rawHeader;
632
-
633
- // Build custom cell renderer based on column configuration
634
- let cellRenderer: ((value: any, row: any) => React.ReactNode) | undefined;
635
-
636
- // Type-based cell renderer: explicit col type > objectDef type > heuristic inference
637
- const objectDefField = objectSchema?.fields?.[col.field];
638
- const inferredType = col.type || objectDefField?.type || inferColumnType({ field: col.field }) || null;
639
- const CellRenderer = inferredType ? getCellRenderer(inferredType) : null;
640
-
641
- // Build field metadata for cell renderers with objectDef enrichment
642
- const fieldMeta: Record<string, any> = { name: col.field, type: inferredType || 'text' };
643
- // Merge objectDef field properties (options with colors, currency, precision, etc.)
644
- if (objectDefField) {
645
- if (objectDefField.label) fieldMeta.label = objectDefField.label;
646
- if (objectDefField.currency) fieldMeta.currency = objectDefField.currency;
647
- if (objectDefField.precision !== undefined) fieldMeta.precision = objectDefField.precision;
648
- if (objectDefField.format) fieldMeta.format = objectDefField.format;
649
- if (objectDefField.options) fieldMeta.options = objectDefField.options;
650
- }
651
- // Auto-generate options from data for inferred select without existing options
652
- if (inferredType === 'select' && !fieldMeta.options) {
653
- const uniqueValues = Array.from(new Set(data.map(row => row[col.field]).filter(Boolean)));
654
- fieldMeta.options = uniqueValues.map(v => ({ value: v, label: humanizeLabel(String(v)) }));
655
- }
656
- if ((col as any).options) {
657
- fieldMeta.options = (col as any).options;
658
- }
659
-
660
- // Auto-link primary field (first column) to record detail (Airtable-style)
661
- const isPrimaryField = colIndex === 0 && !col.link && !col.action;
662
- const isLinked = col.link || isPrimaryField;
663
-
664
- if ((col.link && col.action) || (isPrimaryField && col.action)) {
665
- // Both link and action: link takes priority for navigation, action executes on secondary interaction
666
- cellRenderer = (value: any, row: any) => {
667
- const displayContent = CellRenderer
668
- ? <CellRenderer value={value} field={fieldMeta as any} />
669
- : (value != null && value !== '' ? String(value) : <span className="text-muted-foreground/50 text-xs italic">—</span>);
670
- return (
671
- <button
672
- type="button"
673
- className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
674
- data-testid={isPrimaryField ? 'primary-field-link' : 'link-cell'}
675
- onClick={(e) => {
676
- e.stopPropagation();
677
- navigation.handleClick(row);
678
- }}
679
- >
680
- {displayContent}
681
- </button>
682
- );
683
- };
684
- } else if (isLinked) {
685
- // Link column: clicking navigates to the record detail
686
- cellRenderer = (value: any, row: any) => {
687
- const displayContent = CellRenderer
688
- ? <CellRenderer value={value} field={fieldMeta as any} />
689
- : (value != null && value !== '' ? String(value) : <span className="text-muted-foreground/50 text-xs italic">—</span>);
690
- return (
691
- <button
692
- type="button"
693
- className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
694
- data-testid={isPrimaryField ? 'primary-field-link' : 'link-cell'}
695
- onClick={(e) => {
696
- e.stopPropagation();
697
- navigation.handleClick(row);
698
- }}
699
- >
700
- {displayContent}
701
- </button>
702
- );
703
- };
704
- } else if (col.action) {
705
- // Action column: render as action button
706
- cellRenderer = (value: any, row: any) => {
707
- return (
708
- <Button
709
- variant="outline"
710
- size="sm"
711
- className="h-7 text-xs"
712
- data-testid="action-cell"
713
- onClick={(e) => {
714
- e.stopPropagation();
715
- executeAction({
716
- type: col.action!,
717
- params: { record: row, field: col.field, value },
718
- });
719
- }}
720
- >
721
- {formatActionLabel(col.action!)}
722
- </Button>
723
- );
724
- };
725
- } else if (CellRenderer) {
726
- // Type-only cell renderer (no link/action)
727
- cellRenderer = (value: any) => (
728
- <CellRenderer value={value} field={fieldMeta as any} />
729
- );
730
- } else {
731
- // Default renderer with empty value handling
732
- cellRenderer = (value: any) => (
733
- value != null && value !== ''
734
- ? <span>{String(value)}</span>
735
- : <span className="text-muted-foreground/50 text-xs italic">—</span>
736
- );
737
- }
738
-
739
- // Wrap with prefix compound cell renderer (Airtable-style: [Badge] Text in same cell)
740
- const prefixConfig = (col as any).prefix;
741
- if (prefixConfig?.field) {
742
- const baseCellRenderer = cellRenderer;
743
- const PrefixRenderer = prefixConfig.type === 'badge' ? getCellRenderer('select') : null;
744
- cellRenderer = (value: any, row: any) => {
745
- const prefixValue = row[prefixConfig.field];
746
- const prefixEl = prefixValue != null && prefixValue !== ''
747
- ? PrefixRenderer
748
- ? <PrefixRenderer value={prefixValue} field={{ name: prefixConfig.field, type: 'select' } as any} />
749
- : <span className="text-muted-foreground text-xs mr-1.5">{String(prefixValue)}</span>
750
- : null;
751
- return (
752
- <span className="flex items-center gap-1.5">
753
- {prefixEl}
754
- {baseCellRenderer(value, row)}
755
- </span>
756
- );
757
- };
758
- }
759
-
760
- // Auto-infer alignment from field type if not explicitly set
761
- const numericTypes = ['number', 'currency', 'percent'];
762
- const effectiveType = inferredType || col.type;
763
- const inferredAlign = col.align || (effectiveType && numericTypes.includes(effectiveType) ? 'right' as const : undefined);
764
-
765
- // Determine if column should be hidden on mobile
766
- const isEssential = colIndex === 0 || (col as any).essential === true;
767
-
768
- return {
769
- header,
770
- accessorKey: col.field,
771
- headerIcon: getTypeIcon(inferredType),
772
- ...(!isEssential && { className: 'hidden sm:table-cell' }),
773
- ...(col.width && { width: col.width }),
774
- ...(inferredAlign && { align: inferredAlign }),
775
- sortable: col.sortable !== false,
776
- ...(col.resizable !== undefined && { resizable: col.resizable }),
777
- ...(col.wrap !== undefined && { wrap: col.wrap }),
778
- ...(cellRenderer && { cell: cellRenderer }),
779
- ...(col.pinned && { pinned: col.pinned }),
780
- };
781
- });
782
- }
783
- }
784
-
785
- // String array format - enrich with objectDef field metadata for type-aware rendering
786
- return (cols as string[])
787
- .filter((fieldName) => typeof fieldName === 'string' && fieldName.trim().length > 0)
788
- .map((fieldName, colIndex) => {
789
- const fieldDef = objectSchema?.fields?.[fieldName];
790
- const rawFieldLabel = fieldDef?.label;
791
- const rawHeader = rawFieldLabel || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' ');
792
- const header = schema.objectName ? resolveFieldLabel(schema.objectName, fieldName, rawHeader) : rawHeader;
793
-
794
- // Resolve type: objectDef type > heuristic inference (consistent with ListColumn path)
795
- const resolvedType = fieldDef?.type || inferColumnType({ field: fieldName }) || null;
796
- const CellRenderer = resolvedType ? getCellRenderer(resolvedType) : null;
797
-
798
- // Build field metadata with objectDef enrichment
799
- const fieldMeta: Record<string, any> = { name: fieldName, type: resolvedType || 'text' };
800
- if (fieldDef) {
801
- if (fieldDef.label) fieldMeta.label = fieldDef.label;
802
- if (fieldDef.currency) fieldMeta.currency = fieldDef.currency;
803
- if (fieldDef.precision !== undefined) fieldMeta.precision = fieldDef.precision;
804
- if (fieldDef.format) fieldMeta.format = fieldDef.format;
805
- if (fieldDef.options) fieldMeta.options = fieldDef.options;
806
- }
807
- // Auto-generate select options from data when no options defined
808
- if (resolvedType === 'select' && !fieldMeta.options) {
809
- const uniqueValues = Array.from(new Set(data.map(row => row[fieldName]).filter(Boolean)));
810
- fieldMeta.options = uniqueValues.map((v: any) => ({ value: v, label: humanizeLabel(String(v)) }));
811
- }
812
-
813
- const numericTypes = ['number', 'currency', 'percent'];
814
- const inferredAlign = resolvedType && numericTypes.includes(resolvedType) ? 'right' as const : undefined;
815
-
816
- // Auto-link primary field (first column) to record detail
817
- const isPrimaryField = colIndex === 0;
818
- let cellRenderer: ((value: any, row?: any) => React.ReactNode) | undefined;
819
-
820
- if (isPrimaryField && CellRenderer) {
821
- cellRenderer = (value: any, row: any) => {
822
- const displayContent = <CellRenderer value={value} field={fieldMeta as any} />;
823
- return (
824
- <button
825
- type="button"
826
- className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
827
- data-testid="primary-field-link"
828
- onClick={(e) => { e.stopPropagation(); navigation.handleClick(row); }}
829
- >
830
- {displayContent}
831
- </button>
832
- );
833
- };
834
- } else if (isPrimaryField) {
835
- cellRenderer = (value: any, row: any) => (
836
- <button
837
- type="button"
838
- className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
839
- data-testid="primary-field-link"
840
- onClick={(e) => { e.stopPropagation(); navigation.handleClick(row); }}
841
- >
842
- {value != null && value !== '' ? String(value) : <span className="text-muted-foreground/50 text-xs italic">—</span>}
843
- </button>
844
- );
845
- } else if (CellRenderer) {
846
- cellRenderer = (value: any) => <CellRenderer value={value} field={fieldMeta as any} />;
847
- }
848
-
849
- return {
850
- header,
851
- accessorKey: fieldName,
852
- ...(resolvedType && { headerIcon: getTypeIcon(resolvedType) }),
853
- ...(inferredAlign && { align: inferredAlign }),
854
- ...(cellRenderer && { cell: cellRenderer }),
855
- sortable: fieldDef?.sortable !== false,
856
- };
857
- });
858
- }
859
-
860
- // Legacy support: use 'fields' if columns not provided
861
- if (hasInlineData) {
862
- const inlineData = dataConfig?.provider === 'value' ? dataConfig.items as any[] : [];
863
- if (inlineData.length > 0) {
864
- const fieldsToShow = schemaFields || Object.keys(inlineData[0]);
865
- return fieldsToShow.map((fieldName) => {
866
- const fieldDef = objectSchema?.fields?.[fieldName];
867
- const resolvedType = fieldDef?.type || inferColumnType({ field: fieldName }) || null;
868
- const CellRenderer = resolvedType ? getCellRenderer(resolvedType) : null;
869
- const header = fieldDef?.label || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' ');
870
-
871
- // Build field metadata with objectDef enrichment
872
- const fieldMeta: Record<string, any> = { name: fieldName, type: resolvedType || 'text' };
873
- if (fieldDef) {
874
- if (fieldDef.label) fieldMeta.label = fieldDef.label;
875
- if (fieldDef.currency) fieldMeta.currency = fieldDef.currency;
876
- if (fieldDef.precision !== undefined) fieldMeta.precision = fieldDef.precision;
877
- if (fieldDef.format) fieldMeta.format = fieldDef.format;
878
- if (fieldDef.options) fieldMeta.options = fieldDef.options;
879
- }
880
- // Auto-generate select options from data when no options defined
881
- if (resolvedType === 'select' && !fieldMeta.options) {
882
- const uniqueValues = Array.from(new Set(data.map(row => row[fieldName]).filter(Boolean)));
883
- fieldMeta.options = uniqueValues.map((v: any) => ({ value: v, label: humanizeLabel(String(v)) }));
884
- }
885
-
886
- const numericTypes = ['number', 'currency', 'percent'];
887
- const inferredAlign = resolvedType && numericTypes.includes(resolvedType) ? 'right' as const : undefined;
888
-
889
- return {
890
- header,
891
- accessorKey: fieldName,
892
- ...(resolvedType && { headerIcon: getTypeIcon(resolvedType) }),
893
- ...(inferredAlign && { align: inferredAlign }),
894
- ...(CellRenderer && { cell: (value: any) => <CellRenderer value={value} field={fieldMeta as any} /> }),
895
- sortable: fieldDef?.sortable !== false,
896
- };
897
- });
898
- }
899
- }
900
-
901
- if (!objectSchema) return [];
902
-
903
- const generatedColumns: any[] = [];
904
- const fieldsToShow = schemaFields || Object.keys(objectSchema.fields || {});
905
-
906
- fieldsToShow.forEach((fieldName) => {
907
- const field = objectSchema.fields?.[fieldName];
908
- if (!field) return;
909
-
910
- if (field.permissions && field.permissions.read === false) return;
911
-
912
- const CellRenderer = getCellRenderer(field.type);
913
- const numericTypes = ['number', 'currency', 'percent'];
914
- generatedColumns.push({
915
- header: schema.objectName ? resolveFieldLabel(schema.objectName, fieldName, field.label || fieldName) : field.label || fieldName,
916
- accessorKey: fieldName,
917
- ...(numericTypes.includes(field.type) && { align: 'right' }),
918
- cell: (value: any) => <CellRenderer value={value} field={field} />,
919
- sortable: field.sortable !== false,
920
- });
921
- });
922
-
923
- return generatedColumns;
924
- }, [objectSchema, schemaFields, schemaColumns, dataConfig, hasInlineData, navigation.handleClick, executeAction, data, resolveFieldLabel, schema.objectName]);
925
-
926
- const handleExport = useCallback((format: 'csv' | 'xlsx' | 'json' | 'pdf') => {
927
- const exportConfig = schema.exportOptions;
928
- const maxRecords = exportConfig?.maxRecords || 0;
929
- const includeHeaders = exportConfig?.includeHeaders !== false;
930
- const prefix = exportConfig?.fileNamePrefix || schema.objectName || 'export';
931
- const exportData = maxRecords > 0 ? data.slice(0, maxRecords) : data;
932
-
933
- const downloadFile = (blob: Blob, filename: string) => {
934
- const url = URL.createObjectURL(blob);
935
- const a = document.createElement('a');
936
- a.href = url;
937
- a.download = filename;
938
- a.click();
939
- URL.revokeObjectURL(url);
940
- };
941
-
942
- const escapeCsvValue = (val: any): string => {
943
- const str = val == null ? '' : String(val);
944
- return str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')
945
- ? `"${str.replace(/"/g, '""')}"`
946
- : str;
947
- };
948
-
949
- if (format === 'csv') {
950
- const cols = generateColumns().filter((c: any) => c.accessorKey !== '_actions');
951
- const fields = cols.map((c: any) => c.accessorKey);
952
- const headers = cols.map((c: any) => c.header);
953
- const rows: string[] = [];
954
- if (includeHeaders) {
955
- rows.push(headers.join(','));
956
- }
957
- exportData.forEach(record => {
958
- rows.push(fields.map((f: string) => escapeCsvValue(record[f])).join(','));
959
- });
960
- downloadFile(new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8;' }), `${prefix}.csv`);
961
- } else if (format === 'json') {
962
- downloadFile(new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }), `${prefix}.json`);
963
- }
964
- setShowExport(false);
965
- }, [data, schema.exportOptions, schema.objectName, generateColumns]);
966
-
967
- if (error) {
968
- return (
969
- <div className="p-3 sm:p-4 border border-red-300 bg-red-50 rounded-md">
970
- <h3 className="text-red-800 font-semibold">{t('grid.errorLoading')}</h3>
971
- <p className="text-red-600 text-sm mt-1">{error.message}</p>
972
- </div>
973
- );
974
- }
975
-
976
- if (loading && data.length === 0) {
977
- if (useCardView) {
978
- return (
979
- <div className="space-y-2 p-2">
980
- {[1, 2, 3].map((i) => (
981
- <div key={i} className="border rounded-lg p-3 bg-card animate-pulse">
982
- <div className="h-5 bg-muted rounded w-3/4 mb-3" />
983
- <div className="flex items-center justify-between mb-2">
984
- <div className="h-4 bg-muted rounded w-1/4" />
985
- <div className="h-5 bg-muted rounded-full w-20" />
986
- </div>
987
- <div className="h-3 bg-muted rounded w-1/3" />
988
- </div>
989
- ))}
990
- </div>
991
- );
992
- }
993
- return (
994
- <div className="p-4 sm:p-8 text-center">
995
- <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-foreground"></div>
996
- <p className="mt-2 text-sm text-muted-foreground">{t('grid.loading')}</p>
997
- </div>
998
- );
999
- }
1000
-
1001
- const columns = generateColumns();
1002
-
1003
- // Apply persisted column order and widths
1004
- let persistedColumns = [...columns];
1005
-
1006
- // Apply saved widths
1007
- if (columnState.widths) {
1008
- persistedColumns = persistedColumns.map((col: any) => {
1009
- const savedWidth = columnState.widths?.[col.accessorKey];
1010
- if (savedWidth) {
1011
- return { ...col, size: savedWidth };
1012
- }
1013
- return col;
1014
- });
1015
- }
1016
-
1017
- // Apply saved order
1018
- if (columnState.order && columnState.order.length > 0) {
1019
- const orderMap = new Map(columnState.order.map((key: string, i: number) => [key, i]));
1020
- persistedColumns.sort((a: any, b: any) => {
1021
- const orderA = orderMap.get(a.accessorKey) ?? Infinity;
1022
- const orderB = orderMap.get(b.accessorKey) ?? Infinity;
1023
- return orderA - orderB;
1024
- });
1025
- }
1026
-
1027
- const operations = 'operations' in schema ? schema.operations : undefined;
1028
- const hasActions = operations && (operations.update || operations.delete);
1029
- const hasRowActions = schema.rowActions && schema.rowActions.length > 0;
1030
-
1031
- const columnsWithActions = (hasActions || hasRowActions) ? [
1032
- ...persistedColumns,
1033
- {
1034
- header: t('grid.actions'),
1035
- accessorKey: '_actions',
1036
- cell: (_value: any, row: any) => (
1037
- <RowActionMenu
1038
- row={row}
1039
- rowActions={schema.rowActions}
1040
- canEdit={!!(operations?.update && onEdit)}
1041
- canDelete={!!(operations?.delete && onDelete)}
1042
- onEdit={onEdit}
1043
- onDelete={onDelete}
1044
- onAction={(action, r) => executeAction({ type: action, params: { record: r } })}
1045
- />
1046
- ),
1047
- sortable: false,
1048
- },
1049
- ] : persistedColumns;
1050
-
1051
- // --- Pinned column reordering ---
1052
- // Reorder: pinned:'left' first, unpinned middle, pinned:'right' last
1053
- const pinnedLeftCols = columnsWithActions.filter((c: any) => c.pinned === 'left');
1054
- const pinnedRightCols = columnsWithActions.filter((c: any) => c.pinned === 'right');
1055
- const unpinnedCols = columnsWithActions.filter((c: any) => !c.pinned);
1056
- const hasPinnedColumns = pinnedLeftCols.length > 0 || pinnedRightCols.length > 0;
1057
- const rightPinnedClasses = 'sticky right-0 z-10 bg-background border-l border-border';
1058
- const orderedColumns = hasPinnedColumns
1059
- ? [
1060
- ...pinnedLeftCols,
1061
- ...unpinnedCols,
1062
- ...pinnedRightCols.map((col: any) => ({
1063
- ...col,
1064
- className: [col.className, rightPinnedClasses].filter(Boolean).join(' '),
1065
- cellClassName: [col.cellClassName, rightPinnedClasses].filter(Boolean).join(' '),
1066
- })),
1067
- ]
1068
- : columnsWithActions;
1069
-
1070
- // Calculate frozenColumns: if pinned columns exist, use left-pinned count; otherwise use schema default
1071
- const effectiveFrozenColumns = hasPinnedColumns
1072
- ? pinnedLeftCols.length
1073
- : (schema.frozenColumns ?? 1);
1074
-
1075
- // Determine selection mode (support both new and legacy formats)
1076
- // Auto-enable 'multiple' selection when bulk actions are defined
1077
- const effectiveBulkActions = schema.batchActions ?? (schema as any).bulkActions;
1078
- const hasBulkActions = effectiveBulkActions && effectiveBulkActions.length > 0;
1079
- let selectionMode: 'none' | 'single' | 'multiple' | boolean = false;
1080
- if (schema.selection?.type) {
1081
- selectionMode = schema.selection.type === 'none' ? false : schema.selection.type;
1082
- } else if (schema.selectable !== undefined) {
1083
- // Legacy support
1084
- selectionMode = schema.selectable;
1085
- } else if (hasBulkActions) {
1086
- // Auto-enable multi-select when bulk actions exist
1087
- selectionMode = 'multiple';
1088
- }
1089
-
1090
- // Determine pagination settings (support both new and legacy formats)
1091
- const paginationEnabled = schema.pagination !== undefined
1092
- ? true
1093
- : (schema.showPagination !== undefined ? schema.showPagination : true);
1094
-
1095
- const pageSize = schema.pagination?.pageSize
1096
- || schema.pageSize
1097
- || 10;
1098
-
1099
- // Determine search settings
1100
- const searchEnabled = schema.searchableFields !== undefined
1101
- ? schema.searchableFields.length > 0
1102
- : (schema.showSearch !== undefined ? schema.showSearch : true);
1103
-
1104
- const dataTableSchema: any = {
1105
- type: 'data-table',
1106
- caption: schema.label || schema.title,
1107
- columns: orderedColumns,
1108
- data,
1109
- pagination: paginationEnabled,
1110
- pageSize: pageSize,
1111
- searchable: searchEnabled,
1112
- selectable: selectionMode,
1113
- sortable: true,
1114
- exportable: operations?.export,
1115
- rowActions: hasActions,
1116
- resizableColumns: schema.resizable ?? schema.resizableColumns ?? true,
1117
- reorderableColumns: schema.reorderableColumns ?? false,
1118
- editable: schema.editable ?? false,
1119
- singleClickEdit: schema.singleClickEdit ?? true,
1120
- className: schema.className,
1121
- cellClassName: rowHeightMode === 'compact'
1122
- ? 'px-3 py-1 text-[13px] leading-tight'
1123
- : rowHeightMode === 'short'
1124
- ? 'px-3 py-1 text-[13px] leading-normal'
1125
- : rowHeightMode === 'tall'
1126
- ? 'px-3 py-2.5 text-sm'
1127
- : rowHeightMode === 'extra_tall'
1128
- ? 'px-3 py-3.5 text-sm leading-relaxed'
1129
- : 'px-3 py-1.5 text-[13px] leading-normal',
1130
- showRowNumbers: true,
1131
- showAddRow: !!operations?.create,
1132
- onAddRecord: onAddRecord,
1133
- rowClassName: schema.rowColor ? (row: any, _idx: number) => getRowClassName(row) : undefined,
1134
- rowStyle: schema.conditionalFormatting?.length ? (row: any, _idx: number) => getRowStyle(row) : undefined,
1135
- frozenColumns: effectiveFrozenColumns,
1136
- onSelectionChange: (rows: any[]) => {
1137
- setSelectedRows(rows);
1138
- onRowSelect?.(rows);
1139
- },
1140
- onRowClick: navigation.handleClick,
1141
- onCellChange: onCellChange,
1142
- onRowSave: onRowSave,
1143
- onBatchSave: onBatchSave,
1144
- onColumnResize: (columnKey: string, width: number) => {
1145
- saveColumnState({
1146
- ...columnState,
1147
- widths: { ...columnState.widths, [columnKey]: width },
1148
- });
1149
- },
1150
- onColumnReorder: (newOrder: string[]) => {
1151
- saveColumnState({
1152
- ...columnState,
1153
- order: newOrder,
1154
- });
1155
- },
1156
- };
1157
-
1158
- /** Build a per-group data-table schema (inherits everything except data & pagination). */
1159
- const buildGroupTableSchema = (groupRows: any[]) => ({
1160
- ...dataTableSchema,
1161
- caption: undefined,
1162
- data: groupRows,
1163
- pagination: false,
1164
- searchable: false,
1165
- });
1166
-
1167
- // Build record detail title
1168
- const detailTitle = schema.label
1169
- ? `${schema.label} Detail`
1170
- : schema.objectName
1171
- ? `${schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1)} Detail`
1172
- : 'Record Detail';
1173
-
1174
- // Mobile card-view fallback for screens below 480px
1175
- if (useCardView && data.length > 0 && !isGrouped) {
1176
- const displayColumns = generateColumns().filter((c: any) => c.accessorKey !== '_actions');
1177
-
1178
- // Build a lookup of column metadata for smart rendering
1179
- const colMap = new Map<string, any>();
1180
- displayColumns.forEach((col: any) => colMap.set(col.accessorKey, col));
1181
-
1182
- // Identify special columns by inferred type for visual hierarchy
1183
- const titleCol = displayColumns[0]; // First column is always the title
1184
- const amountKeys = ['amount', 'price', 'total', 'revenue', 'cost', 'value', 'budget', 'salary'];
1185
- const stageKeys = ['stage', 'status', 'priority', 'category', 'severity', 'level'];
1186
- const dateKeys = ['date', 'due', 'created', 'updated', 'deadline', 'start', 'end', 'expires'];
1187
- const percentKeys = ['probability', 'percent', 'rate', 'ratio', 'confidence', 'score'];
1188
-
1189
- // Stage badge color mapping for common pipeline stages
1190
- const stageBadgeColor = (value: string): string => {
1191
- const v = (value || '').toLowerCase();
1192
- if (v.includes('won') || v.includes('completed') || v.includes('done') || v.includes('active'))
1193
- return 'bg-green-100 text-green-800 border-green-300';
1194
- if (v.includes('lost') || v.includes('cancelled') || v.includes('rejected') || v.includes('closed lost'))
1195
- return 'bg-red-100 text-red-800 border-red-300';
1196
- if (v.includes('negotiation') || v.includes('review') || v.includes('in progress'))
1197
- return 'bg-yellow-100 text-yellow-800 border-yellow-300';
1198
- if (v.includes('proposal') || v.includes('pending'))
1199
- return 'bg-blue-100 text-blue-800 border-blue-300';
1200
- if (v.includes('qualification') || v.includes('qualified'))
1201
- return 'bg-indigo-100 text-indigo-800 border-indigo-300';
1202
- if (v.includes('prospecting') || v.includes('new') || v.includes('open'))
1203
- return 'bg-purple-100 text-purple-800 border-purple-300';
1204
- return 'bg-muted text-muted-foreground border-border';
1205
- };
1206
-
1207
- // Left border color for card accent based on stage
1208
- const stageBorderLeft = (value: string): string => {
1209
- const v = (value || '').toLowerCase();
1210
- if (v.includes('won') || v.includes('completed') || v.includes('done') || v.includes('active'))
1211
- return 'border-l-green-500';
1212
- if (v.includes('lost') || v.includes('cancelled') || v.includes('rejected'))
1213
- return 'border-l-red-500';
1214
- if (v.includes('negotiation') || v.includes('review') || v.includes('in progress'))
1215
- return 'border-l-yellow-500';
1216
- if (v.includes('proposal') || v.includes('pending'))
1217
- return 'border-l-blue-500';
1218
- if (v.includes('qualification') || v.includes('qualified'))
1219
- return 'border-l-indigo-500';
1220
- if (v.includes('prospecting') || v.includes('new') || v.includes('open'))
1221
- return 'border-l-purple-500';
1222
- return 'border-l-gray-300';
1223
- };
1224
-
1225
- const classify = (key: string): 'amount' | 'stage' | 'date' | 'percent' | 'other' => {
1226
- const k = key.toLowerCase();
1227
- if (amountKeys.some(p => k.includes(p))) return 'amount';
1228
- if (stageKeys.some(p => k.includes(p))) return 'stage';
1229
- if (dateKeys.some(p => k.includes(p))) return 'date';
1230
- if (percentKeys.some(p => k.includes(p))) return 'percent';
1231
- return 'other';
1232
- };
1233
-
1234
- return (
1235
- <>
1236
- <div className="space-y-2 p-2">
1237
- {data.map((row, idx) => {
1238
- // Collect secondary fields (skip the title column)
1239
- const secondaryCols = displayColumns.slice(1, 5);
1240
- const amountCol = secondaryCols.find((c: any) => classify(c.accessorKey) === 'amount');
1241
- const stageCol = secondaryCols.find((c: any) => classify(c.accessorKey) === 'stage');
1242
- const dateCols = secondaryCols.filter((c: any) => classify(c.accessorKey) === 'date');
1243
- const percentCols = secondaryCols.filter((c: any) => classify(c.accessorKey) === 'percent');
1244
- const otherCols = secondaryCols.filter(
1245
- (c: any) => c !== amountCol && c !== stageCol && !dateCols.includes(c) && !percentCols.includes(c)
1246
- );
1247
-
1248
- // Determine left border accent color from stage value
1249
- const stageValue = stageCol ? String(row[stageCol.accessorKey] ?? '') : '';
1250
- const leftBorderClass = stageValue ? stageBorderLeft(stageValue) : '';
1251
- const cardClassName = [
1252
- 'border rounded-lg p-2.5 bg-card hover:bg-accent/50 cursor-pointer transition-colors touch-manipulation',
1253
- leftBorderClass ? `border-l-[3px] ${leftBorderClass}` : '',
1254
- ].filter(Boolean).join(' ');
1255
-
1256
- return (
1257
- <div
1258
- key={row.id || row._id || idx}
1259
- className={cardClassName}
1260
- onClick={() => navigation.handleClick(row)}
1261
- >
1262
- {/* Title row - Name as bold prominent title */}
1263
- {titleCol && (
1264
- <div className="font-semibold text-sm truncate mb-1">
1265
- {row[titleCol.accessorKey] ?? '—'}
1266
- </div>
1267
- )}
1268
-
1269
- {/* Amount + Stage row - side by side for compact display */}
1270
- {(amountCol || stageCol) && (
1271
- <div className="flex items-center justify-between gap-2 mb-1">
1272
- {amountCol && (
1273
- <span className="text-sm tabular-nums font-medium">
1274
- {typeof row[amountCol.accessorKey] === 'number'
1275
- ? formatCompactCurrency(row[amountCol.accessorKey])
1276
- : row[amountCol.accessorKey] ?? '—'}
1277
- </span>
1278
- )}
1279
- {stageCol && row[stageCol.accessorKey] && (
1280
- <Badge
1281
- variant="outline"
1282
- className={`text-xs shrink-0 max-w-[140px] truncate ${stageBadgeColor(String(row[stageCol.accessorKey]))}`}
1283
- >
1284
- {row[stageCol.accessorKey]}
1285
- </Badge>
1286
- )}
1287
- </div>
1288
- )}
1289
-
1290
- {/* Date + Percent combined row for density */}
1291
- {(dateCols.length > 0 || percentCols.length > 0) && (
1292
- <div className="flex items-center justify-between py-0.5 text-xs text-muted-foreground">
1293
- {dateCols[0] && (
1294
- <span className="tabular-nums">
1295
- {row[dateCols[0].accessorKey]
1296
- ? formatDate(row[dateCols[0].accessorKey], 'short')
1297
- : '—'}
1298
- </span>
1299
- )}
1300
- {percentCols[0] && row[percentCols[0].accessorKey] != null && (
1301
- <span className="tabular-nums">
1302
- {formatPercent(Number(row[percentCols[0].accessorKey]))}
1303
- </span>
1304
- )}
1305
- </div>
1306
- )}
1307
-
1308
- {/* Additional date fields beyond the first */}
1309
- {dateCols.slice(1).map((col: any) => (
1310
- <div key={col.accessorKey} className="flex justify-between items-center py-0.5">
1311
- <span className="text-xs text-muted-foreground">{col.header}</span>
1312
- <span className="text-xs text-muted-foreground tabular-nums">
1313
- {row[col.accessorKey] ? formatDate(row[col.accessorKey], 'short') : '—'}
1314
- </span>
1315
- </div>
1316
- ))}
1317
-
1318
- {/* Other fields - hide empty values on mobile */}
1319
- {otherCols.map((col: any) => {
1320
- const val = row[col.accessorKey];
1321
- if (val == null || val === '') return null;
1322
- return (
1323
- <div key={col.accessorKey} className="flex justify-between items-center py-0.5">
1324
- <span className="text-xs text-muted-foreground">{col.header}</span>
1325
- <span className="text-xs font-medium truncate ml-2 text-right">
1326
- {col.cell ? col.cell(val, row) : String(val)}
1327
- </span>
1328
- </div>
1329
- );
1330
- })}
1331
- </div>
1332
- );
1333
- })}
1334
- </div>
1335
- {navigation.isOverlay && (
1336
- <NavigationOverlay {...navigation} title={detailTitle}>
1337
- {(record) => (
1338
- <div className="space-y-3">
1339
- {Object.entries(record).map(([key, value]) => (
1340
- <div key={key} className="flex flex-col">
1341
- <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
1342
- {key.replace(/_/g, ' ')}
1343
- </span>
1344
- <span className="text-sm">{String(value ?? '—')}</span>
1345
- </div>
1346
- ))}
1347
- </div>
1348
- )}
1349
- </NavigationOverlay>
1350
- )}
1351
- </>
1352
- );
1353
- }
1354
-
1355
- // Row height cycle handler (plain function, not hook — after early returns)
1356
- const cycleRowHeight = () => {
1357
- setRowHeightMode(prev => {
1358
- if (prev === 'compact') return 'short';
1359
- if (prev === 'short') return 'medium';
1360
- if (prev === 'medium') return 'tall';
1361
- if (prev === 'tall') return 'extra_tall';
1362
- return 'compact';
1363
- });
1364
- };
1365
-
1366
- const rowHeightIcons = { compact: Rows4, short: Rows3, medium: Rows2, tall: AlignJustify, extra_tall: AlignJustify };
1367
- const RowHeightIcon = rowHeightIcons[rowHeightMode];
1368
-
1369
- // Grid toolbar (row height toggle + export)
1370
- const showRowHeightToggle = schema.rowHeight !== undefined;
1371
- const hasToolbar = schema.exportOptions || showRowHeightToggle;
1372
- const gridToolbar = hasToolbar ? (
1373
- <div className="flex items-center justify-end gap-1 px-2 py-1">
1374
- {/* Row height toggle */}
1375
- {showRowHeightToggle && (
1376
- <Button
1377
- variant="ghost"
1378
- size="sm"
1379
- className="h-7 px-2 text-muted-foreground hover:text-primary text-xs"
1380
- onClick={cycleRowHeight}
1381
- title={`Row height: ${rowHeightMode}`}
1382
- >
1383
- <RowHeightIcon className="h-3.5 w-3.5 mr-1.5" />
1384
- <span className="hidden sm:inline capitalize">{rowHeightMode}</span>
1385
- </Button>
1386
- )}
1387
-
1388
- {/* Export */}
1389
- {schema.exportOptions && (
1390
- <Popover open={showExport} onOpenChange={setShowExport}>
1391
- <PopoverTrigger asChild>
1392
- <Button
1393
- variant="ghost"
1394
- size="sm"
1395
- className="h-7 px-2 text-muted-foreground hover:text-primary text-xs"
1396
- >
1397
- <Download className="h-3.5 w-3.5 mr-1.5" />
1398
- <span className="hidden sm:inline">{t('grid.export')}</span>
1399
- </Button>
1400
- </PopoverTrigger>
1401
- <PopoverContent align="end" className="w-48 p-2">
1402
- <div className="space-y-1">
1403
- {(schema.exportOptions.formats || ['csv', 'json']).map(format => (
1404
- <Button
1405
- key={format}
1406
- variant="ghost"
1407
- size="sm"
1408
- className="w-full justify-start h-8 text-xs"
1409
- onClick={() => handleExport(format)}
1410
- >
1411
- <Download className="h-3.5 w-3.5 mr-2" />
1412
- {t('grid.exportAs', { format: format.toUpperCase() })}
1413
- </Button>
1414
- ))}
1415
- </div>
1416
- </PopoverContent>
1417
- </Popover>
1418
- )}
1419
- </div>
1420
- ) : null;
1421
-
1422
- // Form-based record detail renderer (replaces simple key-value dump)
1423
- const renderRecordDetail = (record: any) => {
1424
- const systemFields = ['_id', 'id', 'created_at', 'updated_at', 'created_by', 'updated_by'];
1425
- const entries = Object.entries(record);
1426
- const regularFields = entries.filter(([key]) => !systemFields.includes(key));
1427
- const metaFields = entries.filter(([key]) => systemFields.includes(key) && key !== '_id' && key !== 'id');
1428
-
1429
- const formatFieldLabel = (key: string): string =>
1430
- key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ');
1431
-
1432
- const renderFieldValue = (key: string, value: any): React.ReactNode => {
1433
- if (value == null || value === '') {
1434
- return <span className="text-muted-foreground/50 text-sm italic">Empty</span>;
1435
- }
1436
-
1437
- // Use objectSchema field type for type-aware rendering
1438
- const fieldDef = objectSchema?.fields?.[key];
1439
- if (fieldDef?.type) {
1440
- const CellRenderer = getCellRenderer(fieldDef.type);
1441
- if (CellRenderer) {
1442
- return <CellRenderer value={value} field={fieldDef} />;
1443
- }
1444
- }
1445
-
1446
- // Fallback: infer from value and key name
1447
- if (typeof value === 'boolean') {
1448
- return <Badge variant={value ? 'default' : 'outline'}>{value ? 'Yes' : 'No'}</Badge>;
1449
- }
1450
- // Detect date-like values
1451
- if (typeof value === 'string' && !isNaN(Date.parse(value)) && (key.includes('date') || key.includes('_at') || key.includes('time'))) {
1452
- return <span className="text-sm tabular-nums">{formatDate(value)}</span>;
1453
- }
1454
- // Detect currency-like fields by name
1455
- const currencyFields = ['amount', 'price', 'total', 'revenue', 'cost', 'value', 'budget', 'salary'];
1456
- if (typeof value === 'number' && currencyFields.some(f => key.toLowerCase().includes(f))) {
1457
- return <span className="text-sm tabular-nums font-medium">{formatCurrency(value)}</span>;
1458
- }
1459
- return <span className="text-sm break-words">{String(value)}</span>;
1460
- };
1461
-
1462
- return (
1463
- <div className="space-y-4" data-testid="record-detail-panel">
1464
- {/* Regular fields in form-like layout */}
1465
- <div className="rounded-lg border bg-card">
1466
- <div className="divide-y">
1467
- {regularFields.map(([key, value]) => (
1468
- <div key={key} className="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4 px-4 py-3">
1469
- <span className="text-xs font-medium text-muted-foreground sm:w-1/3 sm:text-right sm:pt-0.5 uppercase tracking-wide shrink-0">
1470
- {formatFieldLabel(key)}
1471
- </span>
1472
- <div className="flex-1 min-w-0">
1473
- {renderFieldValue(key, value)}
1474
- </div>
1475
- </div>
1476
- ))}
1477
- </div>
1478
- </div>
1479
-
1480
- {/* System/meta fields */}
1481
- {metaFields.length > 0 && (
1482
- <div className="rounded-lg border bg-muted/30">
1483
- <div className="px-4 py-2 border-b">
1484
- <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">System</span>
1485
- </div>
1486
- <div className="divide-y divide-border/50">
1487
- {metaFields.map(([key, value]) => (
1488
- <div key={key} className="flex items-center gap-4 px-4 py-2">
1489
- <span className="text-xs text-muted-foreground w-1/3 text-right shrink-0">
1490
- {formatFieldLabel(key)}
1491
- </span>
1492
- <span className="text-xs text-muted-foreground flex-1 min-w-0 break-words">{String(value ?? '')}</span>
1493
- </div>
1494
- ))}
1495
- </div>
1496
- </div>
1497
- )}
1498
- </div>
1499
- );
1500
- };
1501
-
1502
- // Summary footer row
1503
- const summaryFooter = hasSummary ? (
1504
- <div className="border-t bg-muted/30 px-2 py-1.5" data-testid="column-summary-footer">
1505
- <div className="flex gap-4 text-xs text-muted-foreground font-medium">
1506
- {orderedColumns
1507
- .filter((col: any) => summaries.has(col.accessorKey))
1508
- .map((col: any) => {
1509
- const summary = summaries.get(col.accessorKey)!;
1510
- return (
1511
- <span key={col.accessorKey} data-testid={`summary-${col.accessorKey}`}>
1512
- {col.header}: {summary.label}
1513
- </span>
1514
- );
1515
- })}
1516
- </div>
1517
- </div>
1518
- ) : null;
1519
-
1520
- // Render grid content: grouped (multiple tables with headers) or flat (single table)
1521
- const gridContent = isGrouped ? (
1522
- <div className="space-y-2">
1523
- {groups.map((group) => (
1524
- <GroupRow
1525
- key={group.key}
1526
- groupKey={group.key}
1527
- label={group.label}
1528
- count={group.rows.length}
1529
- collapsed={group.collapsed}
1530
- aggregations={group.aggregations}
1531
- onToggle={toggleGroup}
1532
- >
1533
- <SchemaRenderer schema={buildGroupTableSchema(group.rows)} />
1534
- </GroupRow>
1535
- ))}
1536
- </div>
1537
- ) : (
1538
- <>
1539
- <SchemaRenderer schema={dataTableSchema} />
1540
- {summaryFooter}
1541
- </>
1542
- );
1543
-
1544
- // For split mode, wrap the grid in the ResizablePanelGroup
1545
- if (navigation.isOverlay && navigation.mode === 'split') {
1546
- return (
1547
- <NavigationOverlay
1548
- {...navigation}
1549
- title={detailTitle}
1550
- mainContent={
1551
- <>
1552
- {gridToolbar}
1553
- {gridContent}
1554
- <BulkActionBar
1555
- selectedRows={selectedRows}
1556
- actions={effectiveBulkActions ?? []}
1557
- onAction={(action, rows) => executeAction({ type: action, params: { records: rows } })}
1558
- onClearSelection={() => setSelectedRows([])}
1559
- />
1560
- </>
1561
- }
1562
- >
1563
- {(record) => renderRecordDetail(record)}
1564
- </NavigationOverlay>
1565
- );
1566
- }
1567
-
1568
- return (
1569
- <div ref={pullRef} className="relative h-full">
1570
- {pullDistance > 0 && (
1571
- <div
1572
- className="flex items-center justify-center text-xs text-muted-foreground"
1573
- style={{ height: pullDistance }}
1574
- >
1575
- {isRefreshing ? t('grid.refreshing') : t('grid.pullToRefresh')}
1576
- </div>
1577
- )}
1578
- {gridToolbar}
1579
- {gridContent}
1580
- <BulkActionBar
1581
- selectedRows={selectedRows}
1582
- actions={effectiveBulkActions ?? []}
1583
- onAction={(action, rows) => executeAction({ type: action, params: { records: rows } })}
1584
- onClearSelection={() => setSelectedRows([])}
1585
- />
1586
- {navigation.isOverlay && (
1587
- <NavigationOverlay
1588
- {...navigation}
1589
- title={detailTitle}
1590
- >
1591
- {(record) => renderRecordDetail(record)}
1592
- </NavigationOverlay>
1593
- )}
1594
- </div>
1595
- );
1596
- };