@object-ui/plugin-grid 3.3.0 → 3.3.2

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