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