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