@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.
- package/.turbo/turbo-build.log +14 -5
- package/CHANGELOG.md +15 -0
- package/README.md +97 -0
- package/dist/index.js +622 -531
- package/dist/index.umd.cjs +3 -3
- 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.d.ts +7 -1
- package/dist/packages/plugin-grid/src/__tests__/VirtualGrid.test.d.ts +0 -0
- package/package.json +8 -8
- package/src/ListColumnExtensions.test.tsx +374 -0
- package/src/ListColumnSchema.test.ts +88 -0
- package/src/ObjectGrid.msw.test.tsx +24 -1
- package/src/ObjectGrid.tsx +273 -109
- package/src/__tests__/VirtualGrid.test.tsx +438 -0
- package/src/index.tsx +12 -2
|
@@ -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
|
}),
|
package/src/ObjectGrid.tsx
CHANGED
|
@@ -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
|
-
|
|
188
|
+
if (hasInlineData) return;
|
|
189
|
+
|
|
190
|
+
let cancelled = false;
|
|
191
|
+
|
|
192
|
+
const loadSchemaAndData = async () => {
|
|
193
|
+
setLoading(true);
|
|
194
|
+
setError(null);
|
|
165
195
|
try {
|
|
166
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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(
|
|
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'
|
|
215
|
-
.map((col) =>
|
|
216
|
-
header
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
|
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:
|
|
540
|
+
onRowClick: navigation.handleClick,
|
|
541
|
+
onCellChange: onCellChange,
|
|
542
|
+
onRowSave: onRowSave,
|
|
543
|
+
onBatchSave: onBatchSave,
|
|
434
544
|
};
|
|
435
545
|
|
|
436
|
-
|
|
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
|
};
|