@object-ui/plugin-grid 0.5.0 → 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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * ListColumn Zod Schema Tests
3
+ *
4
+ * Verifies that the ListColumnSchema Zod definition includes link and action properties.
5
+ */
6
+ import { describe, it, expect } from 'vitest';
7
+ import { ListColumnSchema } from '@object-ui/types/zod';
8
+
9
+ describe('ListColumnSchema (Zod)', () => {
10
+ it('should accept link property', () => {
11
+ const result = ListColumnSchema.safeParse({
12
+ field: 'name',
13
+ link: true,
14
+ });
15
+ expect(result.success).toBe(true);
16
+ if (result.success) {
17
+ expect(result.data.link).toBe(true);
18
+ }
19
+ });
20
+
21
+ it('should accept action property', () => {
22
+ const result = ListColumnSchema.safeParse({
23
+ field: 'status',
24
+ action: 'toggleStatus',
25
+ });
26
+ expect(result.success).toBe(true);
27
+ if (result.success) {
28
+ expect(result.data.action).toBe('toggleStatus');
29
+ }
30
+ });
31
+
32
+ it('should accept all properties together', () => {
33
+ const result = ListColumnSchema.safeParse({
34
+ field: 'name',
35
+ label: 'Full Name',
36
+ width: 200,
37
+ align: 'left',
38
+ hidden: false,
39
+ sortable: true,
40
+ resizable: true,
41
+ wrap: false,
42
+ type: 'text',
43
+ link: true,
44
+ action: 'viewDetail',
45
+ });
46
+ expect(result.success).toBe(true);
47
+ if (result.success) {
48
+ expect(result.data.link).toBe(true);
49
+ expect(result.data.action).toBe('viewDetail');
50
+ }
51
+ });
52
+
53
+ it('should make link optional', () => {
54
+ const result = ListColumnSchema.safeParse({
55
+ field: 'name',
56
+ });
57
+ expect(result.success).toBe(true);
58
+ if (result.success) {
59
+ expect(result.data.link).toBeUndefined();
60
+ }
61
+ });
62
+
63
+ it('should make action optional', () => {
64
+ const result = ListColumnSchema.safeParse({
65
+ field: 'name',
66
+ });
67
+ expect(result.success).toBe(true);
68
+ if (result.success) {
69
+ expect(result.data.action).toBeUndefined();
70
+ }
71
+ });
72
+
73
+ it('should reject non-boolean link', () => {
74
+ const result = ListColumnSchema.safeParse({
75
+ field: 'name',
76
+ link: 'yes',
77
+ });
78
+ expect(result.success).toBe(false);
79
+ });
80
+
81
+ it('should reject non-string action', () => {
82
+ const result = ListColumnSchema.safeParse({
83
+ field: 'name',
84
+ action: 42,
85
+ });
86
+ expect(result.success).toBe(false);
87
+ });
88
+ });
@@ -30,6 +30,26 @@ const mockData = {
30
30
  // --- MSW Setup ---
31
31
 
32
32
  const handlers = [
33
+ // .well-known discovery endpoint (used by client.connect())
34
+ http.get(`${BASE_URL}/.well-known/objectstack`, () => {
35
+ return HttpResponse.json({
36
+ name: 'ObjectStack API',
37
+ version: '1.0',
38
+ endpoints: {
39
+ data: '/api/v1/data',
40
+ metadata: '/api/v1/meta'
41
+ },
42
+ capabilities: {
43
+ graphql: false,
44
+ search: false,
45
+ websockets: false,
46
+ files: true,
47
+ analytics: false,
48
+ hub: false
49
+ }
50
+ });
51
+ }),
52
+
33
53
  // OPTIONS handler for CORS preflight
34
54
  http.options(`${BASE_URL}/*`, () => {
35
55
  return new HttpResponse(null, {
@@ -47,7 +67,10 @@ const handlers = [
47
67
  return HttpResponse.json({ status: 'ok', version: '1.0.0' });
48
68
  }),
49
69
 
50
- // Schema: /api/v1/meta/object/:name
70
+ // Schema: /api/v1/metadata/object/:name and /api/v1/meta/object/:name (client uses /meta)
71
+ http.get(`${BASE_URL}/api/v1/metadata/object/contact`, () => {
72
+ return HttpResponse.json(mockSchema);
73
+ }),
51
74
  http.get(`${BASE_URL}/api/v1/meta/object/contact`, () => {
52
75
  return HttpResponse.json(mockSchema);
53
76
  }),
@@ -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, 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
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
 
@@ -109,6 +111,9 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
109
111
  onDelete,
110
112
  onRowSelect,
111
113
  onRowClick,
114
+ onCellChange,
115
+ onRowSave,
116
+ onBatchSave,
112
117
  ...rest
113
118
  }) => {
114
119
  const [data, setData] = useState<any[]>([]);
@@ -146,6 +151,20 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
146
151
 
147
152
  const hasInlineData = dataConfig?.provider === 'value';
148
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) ---
149
168
  useEffect(() => {
150
169
  if (hasInlineData && dataConfig?.provider === 'value') {
151
170
  // Only update if data is different to avoid infinite loop
@@ -160,42 +179,116 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
160
179
  }
161
180
  }, [hasInlineData, dataConfig]);
162
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.
163
187
  useEffect(() => {
164
- const fetchObjectSchema = async () => {
188
+ if (hasInlineData) return;
189
+
190
+ let cancelled = false;
191
+
192
+ const loadSchemaAndData = async () => {
193
+ setLoading(true);
194
+ setError(null);
165
195
  try {
166
- 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 {
167
211
  throw new Error('DataSource required');
168
212
  }
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');
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 || []);
177
258
  }
178
-
179
- const schemaData = await dataSource.getObjectSchema(objectName);
180
- setObjectSchema(schemaData);
181
259
  } catch (err) {
182
- setError(err as Error);
260
+ if (!cancelled) {
261
+ setError(err as Error);
262
+ }
263
+ } finally {
264
+ if (!cancelled) {
265
+ setLoading(false);
266
+ }
183
267
  }
184
268
  };
185
269
 
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]);
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();
195
288
 
196
289
  const generateColumns = useCallback(() => {
197
290
  // Use normalized columns (support both new and legacy)
198
- const cols = normalizeColumns(schema.columns);
291
+ const cols = normalizeColumns(schemaColumns);
199
292
 
200
293
  if (cols) {
201
294
  // Check if columns are already in data-table format (have 'accessorKey')
@@ -208,17 +301,97 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
208
301
  return cols;
209
302
  }
210
303
 
211
- // ListColumn format - convert to data-table format
304
+ // ListColumn format - convert to data-table format with full feature support
212
305
  if ('field' in firstCol) {
213
306
  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
- }));
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
+ });
222
395
  }
223
396
  }
224
397
 
@@ -238,7 +411,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
238
411
  if (hasInlineData) {
239
412
  const inlineData = dataConfig?.provider === 'value' ? dataConfig.items as any[] : [];
240
413
  if (inlineData.length > 0) {
241
- const fieldsToShow = schema.fields || Object.keys(inlineData[0]);
414
+ const fieldsToShow = schemaFields || Object.keys(inlineData[0]);
242
415
  return fieldsToShow.map((fieldName) => ({
243
416
  header: fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' '),
244
417
  accessorKey: fieldName,
@@ -249,7 +422,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
249
422
  if (!objectSchema) return [];
250
423
 
251
424
  const generatedColumns: any[] = [];
252
- const fieldsToShow = schema.fields || Object.keys(objectSchema.fields || {});
425
+ const fieldsToShow = schemaFields || Object.keys(objectSchema.fields || {});
253
426
 
254
427
  fieldsToShow.forEach((fieldName) => {
255
428
  const field = objectSchema.fields?.[fieldName];
@@ -267,74 +440,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
267
440
  });
268
441
 
269
442
  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]);
443
+ }, [objectSchema, schemaFields, schemaColumns, dataConfig, hasInlineData, navigation.handleClick, executeAction]);
338
444
 
339
445
  if (error) {
340
446
  return (
@@ -357,7 +463,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
357
463
  const columns = generateColumns();
358
464
  const operations = 'operations' in schema ? schema.operations : undefined;
359
465
  const hasActions = operations && (operations.update || operations.delete);
360
-
466
+
361
467
  const columnsWithActions = hasActions ? [
362
468
  ...columns,
363
469
  {
@@ -428,10 +534,68 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
428
534
  rowActions: hasActions,
429
535
  resizableColumns: schema.resizable ?? schema.resizableColumns ?? true,
430
536
  reorderableColumns: schema.reorderableColumns ?? false,
537
+ editable: schema.editable ?? false,
431
538
  className: schema.className,
432
539
  onSelectionChange: onRowSelect,
433
- onRowClick: onRowClick,
540
+ onRowClick: navigation.handleClick,
541
+ onCellChange: onCellChange,
542
+ onRowSave: onRowSave,
543
+ onBatchSave: onBatchSave,
434
544
  };
435
545
 
436
- 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
+ );
437
601
  };