@object-ui/plugin-grid 0.3.1 → 2.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.
@@ -23,9 +23,9 @@
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 } 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
29
  import {
30
30
  DropdownMenu,
31
31
  DropdownMenuContent,
@@ -42,7 +42,9 @@ export interface ObjectGridProps {
42
42
  onEdit?: (record: any) => void;
43
43
  onDelete?: (record: any) => void;
44
44
  onBulkDelete?: (records: any[]) => void;
45
- onCellChange?: (rowIndex: number, columnKey: string, newValue: any) => void;
45
+ onCellChange?: (rowIndex: number, columnKey: string, newValue: any, row: any) => void;
46
+ onRowSave?: (rowIndex: number, changes: Record<string, any>, row: any) => void | Promise<void>;
47
+ onBatchSave?: (changes: Array<{ rowIndex: number; changes: Record<string, any>; row: any }>) => void | Promise<void>;
46
48
  onRowSelect?: (selectedRows: any[]) => void;
47
49
  }
48
50
 
@@ -53,6 +55,15 @@ export interface ObjectGridProps {
53
55
  function getDataConfig(schema: ObjectGridSchema): ViewData | null {
54
56
  // New format: explicit data configuration
55
57
  if (schema.data) {
58
+ // Check if data is an array (shorthand format) or already a ViewData object
59
+ if (Array.isArray(schema.data)) {
60
+ // Convert array shorthand to proper ViewData format
61
+ return {
62
+ provider: 'value',
63
+ items: schema.data,
64
+ };
65
+ }
66
+ // Already in ViewData format
56
67
  return schema.data;
57
68
  }
58
69
 
@@ -99,88 +110,308 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
99
110
  onEdit,
100
111
  onDelete,
101
112
  onRowSelect,
113
+ onRowClick,
114
+ onCellChange,
115
+ onRowSave,
116
+ onBatchSave,
117
+ ...rest
102
118
  }) => {
103
119
  const [data, setData] = useState<any[]>([]);
104
120
  const [loading, setLoading] = useState(true);
105
121
  const [error, setError] = useState<Error | null>(null);
106
122
  const [objectSchema, setObjectSchema] = useState<any>(null);
107
123
 
124
+ // Check if data is passed directly (from ListView)
125
+ const passedData = (rest as any).data;
126
+
127
+ // Resolve bound data if 'bind' property exists
128
+ const boundData = useDataScope(schema.bind);
129
+
108
130
  // Get data configuration (supports both new and legacy formats)
109
- const dataConfig = getDataConfig(schema);
131
+ const rawDataConfig = getDataConfig(schema);
132
+ // Memoize dataConfig using deep comparison to prevent infinite loops
133
+ const dataConfig = React.useMemo(() => {
134
+ // If we have passed data (highest priority), treat it as value provider
135
+ if (passedData && Array.isArray(passedData)) {
136
+ return {
137
+ provider: 'value',
138
+ items: passedData
139
+ };
140
+ }
141
+
142
+ // If we have bound data, it takes precedence as inline value
143
+ if (boundData && Array.isArray(boundData)) {
144
+ return {
145
+ provider: 'value',
146
+ items: boundData
147
+ };
148
+ }
149
+ return rawDataConfig;
150
+ }, [JSON.stringify(rawDataConfig), boundData, passedData]);
151
+
110
152
  const hasInlineData = dataConfig?.provider === 'value';
111
153
 
154
+ // Extract stable primitive/reference-stable values from schema for dependency arrays.
155
+ // This prevents infinite re-render loops when schema is a new object on each render
156
+ // (e.g. when rendered through SchemaRenderer which creates a fresh evaluatedSchema).
157
+ const objectName = dataConfig?.provider === 'object' && dataConfig && 'object' in dataConfig
158
+ ? (dataConfig as any).object
159
+ : schema.objectName;
160
+ const schemaFields = schema.fields;
161
+ const schemaColumns = schema.columns;
162
+ const schemaFilter = schema.filter;
163
+ const schemaSort = schema.sort;
164
+ const schemaPagination = schema.pagination;
165
+ const schemaPageSize = schema.pageSize;
166
+
167
+ // --- Inline data effect (synchronous, no fetch needed) ---
112
168
  useEffect(() => {
113
169
  if (hasInlineData && dataConfig?.provider === 'value') {
114
- setData(dataConfig.items as any[]);
115
- setLoading(false);
170
+ // Only update if data is different to avoid infinite loop
171
+ setData(prev => {
172
+ const newItems = dataConfig.items as any[];
173
+ if (JSON.stringify(prev) !== JSON.stringify(newItems)) {
174
+ return newItems;
175
+ }
176
+ return prev;
177
+ });
178
+ setLoading(false);
116
179
  }
117
180
  }, [hasInlineData, dataConfig]);
118
181
 
182
+ // --- Unified async data loading effect ---
183
+ // Combines schema fetch + data fetch into a single async flow with AbortController.
184
+ // This avoids the fragile "chained effects" pattern where Effect 1 sets objectSchema,
185
+ // triggering Effect 2 to call fetchData — a pattern prone to infinite loops when
186
+ // fetchData's reference is unstable.
119
187
  useEffect(() => {
120
- const fetchObjectSchema = async () => {
188
+ if (hasInlineData) return;
189
+
190
+ let cancelled = false;
191
+
192
+ const loadSchemaAndData = async () => {
193
+ setLoading(true);
194
+ setError(null);
121
195
  try {
122
- if (!dataSource) {
196
+ // --- Step 1: Resolve object schema ---
197
+ let resolvedSchema: any = null;
198
+ const cols = normalizeColumns(schemaColumns) || schemaFields;
199
+
200
+ if (cols && objectName) {
201
+ // We have explicit columns — use a minimal schema stub
202
+ resolvedSchema = { name: objectName, fields: {} };
203
+ } else if (objectName && dataSource) {
204
+ // Fetch full schema from DataSource
205
+ const schemaData = await dataSource.getObjectSchema(objectName);
206
+ if (cancelled) return;
207
+ resolvedSchema = schemaData;
208
+ } else if (!objectName) {
209
+ throw new Error('Object name required for data fetching');
210
+ } else {
123
211
  throw new Error('DataSource required');
124
212
  }
125
-
126
- // For object provider, get the object name
127
- const objectName = dataConfig?.provider === 'object'
128
- ? dataConfig.object
129
- : schema.objectName;
130
-
131
- if (!objectName) {
132
- throw new Error('Object name required for object provider');
213
+
214
+ if (!cancelled) {
215
+ setObjectSchema(resolvedSchema);
216
+ }
217
+
218
+ // --- Step 2: Fetch data ---
219
+ if (dataSource && objectName) {
220
+ const getSelectFields = () => {
221
+ if (schemaFields) return schemaFields;
222
+ if (schemaColumns && Array.isArray(schemaColumns)) {
223
+ return schemaColumns.map((c: any) => typeof c === 'string' ? c : c.field);
224
+ }
225
+ return undefined;
226
+ };
227
+
228
+ const params: any = {
229
+ $select: getSelectFields(),
230
+ $top: (schemaPagination as any)?.pageSize || schemaPageSize || 50,
231
+ };
232
+
233
+ // Support new filter format
234
+ if (schemaFilter && Array.isArray(schemaFilter)) {
235
+ params.$filter = schemaFilter;
236
+ } else if (schema.defaultFilters) {
237
+ // Legacy support
238
+ params.$filter = schema.defaultFilters;
239
+ }
240
+
241
+ // Support new sort format
242
+ if (schemaSort) {
243
+ if (typeof schemaSort === 'string') {
244
+ params.$orderby = schemaSort;
245
+ } else if (Array.isArray(schemaSort)) {
246
+ params.$orderby = schemaSort
247
+ .map((s: any) => `${s.field} ${s.order}`)
248
+ .join(', ');
249
+ }
250
+ } else if (schema.defaultSort) {
251
+ // Legacy support
252
+ params.$orderby = `${(schema.defaultSort as any).field} ${(schema.defaultSort as any).order}`;
253
+ }
254
+
255
+ const result = await dataSource.find(objectName, params);
256
+ if (cancelled) return;
257
+ setData(result.data || []);
133
258
  }
134
-
135
- const schemaData = await dataSource.getObjectSchema(objectName);
136
- setObjectSchema(schemaData);
137
259
  } catch (err) {
138
- setError(err as Error);
260
+ if (!cancelled) {
261
+ setError(err as Error);
262
+ }
263
+ } finally {
264
+ if (!cancelled) {
265
+ setLoading(false);
266
+ }
139
267
  }
140
268
  };
141
269
 
142
- // Normalize columns (support both legacy 'fields' and new 'columns')
143
- const cols = normalizeColumns(schema.columns) || schema.fields;
144
-
145
- if (hasInlineData && cols) {
146
- setObjectSchema({ name: schema.objectName, fields: {} });
147
- } else if (schema.objectName && !hasInlineData && dataSource) {
148
- fetchObjectSchema();
149
- }
150
- }, [schema.objectName, schema.columns, schema.fields, dataSource, hasInlineData, dataConfig]);
270
+ loadSchemaAndData();
271
+
272
+ return () => {
273
+ cancelled = true;
274
+ };
275
+ }, [objectName, schemaFields, schemaColumns, schemaFilter, schemaSort, schemaPagination, schemaPageSize, dataSource, hasInlineData, dataConfig]);
276
+
277
+ // --- NavigationConfig support ---
278
+ // Must be called before any early returns to satisfy React hooks rules
279
+ const navigation = useNavigationOverlay({
280
+ navigation: schema.navigation,
281
+ objectName: schema.objectName,
282
+ onNavigate: schema.onNavigate,
283
+ onRowClick,
284
+ });
285
+
286
+ // --- Action support for action columns ---
287
+ const { execute: executeAction } = useAction();
151
288
 
152
289
  const generateColumns = useCallback(() => {
153
290
  // Use normalized columns (support both new and legacy)
154
- const cols = normalizeColumns(schema.columns);
291
+ const cols = normalizeColumns(schemaColumns);
155
292
 
156
293
  if (cols) {
157
- // If columns are already ListColumn objects, convert them to data-table format
294
+ // Check if columns are already in data-table format (have 'accessorKey')
295
+ // vs ListColumn format (have 'field')
158
296
  if (cols.length > 0 && typeof cols[0] === 'object' && cols[0] !== null) {
159
- return (cols as ListColumn[])
160
- .filter((col) => col?.field && typeof col.field === 'string') // Filter out invalid column objects
161
- .map((col) => ({
162
- header: col.label || col.field.charAt(0).toUpperCase() + col.field.slice(1).replace(/_/g, ' '),
163
- accessorKey: col.field,
164
- ...(col.width && { width: col.width }),
165
- ...(col.align && { align: col.align }),
166
- sortable: col.sortable !== false,
167
- }));
297
+ const firstCol = cols[0] as any;
298
+
299
+ // Already in data-table format - use as-is
300
+ if ('accessorKey' in firstCol) {
301
+ return cols;
302
+ }
303
+
304
+ // ListColumn format - convert to data-table format with full feature support
305
+ if ('field' in firstCol) {
306
+ return (cols as ListColumn[])
307
+ .filter((col) => col?.field && typeof col.field === 'string' && !col.hidden)
308
+ .map((col) => {
309
+ const header = col.label || col.field.charAt(0).toUpperCase() + col.field.slice(1).replace(/_/g, ' ');
310
+
311
+ // Build custom cell renderer based on column configuration
312
+ let cellRenderer: ((value: any, row: any) => React.ReactNode) | undefined;
313
+
314
+ // Type-based cell renderer (e.g., "currency", "date", "boolean")
315
+ const CellRenderer = col.type ? getCellRenderer(col.type) : null;
316
+
317
+ if (col.link && col.action) {
318
+ // Both link and action: link takes priority for navigation, action executes on secondary interaction
319
+ cellRenderer = (value: any, row: any) => {
320
+ const displayContent = CellRenderer
321
+ ? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
322
+ : String(value ?? '');
323
+ return (
324
+ <button
325
+ type="button"
326
+ className="text-primary underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
327
+ onClick={(e) => {
328
+ e.stopPropagation();
329
+ navigation.handleClick(row);
330
+ }}
331
+ >
332
+ {displayContent}
333
+ </button>
334
+ );
335
+ };
336
+ } else if (col.link) {
337
+ // Link column: clicking navigates to the record detail
338
+ cellRenderer = (value: any, row: any) => {
339
+ const displayContent = CellRenderer
340
+ ? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
341
+ : String(value ?? '');
342
+ return (
343
+ <button
344
+ type="button"
345
+ className="text-primary underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
346
+ onClick={(e) => {
347
+ e.stopPropagation();
348
+ navigation.handleClick(row);
349
+ }}
350
+ >
351
+ {displayContent}
352
+ </button>
353
+ );
354
+ };
355
+ } else if (col.action) {
356
+ // Action column: clicking executes the registered action
357
+ cellRenderer = (value: any, row: any) => {
358
+ const displayContent = CellRenderer
359
+ ? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
360
+ : String(value ?? '');
361
+ return (
362
+ <button
363
+ type="button"
364
+ className="text-primary underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
365
+ onClick={(e) => {
366
+ e.stopPropagation();
367
+ executeAction({
368
+ type: col.action!,
369
+ params: { record: row, field: col.field, value },
370
+ });
371
+ }}
372
+ >
373
+ {displayContent}
374
+ </button>
375
+ );
376
+ };
377
+ } else if (CellRenderer) {
378
+ // Type-only cell renderer (no link/action)
379
+ cellRenderer = (value: any) => (
380
+ <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
381
+ );
382
+ }
383
+
384
+ return {
385
+ header,
386
+ accessorKey: col.field,
387
+ ...(col.width && { width: col.width }),
388
+ ...(col.align && { align: col.align }),
389
+ sortable: col.sortable !== false,
390
+ ...(col.resizable !== undefined && { resizable: col.resizable }),
391
+ ...(col.wrap !== undefined && { wrap: col.wrap }),
392
+ ...(cellRenderer && { cell: cellRenderer }),
393
+ };
394
+ });
395
+ }
168
396
  }
169
397
 
170
398
  // String array format - filter out invalid entries
171
399
  return (cols as string[])
172
400
  .filter((fieldName) => typeof fieldName === 'string' && fieldName.trim().length > 0)
173
- .map((fieldName) => ({
174
- header: fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' '),
175
- accessorKey: fieldName,
176
- }));
401
+ .map((fieldName) => {
402
+ const fieldLabel = objectSchema?.fields?.[fieldName]?.label;
403
+ return {
404
+ header: fieldLabel || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' '),
405
+ accessorKey: fieldName,
406
+ };
407
+ });
177
408
  }
178
409
 
179
410
  // Legacy support: use 'fields' if columns not provided
180
411
  if (hasInlineData) {
181
412
  const inlineData = dataConfig?.provider === 'value' ? dataConfig.items as any[] : [];
182
413
  if (inlineData.length > 0) {
183
- const fieldsToShow = schema.fields || Object.keys(inlineData[0]);
414
+ const fieldsToShow = schemaFields || Object.keys(inlineData[0]);
184
415
  return fieldsToShow.map((fieldName) => ({
185
416
  header: fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' '),
186
417
  accessorKey: fieldName,
@@ -191,7 +422,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
191
422
  if (!objectSchema) return [];
192
423
 
193
424
  const generatedColumns: any[] = [];
194
- const fieldsToShow = schema.fields || Object.keys(objectSchema.fields || {});
425
+ const fieldsToShow = schemaFields || Object.keys(objectSchema.fields || {});
195
426
 
196
427
  fieldsToShow.forEach((fieldName) => {
197
428
  const field = objectSchema.fields?.[fieldName];
@@ -209,74 +440,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
209
440
  });
210
441
 
211
442
  return generatedColumns;
212
- }, [objectSchema, schema.fields, schema.columns, dataConfig, hasInlineData]);
213
-
214
- const fetchData = useCallback(async () => {
215
- if (hasInlineData || !dataSource) return;
216
-
217
- setLoading(true);
218
- try {
219
- // Get object name from data config or schema
220
- const objectName = dataConfig?.provider === 'object'
221
- ? dataConfig.object
222
- : schema.objectName;
223
-
224
- if (!objectName) {
225
- throw new Error('Object name required for data fetching');
226
- }
227
-
228
- // Helper to get select fields
229
- const getSelectFields = () => {
230
- if (schema.fields) return schema.fields;
231
- if (schema.columns && Array.isArray(schema.columns)) {
232
- return schema.columns.map(c => typeof c === 'string' ? c : c.field);
233
- }
234
- return undefined;
235
- };
236
-
237
- const params: any = {
238
- $select: getSelectFields(),
239
- $top: schema.pagination?.pageSize || schema.pageSize || 50,
240
- };
241
-
242
- // Support new filter format
243
- if (schema.filter && Array.isArray(schema.filter)) {
244
- params.$filter = schema.filter;
245
- } else if ('defaultFilters' in schema && schema.defaultFilters) {
246
- // Legacy support
247
- params.$filter = schema.defaultFilters;
248
- }
249
-
250
- // Support new sort format
251
- if (schema.sort) {
252
- if (typeof schema.sort === 'string') {
253
- // Legacy string format
254
- params.$orderby = schema.sort;
255
- } else if (Array.isArray(schema.sort)) {
256
- // New array format
257
- params.$orderby = schema.sort
258
- .map(s => `${s.field} ${s.order}`)
259
- .join(', ');
260
- }
261
- } else if ('defaultSort' in schema && schema.defaultSort) {
262
- // Legacy support
263
- params.$orderby = `${schema.defaultSort.field} ${schema.defaultSort.order}`;
264
- }
265
-
266
- const result = await dataSource.find(objectName, params);
267
- setData(result.data || []);
268
- } catch (err) {
269
- setError(err as Error);
270
- } finally {
271
- setLoading(false);
272
- }
273
- }, [schema, dataSource, hasInlineData, dataConfig]);
274
-
275
- useEffect(() => {
276
- if (objectSchema || hasInlineData) {
277
- fetchData();
278
- }
279
- }, [objectSchema, hasInlineData, fetchData]);
443
+ }, [objectSchema, schemaFields, schemaColumns, dataConfig, hasInlineData, navigation.handleClick, executeAction]);
280
444
 
281
445
  if (error) {
282
446
  return (
@@ -299,7 +463,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
299
463
  const columns = generateColumns();
300
464
  const operations = 'operations' in schema ? schema.operations : undefined;
301
465
  const hasActions = operations && (operations.update || operations.delete);
302
-
466
+
303
467
  const columnsWithActions = hasActions ? [
304
468
  ...columns,
305
469
  {
@@ -321,7 +485,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
321
485
  </DropdownMenuItem>
322
486
  )}
323
487
  {operations?.delete && onDelete && (
324
- <DropdownMenuItem variant="destructive" onClick={() => onDelete(row)}>
488
+ <DropdownMenuItem onClick={() => onDelete(row)}>
325
489
  <Trash2 className="mr-2 h-4 w-4" />
326
490
  Delete
327
491
  </DropdownMenuItem>
@@ -369,9 +533,69 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
369
533
  exportable: operations?.export,
370
534
  rowActions: hasActions,
371
535
  resizableColumns: schema.resizable ?? schema.resizableColumns ?? true,
536
+ reorderableColumns: schema.reorderableColumns ?? false,
537
+ editable: schema.editable ?? false,
372
538
  className: schema.className,
373
539
  onSelectionChange: onRowSelect,
540
+ onRowClick: navigation.handleClick,
541
+ onCellChange: onCellChange,
542
+ onRowSave: onRowSave,
543
+ onBatchSave: onBatchSave,
374
544
  };
375
545
 
376
- return <SchemaRenderer schema={dataTableSchema} />;
546
+ // Build record detail title
547
+ const detailTitle = schema.label
548
+ ? `${schema.label} Detail`
549
+ : schema.objectName
550
+ ? `${schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1)} Detail`
551
+ : 'Record Detail';
552
+
553
+ // For split mode, wrap the grid in the ResizablePanelGroup
554
+ if (navigation.isOverlay && navigation.mode === 'split') {
555
+ return (
556
+ <NavigationOverlay
557
+ {...navigation}
558
+ title={detailTitle}
559
+ mainContent={<SchemaRenderer schema={dataTableSchema} />}
560
+ >
561
+ {(record) => (
562
+ <div className="space-y-3">
563
+ {Object.entries(record).map(([key, value]) => (
564
+ <div key={key} className="flex flex-col">
565
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
566
+ {key.replace(/_/g, ' ')}
567
+ </span>
568
+ <span className="text-sm">{String(value ?? '—')}</span>
569
+ </div>
570
+ ))}
571
+ </div>
572
+ )}
573
+ </NavigationOverlay>
574
+ );
575
+ }
576
+
577
+ return (
578
+ <>
579
+ <SchemaRenderer schema={dataTableSchema} />
580
+ {navigation.isOverlay && (
581
+ <NavigationOverlay
582
+ {...navigation}
583
+ title={detailTitle}
584
+ >
585
+ {(record) => (
586
+ <div className="space-y-3">
587
+ {Object.entries(record).map(([key, value]) => (
588
+ <div key={key} className="flex flex-col">
589
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
590
+ {key.replace(/_/g, ' ')}
591
+ </span>
592
+ <span className="text-sm">{String(value ?? '—')}</span>
593
+ </div>
594
+ ))}
595
+ </div>
596
+ )}
597
+ </NavigationOverlay>
598
+ )}
599
+ </>
600
+ );
377
601
  };
@@ -0,0 +1,23 @@
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
+ import { describe, it, expect } from 'vitest';
10
+ import { VirtualGrid } from './VirtualGrid';
11
+
12
+ describe('VirtualGrid', () => {
13
+ it('should be exported', () => {
14
+ expect(VirtualGrid).toBeDefined();
15
+ expect(typeof VirtualGrid).toBe('function');
16
+ });
17
+
18
+ it('should have the correct display name', () => {
19
+ // Verify it's a React component
20
+ expect(VirtualGrid.name).toBe('VirtualGrid');
21
+ });
22
+ });
23
+