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