@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.
Files changed (35) hide show
  1. package/.turbo/turbo-build.log +51 -6
  2. package/CHANGELOG.md +37 -0
  3. package/README.md +97 -0
  4. package/dist/index.js +994 -584
  5. package/dist/index.umd.cjs +3 -3
  6. package/dist/packages/plugin-grid/src/InlineEditing.d.ts +28 -0
  7. package/dist/packages/plugin-grid/src/ListColumnExtensions.test.d.ts +0 -0
  8. package/dist/packages/plugin-grid/src/ListColumnSchema.test.d.ts +1 -0
  9. package/dist/packages/plugin-grid/src/ObjectGrid.EdgeCases.stories.d.ts +25 -0
  10. package/dist/packages/plugin-grid/src/ObjectGrid.d.ts +7 -1
  11. package/dist/packages/plugin-grid/src/ObjectGrid.stories.d.ts +33 -0
  12. package/dist/packages/plugin-grid/src/__tests__/InlineEditing.test.d.ts +0 -0
  13. package/dist/packages/plugin-grid/src/__tests__/VirtualGrid.test.d.ts +0 -0
  14. package/dist/packages/plugin-grid/src/__tests__/accessibility.test.d.ts +0 -0
  15. package/dist/packages/plugin-grid/src/__tests__/performance-benchmark.test.d.ts +0 -0
  16. package/dist/packages/plugin-grid/src/__tests__/view-states.test.d.ts +0 -0
  17. package/dist/packages/plugin-grid/src/index.d.ts +5 -0
  18. package/dist/packages/plugin-grid/src/useGroupedData.d.ts +30 -0
  19. package/dist/packages/plugin-grid/src/useRowColor.d.ts +8 -0
  20. package/package.json +11 -10
  21. package/src/InlineEditing.tsx +235 -0
  22. package/src/ListColumnExtensions.test.tsx +374 -0
  23. package/src/ListColumnSchema.test.ts +88 -0
  24. package/src/ObjectGrid.EdgeCases.stories.tsx +147 -0
  25. package/src/ObjectGrid.msw.test.tsx +24 -1
  26. package/src/ObjectGrid.stories.tsx +139 -0
  27. package/src/ObjectGrid.tsx +409 -113
  28. package/src/__tests__/InlineEditing.test.tsx +360 -0
  29. package/src/__tests__/VirtualGrid.test.tsx +438 -0
  30. package/src/__tests__/accessibility.test.tsx +254 -0
  31. package/src/__tests__/performance-benchmark.test.tsx +182 -0
  32. package/src/__tests__/view-states.test.tsx +203 -0
  33. package/src/index.tsx +17 -2
  34. package/src/useGroupedData.ts +122 -0
  35. package/src/useRowColor.ts +74 -0
@@ -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
- const fetchObjectSchema = async () => {
209
+ if (hasInlineData) return;
210
+
211
+ let cancelled = false;
212
+
213
+ const loadSchemaAndData = async () => {
214
+ setLoading(true);
215
+ setError(null);
165
216
  try {
166
- if (!dataSource) {
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
- // For object provider, get the object name
171
- const objectName = dataConfig?.provider === 'object' && 'object' in dataConfig
172
- ? dataConfig.object
173
- : schema.objectName;
174
-
175
- if (!objectName) {
176
- throw new Error('Object name required for object provider');
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
- setError(err as Error);
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
- // Normalize columns (support both legacy 'fields' and new 'columns')
187
- const cols = normalizeColumns(schema.columns) || schema.fields;
188
-
189
- if (hasInlineData && cols) {
190
- setObjectSchema({ name: schema.objectName, fields: {} });
191
- } else if (schema.objectName && !hasInlineData && dataSource) {
192
- fetchObjectSchema();
193
- }
194
- }, [schema.objectName, schema.columns, schema.fields, dataSource, hasInlineData, dataConfig]);
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(schema.columns);
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') // Filter out invalid column objects
215
- .map((col) => ({
216
- header: col.label || col.field.charAt(0).toUpperCase() + col.field.slice(1).replace(/_/g, ' '),
217
- accessorKey: col.field,
218
- ...(col.width && { width: col.width }),
219
- ...(col.align && { align: col.align }),
220
- sortable: col.sortable !== false,
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 = schema.fields || Object.keys(inlineData[0]);
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 = schema.fields || Object.keys(objectSchema.fields || {});
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, schema.fields, schema.columns, dataConfig, hasInlineData]);
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: onRowClick,
586
+ onRowClick: navigation.handleClick,
587
+ onCellChange: onCellChange,
588
+ onRowSave: onRowSave,
589
+ onBatchSave: onBatchSave,
434
590
  };
435
591
 
436
- return <SchemaRenderer schema={dataTableSchema} />;
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
  };