@object-ui/plugin-grid 0.5.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +51 -6
- package/CHANGELOG.md +37 -0
- package/README.md +97 -0
- package/dist/index.js +994 -584
- package/dist/index.umd.cjs +3 -3
- package/dist/packages/plugin-grid/src/InlineEditing.d.ts +28 -0
- package/dist/packages/plugin-grid/src/ListColumnExtensions.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/ListColumnSchema.test.d.ts +1 -0
- package/dist/packages/plugin-grid/src/ObjectGrid.EdgeCases.stories.d.ts +25 -0
- package/dist/packages/plugin-grid/src/ObjectGrid.d.ts +7 -1
- package/dist/packages/plugin-grid/src/ObjectGrid.stories.d.ts +33 -0
- package/dist/packages/plugin-grid/src/__tests__/InlineEditing.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/VirtualGrid.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/accessibility.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/performance-benchmark.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/view-states.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/index.d.ts +5 -0
- package/dist/packages/plugin-grid/src/useGroupedData.d.ts +30 -0
- package/dist/packages/plugin-grid/src/useRowColor.d.ts +8 -0
- package/package.json +11 -10
- package/src/InlineEditing.tsx +235 -0
- package/src/ListColumnExtensions.test.tsx +374 -0
- package/src/ListColumnSchema.test.ts +88 -0
- package/src/ObjectGrid.EdgeCases.stories.tsx +147 -0
- package/src/ObjectGrid.msw.test.tsx +24 -1
- package/src/ObjectGrid.stories.tsx +139 -0
- package/src/ObjectGrid.tsx +409 -113
- package/src/__tests__/InlineEditing.test.tsx +360 -0
- package/src/__tests__/VirtualGrid.test.tsx +438 -0
- package/src/__tests__/accessibility.test.tsx +254 -0
- package/src/__tests__/performance-benchmark.test.tsx +182 -0
- package/src/__tests__/view-states.test.tsx +203 -0
- package/src/index.tsx +17 -2
- package/src/useGroupedData.ts +122 -0
- package/src/useRowColor.ts +74 -0
package/src/ObjectGrid.tsx
CHANGED
|
@@ -23,16 +23,19 @@
|
|
|
23
23
|
|
|
24
24
|
import React, { useEffect, useState, useCallback } from 'react';
|
|
25
25
|
import type { ObjectGridSchema, DataSource, ListColumn, ViewData } from '@object-ui/types';
|
|
26
|
-
import { SchemaRenderer, useDataScope } from '@object-ui/react';
|
|
26
|
+
import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction } from '@object-ui/react';
|
|
27
27
|
import { getCellRenderer } from '@object-ui/fields';
|
|
28
|
-
import { Button } from '@object-ui/components';
|
|
28
|
+
import { Button, NavigationOverlay } from '@object-ui/components';
|
|
29
|
+
import { usePullToRefresh } from '@object-ui/mobile';
|
|
29
30
|
import {
|
|
30
31
|
DropdownMenu,
|
|
31
32
|
DropdownMenuContent,
|
|
32
33
|
DropdownMenuItem,
|
|
33
34
|
DropdownMenuTrigger,
|
|
34
35
|
} from '@object-ui/components';
|
|
35
|
-
import { Edit, Trash2, MoreVertical } from 'lucide-react';
|
|
36
|
+
import { Edit, Trash2, MoreVertical, ChevronRight, ChevronDown } from 'lucide-react';
|
|
37
|
+
import { useRowColor } from './useRowColor';
|
|
38
|
+
import { useGroupedData } from './useGroupedData';
|
|
36
39
|
|
|
37
40
|
export interface ObjectGridProps {
|
|
38
41
|
schema: ObjectGridSchema;
|
|
@@ -42,7 +45,9 @@ export interface ObjectGridProps {
|
|
|
42
45
|
onEdit?: (record: any) => void;
|
|
43
46
|
onDelete?: (record: any) => void;
|
|
44
47
|
onBulkDelete?: (records: any[]) => void;
|
|
45
|
-
onCellChange?: (rowIndex: number, columnKey: string, newValue: any) => void;
|
|
48
|
+
onCellChange?: (rowIndex: number, columnKey: string, newValue: any, row: any) => void;
|
|
49
|
+
onRowSave?: (rowIndex: number, changes: Record<string, any>, row: any) => void | Promise<void>;
|
|
50
|
+
onBatchSave?: (changes: Array<{ rowIndex: number; changes: Record<string, any>; row: any }>) => void | Promise<void>;
|
|
46
51
|
onRowSelect?: (selectedRows: any[]) => void;
|
|
47
52
|
}
|
|
48
53
|
|
|
@@ -109,12 +114,33 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
109
114
|
onDelete,
|
|
110
115
|
onRowSelect,
|
|
111
116
|
onRowClick,
|
|
117
|
+
onCellChange,
|
|
118
|
+
onRowSave,
|
|
119
|
+
onBatchSave,
|
|
112
120
|
...rest
|
|
113
121
|
}) => {
|
|
114
122
|
const [data, setData] = useState<any[]>([]);
|
|
115
123
|
const [loading, setLoading] = useState(true);
|
|
116
124
|
const [error, setError] = useState<Error | null>(null);
|
|
117
125
|
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
126
|
+
const [useCardView, setUseCardView] = useState(false);
|
|
127
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
128
|
+
|
|
129
|
+
const handlePullRefresh = useCallback(async () => {
|
|
130
|
+
setRefreshKey(k => k + 1);
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
const { ref: pullRef, isRefreshing, pullDistance } = usePullToRefresh<HTMLDivElement>({
|
|
134
|
+
onRefresh: handlePullRefresh,
|
|
135
|
+
enabled: !!dataSource && !!schema.objectName,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const checkWidth = () => setUseCardView(window.innerWidth < 480);
|
|
140
|
+
checkWidth();
|
|
141
|
+
window.addEventListener('resize', checkWidth);
|
|
142
|
+
return () => window.removeEventListener('resize', checkWidth);
|
|
143
|
+
}, []);
|
|
118
144
|
|
|
119
145
|
// Check if data is passed directly (from ListView)
|
|
120
146
|
const passedData = (rest as any).data;
|
|
@@ -146,6 +172,20 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
146
172
|
|
|
147
173
|
const hasInlineData = dataConfig?.provider === 'value';
|
|
148
174
|
|
|
175
|
+
// Extract stable primitive/reference-stable values from schema for dependency arrays.
|
|
176
|
+
// This prevents infinite re-render loops when schema is a new object on each render
|
|
177
|
+
// (e.g. when rendered through SchemaRenderer which creates a fresh evaluatedSchema).
|
|
178
|
+
const objectName = dataConfig?.provider === 'object' && dataConfig && 'object' in dataConfig
|
|
179
|
+
? (dataConfig as any).object
|
|
180
|
+
: schema.objectName;
|
|
181
|
+
const schemaFields = schema.fields;
|
|
182
|
+
const schemaColumns = schema.columns;
|
|
183
|
+
const schemaFilter = schema.filter;
|
|
184
|
+
const schemaSort = schema.sort;
|
|
185
|
+
const schemaPagination = schema.pagination;
|
|
186
|
+
const schemaPageSize = schema.pageSize;
|
|
187
|
+
|
|
188
|
+
// --- Inline data effect (synchronous, no fetch needed) ---
|
|
149
189
|
useEffect(() => {
|
|
150
190
|
if (hasInlineData && dataConfig?.provider === 'value') {
|
|
151
191
|
// Only update if data is different to avoid infinite loop
|
|
@@ -160,42 +200,122 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
160
200
|
}
|
|
161
201
|
}, [hasInlineData, dataConfig]);
|
|
162
202
|
|
|
203
|
+
// --- Unified async data loading effect ---
|
|
204
|
+
// Combines schema fetch + data fetch into a single async flow with AbortController.
|
|
205
|
+
// This avoids the fragile "chained effects" pattern where Effect 1 sets objectSchema,
|
|
206
|
+
// triggering Effect 2 to call fetchData — a pattern prone to infinite loops when
|
|
207
|
+
// fetchData's reference is unstable.
|
|
163
208
|
useEffect(() => {
|
|
164
|
-
|
|
209
|
+
if (hasInlineData) return;
|
|
210
|
+
|
|
211
|
+
let cancelled = false;
|
|
212
|
+
|
|
213
|
+
const loadSchemaAndData = async () => {
|
|
214
|
+
setLoading(true);
|
|
215
|
+
setError(null);
|
|
165
216
|
try {
|
|
166
|
-
|
|
217
|
+
// --- Step 1: Resolve object schema ---
|
|
218
|
+
let resolvedSchema: any = null;
|
|
219
|
+
const cols = normalizeColumns(schemaColumns) || schemaFields;
|
|
220
|
+
|
|
221
|
+
if (cols && objectName) {
|
|
222
|
+
// We have explicit columns — use a minimal schema stub
|
|
223
|
+
resolvedSchema = { name: objectName, fields: {} };
|
|
224
|
+
} else if (objectName && dataSource) {
|
|
225
|
+
// Fetch full schema from DataSource
|
|
226
|
+
const schemaData = await dataSource.getObjectSchema(objectName);
|
|
227
|
+
if (cancelled) return;
|
|
228
|
+
resolvedSchema = schemaData;
|
|
229
|
+
} else if (!objectName) {
|
|
230
|
+
throw new Error('Object name required for data fetching');
|
|
231
|
+
} else {
|
|
167
232
|
throw new Error('DataSource required');
|
|
168
233
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (
|
|
176
|
-
|
|
234
|
+
|
|
235
|
+
if (!cancelled) {
|
|
236
|
+
setObjectSchema(resolvedSchema);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- Step 2: Fetch data ---
|
|
240
|
+
if (dataSource && objectName) {
|
|
241
|
+
const getSelectFields = () => {
|
|
242
|
+
if (schemaFields) return schemaFields;
|
|
243
|
+
if (schemaColumns && Array.isArray(schemaColumns)) {
|
|
244
|
+
return schemaColumns.map((c: any) => typeof c === 'string' ? c : c.field);
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const params: any = {
|
|
250
|
+
$select: getSelectFields(),
|
|
251
|
+
$top: (schemaPagination as any)?.pageSize || schemaPageSize || 50,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Support new filter format
|
|
255
|
+
if (schemaFilter && Array.isArray(schemaFilter)) {
|
|
256
|
+
params.$filter = schemaFilter;
|
|
257
|
+
} else if (schema.defaultFilters) {
|
|
258
|
+
// Legacy support
|
|
259
|
+
params.$filter = schema.defaultFilters;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Support new sort format
|
|
263
|
+
if (schemaSort) {
|
|
264
|
+
if (typeof schemaSort === 'string') {
|
|
265
|
+
params.$orderby = schemaSort;
|
|
266
|
+
} else if (Array.isArray(schemaSort)) {
|
|
267
|
+
params.$orderby = schemaSort
|
|
268
|
+
.map((s: any) => `${s.field} ${s.order}`)
|
|
269
|
+
.join(', ');
|
|
270
|
+
}
|
|
271
|
+
} else if (schema.defaultSort) {
|
|
272
|
+
// Legacy support
|
|
273
|
+
params.$orderby = `${(schema.defaultSort as any).field} ${(schema.defaultSort as any).order}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const result = await dataSource.find(objectName, params);
|
|
277
|
+
if (cancelled) return;
|
|
278
|
+
setData(result.data || []);
|
|
177
279
|
}
|
|
178
|
-
|
|
179
|
-
const schemaData = await dataSource.getObjectSchema(objectName);
|
|
180
|
-
setObjectSchema(schemaData);
|
|
181
280
|
} catch (err) {
|
|
182
|
-
|
|
281
|
+
if (!cancelled) {
|
|
282
|
+
setError(err as Error);
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
if (!cancelled) {
|
|
286
|
+
setLoading(false);
|
|
287
|
+
}
|
|
183
288
|
}
|
|
184
289
|
};
|
|
185
290
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
291
|
+
loadSchemaAndData();
|
|
292
|
+
|
|
293
|
+
return () => {
|
|
294
|
+
cancelled = true;
|
|
295
|
+
};
|
|
296
|
+
}, [objectName, schemaFields, schemaColumns, schemaFilter, schemaSort, schemaPagination, schemaPageSize, dataSource, hasInlineData, dataConfig, refreshKey]);
|
|
297
|
+
|
|
298
|
+
// --- NavigationConfig support ---
|
|
299
|
+
// Must be called before any early returns to satisfy React hooks rules
|
|
300
|
+
const navigation = useNavigationOverlay({
|
|
301
|
+
navigation: schema.navigation,
|
|
302
|
+
objectName: schema.objectName,
|
|
303
|
+
onNavigate: schema.onNavigate,
|
|
304
|
+
onRowClick,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// --- Action support for action columns ---
|
|
308
|
+
const { execute: executeAction } = useAction();
|
|
309
|
+
|
|
310
|
+
// --- Row color support ---
|
|
311
|
+
const getRowClassName = useRowColor(schema.rowColor);
|
|
312
|
+
|
|
313
|
+
// --- Grouping support ---
|
|
314
|
+
const { groups, isGrouped, toggleGroup } = useGroupedData(schema.grouping, data);
|
|
195
315
|
|
|
196
316
|
const generateColumns = useCallback(() => {
|
|
197
317
|
// Use normalized columns (support both new and legacy)
|
|
198
|
-
const cols = normalizeColumns(
|
|
318
|
+
const cols = normalizeColumns(schemaColumns);
|
|
199
319
|
|
|
200
320
|
if (cols) {
|
|
201
321
|
// Check if columns are already in data-table format (have 'accessorKey')
|
|
@@ -208,17 +328,112 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
208
328
|
return cols;
|
|
209
329
|
}
|
|
210
330
|
|
|
211
|
-
// ListColumn format - convert to data-table format
|
|
331
|
+
// ListColumn format - convert to data-table format with full feature support
|
|
212
332
|
if ('field' in firstCol) {
|
|
213
333
|
return (cols as ListColumn[])
|
|
214
|
-
.filter((col) => col?.field && typeof col.field === 'string'
|
|
215
|
-
.map((col) =>
|
|
216
|
-
header
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
334
|
+
.filter((col) => col?.field && typeof col.field === 'string' && !col.hidden)
|
|
335
|
+
.map((col, colIndex) => {
|
|
336
|
+
const header = col.label || col.field.charAt(0).toUpperCase() + col.field.slice(1).replace(/_/g, ' ');
|
|
337
|
+
|
|
338
|
+
// Build custom cell renderer based on column configuration
|
|
339
|
+
let cellRenderer: ((value: any, row: any) => React.ReactNode) | undefined;
|
|
340
|
+
|
|
341
|
+
// Type-based cell renderer (e.g., "currency", "date", "boolean")
|
|
342
|
+
const CellRenderer = col.type ? getCellRenderer(col.type) : null;
|
|
343
|
+
|
|
344
|
+
if (col.link && col.action) {
|
|
345
|
+
// Both link and action: link takes priority for navigation, action executes on secondary interaction
|
|
346
|
+
cellRenderer = (value: any, row: any) => {
|
|
347
|
+
const displayContent = CellRenderer
|
|
348
|
+
? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
|
|
349
|
+
: (value != null && value !== '' ? String(value) : <span className="text-muted-foreground">-</span>);
|
|
350
|
+
return (
|
|
351
|
+
<button
|
|
352
|
+
type="button"
|
|
353
|
+
className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
|
|
354
|
+
onClick={(e) => {
|
|
355
|
+
e.stopPropagation();
|
|
356
|
+
navigation.handleClick(row);
|
|
357
|
+
}}
|
|
358
|
+
>
|
|
359
|
+
{displayContent}
|
|
360
|
+
</button>
|
|
361
|
+
);
|
|
362
|
+
};
|
|
363
|
+
} else if (col.link) {
|
|
364
|
+
// Link column: clicking navigates to the record detail
|
|
365
|
+
cellRenderer = (value: any, row: any) => {
|
|
366
|
+
const displayContent = CellRenderer
|
|
367
|
+
? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
|
|
368
|
+
: (value != null && value !== '' ? String(value) : <span className="text-muted-foreground">-</span>);
|
|
369
|
+
return (
|
|
370
|
+
<button
|
|
371
|
+
type="button"
|
|
372
|
+
className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
|
|
373
|
+
onClick={(e) => {
|
|
374
|
+
e.stopPropagation();
|
|
375
|
+
navigation.handleClick(row);
|
|
376
|
+
}}
|
|
377
|
+
>
|
|
378
|
+
{displayContent}
|
|
379
|
+
</button>
|
|
380
|
+
);
|
|
381
|
+
};
|
|
382
|
+
} else if (col.action) {
|
|
383
|
+
// Action column: clicking executes the registered action
|
|
384
|
+
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
|
+
return (
|
|
389
|
+
<button
|
|
390
|
+
type="button"
|
|
391
|
+
className="text-primary underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
|
|
392
|
+
onClick={(e) => {
|
|
393
|
+
e.stopPropagation();
|
|
394
|
+
executeAction({
|
|
395
|
+
type: col.action!,
|
|
396
|
+
params: { record: row, field: col.field, value },
|
|
397
|
+
});
|
|
398
|
+
}}
|
|
399
|
+
>
|
|
400
|
+
{displayContent}
|
|
401
|
+
</button>
|
|
402
|
+
);
|
|
403
|
+
};
|
|
404
|
+
} else if (CellRenderer) {
|
|
405
|
+
// Type-only cell renderer (no link/action)
|
|
406
|
+
cellRenderer = (value: any) => (
|
|
407
|
+
<CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
|
|
408
|
+
);
|
|
409
|
+
} else {
|
|
410
|
+
// Default renderer with empty value handling
|
|
411
|
+
cellRenderer = (value: any) => (
|
|
412
|
+
value != null && value !== ''
|
|
413
|
+
? <span>{String(value)}</span>
|
|
414
|
+
: <span className="text-muted-foreground">-</span>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Auto-infer alignment from field type if not explicitly set
|
|
419
|
+
const numericTypes = ['number', 'currency', 'percent'];
|
|
420
|
+
const inferredAlign = col.align || (col.type && numericTypes.includes(col.type) ? 'right' as const : undefined);
|
|
421
|
+
|
|
422
|
+
// Determine if column should be hidden on mobile
|
|
423
|
+
const isEssential = colIndex === 0 || (col as any).essential === true;
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
header,
|
|
427
|
+
accessorKey: col.field,
|
|
428
|
+
...(!isEssential && { className: 'hidden sm:table-cell' }),
|
|
429
|
+
...(col.width && { width: col.width }),
|
|
430
|
+
...(inferredAlign && { align: inferredAlign }),
|
|
431
|
+
sortable: col.sortable !== false,
|
|
432
|
+
...(col.resizable !== undefined && { resizable: col.resizable }),
|
|
433
|
+
...(col.wrap !== undefined && { wrap: col.wrap }),
|
|
434
|
+
...(cellRenderer && { cell: cellRenderer }),
|
|
435
|
+
};
|
|
436
|
+
});
|
|
222
437
|
}
|
|
223
438
|
}
|
|
224
439
|
|
|
@@ -238,7 +453,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
238
453
|
if (hasInlineData) {
|
|
239
454
|
const inlineData = dataConfig?.provider === 'value' ? dataConfig.items as any[] : [];
|
|
240
455
|
if (inlineData.length > 0) {
|
|
241
|
-
const fieldsToShow =
|
|
456
|
+
const fieldsToShow = schemaFields || Object.keys(inlineData[0]);
|
|
242
457
|
return fieldsToShow.map((fieldName) => ({
|
|
243
458
|
header: fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' '),
|
|
244
459
|
accessorKey: fieldName,
|
|
@@ -249,7 +464,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
249
464
|
if (!objectSchema) return [];
|
|
250
465
|
|
|
251
466
|
const generatedColumns: any[] = [];
|
|
252
|
-
const fieldsToShow =
|
|
467
|
+
const fieldsToShow = schemaFields || Object.keys(objectSchema.fields || {});
|
|
253
468
|
|
|
254
469
|
fieldsToShow.forEach((fieldName) => {
|
|
255
470
|
const field = objectSchema.fields?.[fieldName];
|
|
@@ -258,87 +473,22 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
258
473
|
if (field.permissions && field.permissions.read === false) return;
|
|
259
474
|
|
|
260
475
|
const CellRenderer = getCellRenderer(field.type);
|
|
476
|
+
const numericTypes = ['number', 'currency', 'percent'];
|
|
261
477
|
generatedColumns.push({
|
|
262
478
|
header: field.label || fieldName,
|
|
263
479
|
accessorKey: fieldName,
|
|
480
|
+
...(numericTypes.includes(field.type) && { align: 'right' }),
|
|
264
481
|
cell: (value: any) => <CellRenderer value={value} field={field} />,
|
|
265
482
|
sortable: field.sortable !== false,
|
|
266
483
|
});
|
|
267
484
|
});
|
|
268
485
|
|
|
269
486
|
return generatedColumns;
|
|
270
|
-
}, [objectSchema,
|
|
271
|
-
|
|
272
|
-
const fetchData = useCallback(async () => {
|
|
273
|
-
if (hasInlineData || !dataSource) return;
|
|
274
|
-
|
|
275
|
-
setLoading(true);
|
|
276
|
-
try {
|
|
277
|
-
// Get object name from data config or schema
|
|
278
|
-
const objectName = dataConfig?.provider === 'object' && 'object' in dataConfig
|
|
279
|
-
? dataConfig.object
|
|
280
|
-
: schema.objectName;
|
|
281
|
-
|
|
282
|
-
if (!objectName) {
|
|
283
|
-
throw new Error('Object name required for data fetching');
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Helper to get select fields
|
|
287
|
-
const getSelectFields = () => {
|
|
288
|
-
if (schema.fields) return schema.fields;
|
|
289
|
-
if (schema.columns && Array.isArray(schema.columns)) {
|
|
290
|
-
return schema.columns.map(c => typeof c === 'string' ? c : c.field);
|
|
291
|
-
}
|
|
292
|
-
return undefined;
|
|
293
|
-
};
|
|
294
|
-
|
|
295
|
-
const params: any = {
|
|
296
|
-
$select: getSelectFields(),
|
|
297
|
-
$top: schema.pagination?.pageSize || schema.pageSize || 50,
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
// Support new filter format
|
|
301
|
-
if (schema.filter && Array.isArray(schema.filter)) {
|
|
302
|
-
params.$filter = schema.filter;
|
|
303
|
-
} else if ('defaultFilters' in schema && schema.defaultFilters) {
|
|
304
|
-
// Legacy support
|
|
305
|
-
params.$filter = schema.defaultFilters;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Support new sort format
|
|
309
|
-
if (schema.sort) {
|
|
310
|
-
if (typeof schema.sort === 'string') {
|
|
311
|
-
// Legacy string format
|
|
312
|
-
params.$orderby = schema.sort;
|
|
313
|
-
} else if (Array.isArray(schema.sort)) {
|
|
314
|
-
// New array format
|
|
315
|
-
params.$orderby = schema.sort
|
|
316
|
-
.map(s => `${s.field} ${s.order}`)
|
|
317
|
-
.join(', ');
|
|
318
|
-
}
|
|
319
|
-
} else if ('defaultSort' in schema && schema.defaultSort) {
|
|
320
|
-
// Legacy support
|
|
321
|
-
params.$orderby = `${schema.defaultSort.field} ${schema.defaultSort.order}`;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const result = await dataSource.find(objectName, params);
|
|
325
|
-
setData(result.data || []);
|
|
326
|
-
} catch (err) {
|
|
327
|
-
setError(err as Error);
|
|
328
|
-
} finally {
|
|
329
|
-
setLoading(false);
|
|
330
|
-
}
|
|
331
|
-
}, [schema, dataSource, hasInlineData, dataConfig]);
|
|
332
|
-
|
|
333
|
-
useEffect(() => {
|
|
334
|
-
if (objectSchema || hasInlineData) {
|
|
335
|
-
fetchData();
|
|
336
|
-
}
|
|
337
|
-
}, [objectSchema, hasInlineData, fetchData]);
|
|
487
|
+
}, [objectSchema, schemaFields, schemaColumns, dataConfig, hasInlineData, navigation.handleClick, executeAction]);
|
|
338
488
|
|
|
339
489
|
if (error) {
|
|
340
490
|
return (
|
|
341
|
-
<div className="p-4 border border-red-300 bg-red-50 rounded-md">
|
|
491
|
+
<div className="p-3 sm:p-4 border border-red-300 bg-red-50 rounded-md">
|
|
342
492
|
<h3 className="text-red-800 font-semibold">Error loading grid</h3>
|
|
343
493
|
<p className="text-red-600 text-sm mt-1">{error.message}</p>
|
|
344
494
|
</div>
|
|
@@ -347,7 +497,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
347
497
|
|
|
348
498
|
if (loading && data.length === 0) {
|
|
349
499
|
return (
|
|
350
|
-
<div className="p-8 text-center">
|
|
500
|
+
<div className="p-4 sm:p-8 text-center">
|
|
351
501
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
|
352
502
|
<p className="mt-2 text-sm text-gray-600">Loading grid...</p>
|
|
353
503
|
</div>
|
|
@@ -357,7 +507,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
357
507
|
const columns = generateColumns();
|
|
358
508
|
const operations = 'operations' in schema ? schema.operations : undefined;
|
|
359
509
|
const hasActions = operations && (operations.update || operations.delete);
|
|
360
|
-
|
|
510
|
+
|
|
361
511
|
const columnsWithActions = hasActions ? [
|
|
362
512
|
...columns,
|
|
363
513
|
{
|
|
@@ -366,7 +516,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
366
516
|
cell: (_value: any, row: any) => (
|
|
367
517
|
<DropdownMenu>
|
|
368
518
|
<DropdownMenuTrigger asChild>
|
|
369
|
-
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
519
|
+
<Button variant="ghost" size="icon" className="h-8 w-8 min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0">
|
|
370
520
|
<MoreVertical className="h-4 w-4" />
|
|
371
521
|
<span className="sr-only">Open menu</span>
|
|
372
522
|
</Button>
|
|
@@ -428,10 +578,156 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
|
|
|
428
578
|
rowActions: hasActions,
|
|
429
579
|
resizableColumns: schema.resizable ?? schema.resizableColumns ?? true,
|
|
430
580
|
reorderableColumns: schema.reorderableColumns ?? false,
|
|
581
|
+
editable: schema.editable ?? false,
|
|
431
582
|
className: schema.className,
|
|
583
|
+
cellClassName: 'px-2 py-1.5 sm:px-3 sm:py-2 md:px-4 md:py-2.5',
|
|
584
|
+
rowClassName: schema.rowColor ? (row: any, _idx: number) => getRowClassName(row) : undefined,
|
|
432
585
|
onSelectionChange: onRowSelect,
|
|
433
|
-
onRowClick:
|
|
586
|
+
onRowClick: navigation.handleClick,
|
|
587
|
+
onCellChange: onCellChange,
|
|
588
|
+
onRowSave: onRowSave,
|
|
589
|
+
onBatchSave: onBatchSave,
|
|
434
590
|
};
|
|
435
591
|
|
|
436
|
-
|
|
592
|
+
/** Build a per-group data-table schema (inherits everything except data & pagination). */
|
|
593
|
+
const buildGroupTableSchema = (groupRows: any[]) => ({
|
|
594
|
+
...dataTableSchema,
|
|
595
|
+
caption: undefined,
|
|
596
|
+
data: groupRows,
|
|
597
|
+
pagination: false,
|
|
598
|
+
searchable: false,
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// Build record detail title
|
|
602
|
+
const detailTitle = schema.label
|
|
603
|
+
? `${schema.label} Detail`
|
|
604
|
+
: schema.objectName
|
|
605
|
+
? `${schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1)} Detail`
|
|
606
|
+
: 'Record Detail';
|
|
607
|
+
|
|
608
|
+
// Mobile card-view fallback for screens below 480px
|
|
609
|
+
if (useCardView && data.length > 0 && !isGrouped) {
|
|
610
|
+
const displayColumns = generateColumns().filter((c: any) => c.accessorKey !== '_actions');
|
|
611
|
+
return (
|
|
612
|
+
<>
|
|
613
|
+
<div className="space-y-2 p-2">
|
|
614
|
+
{data.map((row, idx) => (
|
|
615
|
+
<div
|
|
616
|
+
key={row.id || row._id || idx}
|
|
617
|
+
className="border rounded-lg p-3 bg-card hover:bg-accent/50 cursor-pointer transition-colors touch-manipulation"
|
|
618
|
+
onClick={() => navigation.handleClick(row)}
|
|
619
|
+
>
|
|
620
|
+
{displayColumns.slice(0, 4).map((col: any) => (
|
|
621
|
+
<div key={col.accessorKey} className="flex justify-between items-center py-1">
|
|
622
|
+
<span className="text-xs text-muted-foreground">{col.header}</span>
|
|
623
|
+
<span className="text-sm font-medium truncate ml-2 text-right">
|
|
624
|
+
{col.cell ? col.cell(row[col.accessorKey], row) : String(row[col.accessorKey] ?? '—')}
|
|
625
|
+
</span>
|
|
626
|
+
</div>
|
|
627
|
+
))}
|
|
628
|
+
</div>
|
|
629
|
+
))}
|
|
630
|
+
</div>
|
|
631
|
+
{navigation.isOverlay && (
|
|
632
|
+
<NavigationOverlay {...navigation} title={detailTitle}>
|
|
633
|
+
{(record) => (
|
|
634
|
+
<div className="space-y-3">
|
|
635
|
+
{Object.entries(record).map(([key, value]) => (
|
|
636
|
+
<div key={key} className="flex flex-col">
|
|
637
|
+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
638
|
+
{key.replace(/_/g, ' ')}
|
|
639
|
+
</span>
|
|
640
|
+
<span className="text-sm">{String(value ?? '—')}</span>
|
|
641
|
+
</div>
|
|
642
|
+
))}
|
|
643
|
+
</div>
|
|
644
|
+
)}
|
|
645
|
+
</NavigationOverlay>
|
|
646
|
+
)}
|
|
647
|
+
</>
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Render grid content: grouped (multiple tables with headers) or flat (single table)
|
|
652
|
+
const gridContent = isGrouped ? (
|
|
653
|
+
<div className="space-y-2">
|
|
654
|
+
{groups.map((group) => (
|
|
655
|
+
<div key={group.key} className="border rounded-md">
|
|
656
|
+
<button
|
|
657
|
+
type="button"
|
|
658
|
+
className="flex w-full items-center gap-2 px-3 py-2 text-sm font-medium text-left bg-muted/50 hover:bg-muted transition-colors"
|
|
659
|
+
onClick={() => toggleGroup(group.key)}
|
|
660
|
+
>
|
|
661
|
+
{group.collapsed
|
|
662
|
+
? <ChevronRight className="h-4 w-4 shrink-0" />
|
|
663
|
+
: <ChevronDown className="h-4 w-4 shrink-0" />}
|
|
664
|
+
<span>{group.label}</span>
|
|
665
|
+
<span className="ml-auto text-xs text-muted-foreground">{group.rows.length}</span>
|
|
666
|
+
</button>
|
|
667
|
+
{!group.collapsed && (
|
|
668
|
+
<SchemaRenderer schema={buildGroupTableSchema(group.rows)} />
|
|
669
|
+
)}
|
|
670
|
+
</div>
|
|
671
|
+
))}
|
|
672
|
+
</div>
|
|
673
|
+
) : (
|
|
674
|
+
<SchemaRenderer schema={dataTableSchema} />
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
// For split mode, wrap the grid in the ResizablePanelGroup
|
|
678
|
+
if (navigation.isOverlay && navigation.mode === 'split') {
|
|
679
|
+
return (
|
|
680
|
+
<NavigationOverlay
|
|
681
|
+
{...navigation}
|
|
682
|
+
title={detailTitle}
|
|
683
|
+
mainContent={gridContent}
|
|
684
|
+
>
|
|
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
|
+
)}
|
|
697
|
+
</NavigationOverlay>
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return (
|
|
702
|
+
<div ref={pullRef} className="relative h-full">
|
|
703
|
+
{pullDistance > 0 && (
|
|
704
|
+
<div
|
|
705
|
+
className="flex items-center justify-center text-xs text-muted-foreground"
|
|
706
|
+
style={{ height: pullDistance }}
|
|
707
|
+
>
|
|
708
|
+
{isRefreshing ? 'Refreshing…' : 'Pull to refresh'}
|
|
709
|
+
</div>
|
|
710
|
+
)}
|
|
711
|
+
{gridContent}
|
|
712
|
+
{navigation.isOverlay && (
|
|
713
|
+
<NavigationOverlay
|
|
714
|
+
{...navigation}
|
|
715
|
+
title={detailTitle}
|
|
716
|
+
>
|
|
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
|
+
)}
|
|
729
|
+
</NavigationOverlay>
|
|
730
|
+
)}
|
|
731
|
+
</div>
|
|
732
|
+
);
|
|
437
733
|
};
|