@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.
- package/.turbo/turbo-build.log +30 -0
- package/CHANGELOG.md +15 -0
- package/README.md +97 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1117 -303
- package/dist/index.umd.cjs +5 -2
- 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/{plugin-grid → packages/plugin-grid}/src/ObjectGrid.d.ts +7 -1
- package/dist/packages/plugin-grid/src/ObjectGrid.msw.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/VirtualGrid.d.ts +35 -0
- package/dist/packages/plugin-grid/src/VirtualGrid.test.d.ts +8 -0
- package/dist/packages/plugin-grid/src/__tests__/VirtualGrid.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/index.d.ts +10 -0
- package/dist/packages/plugin-grid/src/index.test.d.ts +1 -0
- package/package.json +11 -8
- package/src/ListColumnExtensions.test.tsx +374 -0
- package/src/ListColumnSchema.test.ts +88 -0
- package/src/ObjectGrid.msw.test.tsx +130 -0
- package/src/ObjectGrid.tsx +341 -117
- package/src/VirtualGrid.test.tsx +23 -0
- package/src/VirtualGrid.tsx +183 -0
- package/src/__tests__/VirtualGrid.test.tsx +438 -0
- package/src/index.test.tsx +29 -0
- package/src/index.tsx +33 -5
- package/vite.config.ts +18 -0
- package/vitest.config.ts +13 -0
- package/vitest.setup.ts +1 -0
- package/dist/plugin-grid/src/index.d.ts +0 -3
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 } 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
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
188
|
+
if (hasInlineData) return;
|
|
189
|
+
|
|
190
|
+
let cancelled = false;
|
|
191
|
+
|
|
192
|
+
const loadSchemaAndData = async () => {
|
|
193
|
+
setLoading(true);
|
|
194
|
+
setError(null);
|
|
121
195
|
try {
|
|
122
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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(
|
|
291
|
+
const cols = normalizeColumns(schemaColumns);
|
|
155
292
|
|
|
156
293
|
if (cols) {
|
|
157
|
-
//
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
+
|