@object-ui/plugin-view 0.5.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +7 -6
- package/CHANGELOG.md +38 -0
- package/README.md +58 -0
- package/dist/index.js +1168 -349
- package/dist/index.umd.cjs +2 -2
- package/dist/plugin-view/src/FilterUI.d.ts +16 -0
- package/dist/plugin-view/src/ObjectView.d.ts +85 -5
- package/dist/plugin-view/src/SortUI.d.ts +16 -0
- package/dist/plugin-view/src/ViewSwitcher.d.ts +16 -0
- package/dist/plugin-view/src/index.d.ts +7 -1
- package/package.json +9 -8
- package/src/FilterUI.tsx +317 -0
- package/src/ObjectView.tsx +668 -148
- package/src/SortUI.tsx +210 -0
- package/src/ViewSwitcher.tsx +311 -0
- package/src/__tests__/FilterUI.test.tsx +544 -0
- package/src/__tests__/ObjectView.test.tsx +375 -0
- package/src/__tests__/SortUI.test.tsx +380 -0
- package/src/__tests__/registration.test.tsx +32 -0
- package/src/__tests__/toolbar-consistency.test.tsx +755 -0
- package/src/index.tsx +147 -5
- package/vite.config.ts +1 -0
- package/vitest.config.ts +12 -0
- package/vitest.setup.ts +1 -0
package/src/ObjectView.tsx
CHANGED
|
@@ -8,16 +8,37 @@
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* ObjectView Component
|
|
11
|
-
*
|
|
12
|
-
* A complete object management interface that combines
|
|
13
|
-
*
|
|
11
|
+
*
|
|
12
|
+
* A complete object management interface that combines multi-view data display
|
|
13
|
+
* (grid, kanban, calendar, gallery, timeline, gantt, map) with ObjectForm
|
|
14
|
+
* for create/edit operations.
|
|
15
|
+
*
|
|
16
|
+
* Features:
|
|
17
|
+
* - Multi-view type rendering via SchemaRenderer
|
|
18
|
+
* - Named listViews support (e.g., "All", "My Records", "Active")
|
|
19
|
+
* - Navigation config for row click behavior (page/drawer/modal/none/new_window)
|
|
20
|
+
* - Direct data fetching for all view types
|
|
21
|
+
* - Integrated search, filter, and sort controls
|
|
22
|
+
* - ViewSwitcher for toggling between view types
|
|
14
23
|
*/
|
|
15
24
|
|
|
16
|
-
import React, { useEffect, useState, useCallback } from 'react';
|
|
17
|
-
import type {
|
|
25
|
+
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
|
26
|
+
import type {
|
|
27
|
+
ObjectViewSchema,
|
|
28
|
+
ObjectGridSchema,
|
|
29
|
+
ObjectFormSchema,
|
|
30
|
+
DataSource,
|
|
31
|
+
ViewSwitcherSchema,
|
|
32
|
+
FilterUISchema,
|
|
33
|
+
SortUISchema,
|
|
34
|
+
ViewType,
|
|
35
|
+
NamedListView,
|
|
36
|
+
ViewNavigationConfig,
|
|
37
|
+
} from '@object-ui/types';
|
|
18
38
|
import { ObjectGrid } from '@object-ui/plugin-grid';
|
|
19
39
|
import { ObjectForm } from '@object-ui/plugin-form';
|
|
20
40
|
import {
|
|
41
|
+
cn,
|
|
21
42
|
Dialog,
|
|
22
43
|
DialogContent,
|
|
23
44
|
DialogHeader,
|
|
@@ -29,35 +50,106 @@ import {
|
|
|
29
50
|
DrawerTitle,
|
|
30
51
|
DrawerDescription,
|
|
31
52
|
Button,
|
|
32
|
-
|
|
53
|
+
Tabs,
|
|
54
|
+
TabsList,
|
|
55
|
+
TabsTrigger,
|
|
33
56
|
} from '@object-ui/components';
|
|
34
|
-
import { Plus
|
|
57
|
+
import { Plus } from 'lucide-react';
|
|
58
|
+
import { ViewSwitcher } from './ViewSwitcher';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Attempt to import SchemaRenderer from @object-ui/react.
|
|
62
|
+
* Falls back to null if not available.
|
|
63
|
+
*/
|
|
64
|
+
let SchemaRendererComponent: React.FC<any> | null = null;
|
|
65
|
+
try {
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
67
|
+
const mod = require('@object-ui/react');
|
|
68
|
+
SchemaRendererComponent = mod.SchemaRenderer || null;
|
|
69
|
+
} catch {
|
|
70
|
+
// @object-ui/react not available
|
|
71
|
+
}
|
|
35
72
|
|
|
36
73
|
export interface ObjectViewProps {
|
|
37
74
|
/**
|
|
38
75
|
* The schema configuration for the view
|
|
39
76
|
*/
|
|
40
77
|
schema: ObjectViewSchema;
|
|
41
|
-
|
|
78
|
+
|
|
42
79
|
/**
|
|
43
|
-
* Data source (ObjectQL or ObjectStack adapter)
|
|
80
|
+
* Data source (ObjectQL or ObjectStack adapter).
|
|
81
|
+
* If not provided, falls back to SchemaRendererProvider context.
|
|
44
82
|
*/
|
|
45
83
|
dataSource: DataSource;
|
|
46
|
-
|
|
84
|
+
|
|
47
85
|
/**
|
|
48
86
|
* Additional CSS class
|
|
49
87
|
*/
|
|
50
88
|
className?: string;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Views available for the ViewSwitcher.
|
|
92
|
+
* Each view defines a type (grid, kanban, calendar, etc.) and display columns/config.
|
|
93
|
+
* If not provided, uses schema.listViews or falls back to default grid view.
|
|
94
|
+
*/
|
|
95
|
+
views?: Array<{
|
|
96
|
+
id: string;
|
|
97
|
+
label: string;
|
|
98
|
+
type: ViewType;
|
|
99
|
+
columns?: string[];
|
|
100
|
+
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
|
101
|
+
filter?: any[];
|
|
102
|
+
[key: string]: any;
|
|
103
|
+
}>;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The currently active view ID.
|
|
107
|
+
* Used for controlled ViewSwitcher state.
|
|
108
|
+
*/
|
|
109
|
+
activeViewId?: string;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Callback when the active view changes
|
|
113
|
+
*/
|
|
114
|
+
onViewChange?: (viewId: string) => void;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Callback when a row is clicked (for record detail navigation)
|
|
118
|
+
*/
|
|
119
|
+
onRowClick?: (record: Record<string, unknown>) => void;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Callback when edit is triggered on a record
|
|
123
|
+
*/
|
|
124
|
+
onEdit?: (record: Record<string, unknown>) => void;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Render a custom ListView implementation for multi-view support.
|
|
128
|
+
* When provided, this replaces the default view rendering for the content area.
|
|
129
|
+
*/
|
|
130
|
+
renderListView?: (props: {
|
|
131
|
+
schema: any;
|
|
132
|
+
dataSource: DataSource;
|
|
133
|
+
onEdit?: (record: Record<string, unknown>) => void;
|
|
134
|
+
onRowClick?: (record: Record<string, unknown>) => void;
|
|
135
|
+
className?: string;
|
|
136
|
+
}) => React.ReactNode;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Toolbar addon: extra elements to render in the toolbar (e.g., MetadataToggle)
|
|
140
|
+
*/
|
|
141
|
+
toolbarAddon?: React.ReactNode;
|
|
51
142
|
}
|
|
52
143
|
|
|
53
144
|
type FormMode = 'create' | 'edit' | 'view';
|
|
54
145
|
|
|
55
146
|
/**
|
|
56
147
|
* ObjectView Component
|
|
57
|
-
*
|
|
58
|
-
* Renders a complete object management interface with
|
|
59
|
-
*
|
|
60
|
-
*
|
|
148
|
+
*
|
|
149
|
+
* Renders a complete object management interface with multi-view rendering
|
|
150
|
+
* and integrated CRUD operations.
|
|
151
|
+
*
|
|
152
|
+
* @example Basic usage (grid only)
|
|
61
153
|
* ```tsx
|
|
62
154
|
* <ObjectView
|
|
63
155
|
* schema={{
|
|
@@ -65,7 +157,36 @@ type FormMode = 'create' | 'edit' | 'view';
|
|
|
65
157
|
* objectName: 'users',
|
|
66
158
|
* layout: 'drawer',
|
|
67
159
|
* showSearch: true,
|
|
68
|
-
* showFilters: true
|
|
160
|
+
* showFilters: true,
|
|
161
|
+
* }}
|
|
162
|
+
* dataSource={dataSource}
|
|
163
|
+
* />
|
|
164
|
+
* ```
|
|
165
|
+
*
|
|
166
|
+
* @example Named listViews
|
|
167
|
+
* ```tsx
|
|
168
|
+
* <ObjectView
|
|
169
|
+
* schema={{
|
|
170
|
+
* type: 'object-view',
|
|
171
|
+
* objectName: 'contacts',
|
|
172
|
+
* listViews: {
|
|
173
|
+
* all: { label: 'All Contacts', type: 'grid', columns: ['name', 'email', 'phone'] },
|
|
174
|
+
* board: { label: 'By Status', type: 'kanban', options: { kanban: { groupField: 'status' } } },
|
|
175
|
+
* calendar: { label: 'Meetings', type: 'calendar', options: { calendar: { startDateField: 'meeting_date' } } },
|
|
176
|
+
* },
|
|
177
|
+
* defaultListView: 'all',
|
|
178
|
+
* }}
|
|
179
|
+
* dataSource={dataSource}
|
|
180
|
+
* />
|
|
181
|
+
* ```
|
|
182
|
+
*
|
|
183
|
+
* @example With navigation config
|
|
184
|
+
* ```tsx
|
|
185
|
+
* <ObjectView
|
|
186
|
+
* schema={{
|
|
187
|
+
* type: 'object-view',
|
|
188
|
+
* objectName: 'accounts',
|
|
189
|
+
* navigation: { mode: 'drawer', width: '600px' },
|
|
69
190
|
* }}
|
|
70
191
|
* dataSource={dataSource}
|
|
71
192
|
* />
|
|
@@ -75,34 +196,151 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
|
|
|
75
196
|
schema,
|
|
76
197
|
dataSource,
|
|
77
198
|
className,
|
|
199
|
+
views: viewsProp,
|
|
200
|
+
activeViewId,
|
|
201
|
+
onViewChange,
|
|
202
|
+
onRowClick,
|
|
203
|
+
onEdit: onEditProp,
|
|
204
|
+
renderListView,
|
|
205
|
+
toolbarAddon,
|
|
78
206
|
}) => {
|
|
79
207
|
const [objectSchema, setObjectSchema] = useState<Record<string, unknown> | null>(null);
|
|
80
208
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
81
209
|
const [formMode, setFormMode] = useState<FormMode>('create');
|
|
82
210
|
const [selectedRecord, setSelectedRecord] = useState<Record<string, unknown> | null>(null);
|
|
83
|
-
const [searchQuery, setSearchQuery] = useState('');
|
|
84
|
-
const [showFilters, setShowFilters] = useState(false);
|
|
85
211
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
86
212
|
|
|
213
|
+
// Data fetching state for non-grid views
|
|
214
|
+
const [data, setData] = useState<any[]>([]);
|
|
215
|
+
const [loading, setLoading] = useState(false);
|
|
216
|
+
|
|
217
|
+
// Filter & Sort state
|
|
218
|
+
const [filterValues, setFilterValues] = useState<Record<string, any>>({});
|
|
219
|
+
const [sortConfig, setSortConfig] = useState<Array<{ field: string; direction: 'asc' | 'desc' }>>([]);
|
|
220
|
+
|
|
221
|
+
// --- Named listViews ---
|
|
222
|
+
const namedListViews = schema.listViews;
|
|
223
|
+
const hasNamedViews = namedListViews != null && Object.keys(namedListViews).length > 0;
|
|
224
|
+
const [activeNamedView, setActiveNamedView] = useState<string>(() => {
|
|
225
|
+
if (schema.defaultListView && namedListViews?.[schema.defaultListView]) {
|
|
226
|
+
return schema.defaultListView;
|
|
227
|
+
}
|
|
228
|
+
if (namedListViews) {
|
|
229
|
+
const keys = Object.keys(namedListViews);
|
|
230
|
+
return keys[0] || '';
|
|
231
|
+
}
|
|
232
|
+
return '';
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Get current named view config
|
|
236
|
+
const currentNamedViewConfig: NamedListView | null = useMemo(() => {
|
|
237
|
+
if (!hasNamedViews || !activeNamedView) return null;
|
|
238
|
+
return namedListViews![activeNamedView] || null;
|
|
239
|
+
}, [hasNamedViews, activeNamedView, namedListViews]);
|
|
240
|
+
|
|
241
|
+
// --- Multi-view type state (prop-based views) ---
|
|
242
|
+
const viewsPropResolved = useMemo(() => {
|
|
243
|
+
if (viewsProp && viewsProp.length > 0) return viewsProp;
|
|
244
|
+
return null;
|
|
245
|
+
}, [viewsProp]);
|
|
246
|
+
|
|
247
|
+
const hasMultiView = viewsPropResolved != null && viewsPropResolved.length > 0;
|
|
248
|
+
const currentActiveViewId = activeViewId || viewsPropResolved?.[0]?.id;
|
|
249
|
+
const activeView = viewsPropResolved?.find(v => v.id === currentActiveViewId) || viewsPropResolved?.[0];
|
|
250
|
+
|
|
251
|
+
// Current view type from named view, multi-view prop, or default
|
|
252
|
+
const currentViewType: string = useMemo(() => {
|
|
253
|
+
if (currentNamedViewConfig?.type) return currentNamedViewConfig.type;
|
|
254
|
+
if (activeView?.type) return activeView.type;
|
|
255
|
+
return schema.defaultViewType || 'grid';
|
|
256
|
+
}, [currentNamedViewConfig, activeView, schema.defaultViewType]);
|
|
257
|
+
|
|
258
|
+
// Navigation config
|
|
259
|
+
const navigationConfig: ViewNavigationConfig | undefined = schema.navigation;
|
|
260
|
+
|
|
87
261
|
// Fetch object schema from ObjectQL/ObjectStack
|
|
88
262
|
useEffect(() => {
|
|
263
|
+
let isMounted = true;
|
|
89
264
|
const fetchObjectSchema = async () => {
|
|
90
265
|
try {
|
|
91
266
|
const schemaData = await dataSource.getObjectSchema(schema.objectName);
|
|
92
|
-
setObjectSchema(schemaData);
|
|
267
|
+
if (isMounted) setObjectSchema(schemaData);
|
|
93
268
|
} catch (err) {
|
|
94
269
|
console.error('Failed to fetch object schema:', err);
|
|
95
270
|
}
|
|
96
271
|
};
|
|
97
|
-
|
|
98
272
|
if (schema.objectName && dataSource) {
|
|
99
273
|
fetchObjectSchema();
|
|
100
274
|
}
|
|
275
|
+
return () => { isMounted = false; };
|
|
101
276
|
}, [schema.objectName, dataSource]);
|
|
102
277
|
|
|
278
|
+
// Fetch data for non-grid view types (grid handles its own data via ObjectGrid)
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
let isMounted = true;
|
|
281
|
+
|
|
282
|
+
const fetchData = async () => {
|
|
283
|
+
// Only fetch for non-grid views (ObjectGrid has its own data fetching)
|
|
284
|
+
if (currentViewType === 'grid' && !renderListView) return;
|
|
285
|
+
if (!dataSource || !schema.objectName) return;
|
|
286
|
+
|
|
287
|
+
setLoading(true);
|
|
288
|
+
try {
|
|
289
|
+
// Build filter
|
|
290
|
+
const baseFilter = currentNamedViewConfig?.filter || activeView?.filter || schema.table?.defaultFilters || [];
|
|
291
|
+
const userFilter = Object.entries(filterValues)
|
|
292
|
+
.filter(([, v]) => v !== undefined && v !== '' && v !== null)
|
|
293
|
+
.map(([field, value]) => [field, '=', value]);
|
|
294
|
+
|
|
295
|
+
let finalFilter: any = [];
|
|
296
|
+
if (baseFilter.length > 0 && userFilter.length > 0) {
|
|
297
|
+
finalFilter = ['and', ...baseFilter, ...userFilter];
|
|
298
|
+
} else if (userFilter.length === 1) {
|
|
299
|
+
finalFilter = userFilter[0];
|
|
300
|
+
} else if (userFilter.length > 1) {
|
|
301
|
+
finalFilter = ['and', ...userFilter];
|
|
302
|
+
} else {
|
|
303
|
+
finalFilter = baseFilter;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Build sort
|
|
307
|
+
const sort = sortConfig.length > 0
|
|
308
|
+
? sortConfig.map(s => ({ field: s.field, order: s.direction }))
|
|
309
|
+
: (currentNamedViewConfig?.sort || activeView?.sort || schema.table?.defaultSort || undefined);
|
|
310
|
+
|
|
311
|
+
const results = await dataSource.find(schema.objectName, {
|
|
312
|
+
$filter: finalFilter.length > 0 ? finalFilter : undefined,
|
|
313
|
+
$orderby: sort,
|
|
314
|
+
$top: 100,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
let items: any[] = [];
|
|
318
|
+
if (Array.isArray(results)) {
|
|
319
|
+
items = results;
|
|
320
|
+
} else if (results && typeof results === 'object') {
|
|
321
|
+
if (Array.isArray((results as any).data)) {
|
|
322
|
+
items = (results as any).data;
|
|
323
|
+
} else if (Array.isArray((results as any).records)) {
|
|
324
|
+
items = (results as any).records;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (isMounted) setData(items);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
console.error('ObjectView data fetch error:', err);
|
|
331
|
+
} finally {
|
|
332
|
+
if (isMounted) setLoading(false);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
fetchData();
|
|
337
|
+
return () => { isMounted = false; };
|
|
338
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
339
|
+
}, [schema.objectName, dataSource, currentViewType, filterValues, sortConfig, refreshKey, currentNamedViewConfig, activeView, renderListView]);
|
|
340
|
+
|
|
103
341
|
// Determine layout mode
|
|
104
342
|
const layout = schema.layout || 'drawer';
|
|
105
|
-
|
|
343
|
+
|
|
106
344
|
// Determine enabled operations
|
|
107
345
|
const operations = schema.operations || schema.table?.operations || {
|
|
108
346
|
create: true,
|
|
@@ -124,6 +362,10 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
|
|
|
124
362
|
|
|
125
363
|
// Handle edit action
|
|
126
364
|
const handleEdit = useCallback((record: Record<string, unknown>) => {
|
|
365
|
+
if (onEditProp) {
|
|
366
|
+
onEditProp(record);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
127
369
|
if (layout === 'page' && schema.onNavigate) {
|
|
128
370
|
const recordId = record._id || record.id;
|
|
129
371
|
schema.onNavigate(recordId as string | number, 'edit');
|
|
@@ -132,9 +374,9 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
|
|
|
132
374
|
setSelectedRecord(record);
|
|
133
375
|
setIsFormOpen(true);
|
|
134
376
|
}
|
|
135
|
-
}, [layout, schema]);
|
|
377
|
+
}, [layout, schema, onEditProp]);
|
|
136
378
|
|
|
137
|
-
// Handle view action
|
|
379
|
+
// Handle view action (read a record)
|
|
138
380
|
const handleView = useCallback((record: Record<string, unknown>) => {
|
|
139
381
|
if (layout === 'page' && schema.onNavigate) {
|
|
140
382
|
const recordId = record._id || record.id;
|
|
@@ -146,32 +388,65 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
|
|
|
146
388
|
}
|
|
147
389
|
}, [layout, schema]);
|
|
148
390
|
|
|
149
|
-
// Handle row click
|
|
391
|
+
// Handle row click - respects NavigationConfig
|
|
150
392
|
const handleRowClick = useCallback((record: Record<string, unknown>) => {
|
|
393
|
+
if (onRowClick) {
|
|
394
|
+
onRowClick(record);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Check NavigationConfig
|
|
399
|
+
if (navigationConfig) {
|
|
400
|
+
if (navigationConfig.mode === 'none' || navigationConfig.preventNavigation) {
|
|
401
|
+
return; // Do nothing
|
|
402
|
+
}
|
|
403
|
+
if (navigationConfig.mode === 'new_window' || navigationConfig.openNewTab) {
|
|
404
|
+
const recordId = record._id || record.id;
|
|
405
|
+
const url = `/${schema.objectName}/${recordId}`;
|
|
406
|
+
window.open(url, '_blank');
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (navigationConfig.mode === 'drawer') {
|
|
410
|
+
setFormMode('view');
|
|
411
|
+
setSelectedRecord(record);
|
|
412
|
+
setIsFormOpen(true);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (navigationConfig.mode === 'modal') {
|
|
416
|
+
setFormMode('view');
|
|
417
|
+
setSelectedRecord(record);
|
|
418
|
+
setIsFormOpen(true);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (navigationConfig.mode === 'page') {
|
|
422
|
+
const recordId = record._id || record.id;
|
|
423
|
+
if (schema.onNavigate) {
|
|
424
|
+
schema.onNavigate(recordId as string | number, 'view');
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Default behavior
|
|
151
431
|
if (operations.read !== false) {
|
|
152
432
|
handleView(record);
|
|
153
433
|
}
|
|
154
|
-
}, [operations.read, handleView]);
|
|
434
|
+
}, [onRowClick, navigationConfig, operations.read, handleView, schema]);
|
|
155
435
|
|
|
156
436
|
// Handle delete action
|
|
157
437
|
const handleDelete = useCallback((_record: Record<string, unknown>) => {
|
|
158
|
-
// Trigger table refresh after delete
|
|
159
438
|
setRefreshKey(prev => prev + 1);
|
|
160
439
|
}, []);
|
|
161
440
|
|
|
162
441
|
// Handle bulk delete action
|
|
163
442
|
const handleBulkDelete = useCallback((_records: Record<string, unknown>[]) => {
|
|
164
|
-
// Trigger table refresh after bulk delete
|
|
165
443
|
setRefreshKey(prev => prev + 1);
|
|
166
444
|
}, []);
|
|
167
445
|
|
|
168
446
|
// Handle form submission
|
|
169
447
|
const handleFormSuccess = useCallback(() => {
|
|
170
|
-
// Close the form
|
|
171
448
|
setIsFormOpen(false);
|
|
172
449
|
setSelectedRecord(null);
|
|
173
|
-
|
|
174
|
-
// Trigger table refresh
|
|
175
450
|
setRefreshKey(prev => prev + 1);
|
|
176
451
|
}, []);
|
|
177
452
|
|
|
@@ -186,29 +461,217 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
|
|
|
186
461
|
setRefreshKey(prev => prev + 1);
|
|
187
462
|
}, []);
|
|
188
463
|
|
|
189
|
-
//
|
|
190
|
-
const
|
|
464
|
+
// --- ViewSwitcher schema (for multi-view prop views) ---
|
|
465
|
+
const viewSwitcherSchema: ViewSwitcherSchema | null = useMemo(() => {
|
|
466
|
+
if (!hasMultiView || !viewsPropResolved || viewsPropResolved.length <= 1) return null;
|
|
467
|
+
return {
|
|
468
|
+
type: 'view-switcher' as const,
|
|
469
|
+
variant: 'tabs',
|
|
470
|
+
position: 'top',
|
|
471
|
+
persistPreference: true,
|
|
472
|
+
storageKey: `view-pref-${schema.objectName}`,
|
|
473
|
+
defaultView: (activeView?.type || 'grid') as ViewType,
|
|
474
|
+
activeView: (activeView?.type || 'grid') as ViewType,
|
|
475
|
+
views: viewsPropResolved.map(v => {
|
|
476
|
+
const iconMap: Record<string, string> = {
|
|
477
|
+
kanban: 'kanban',
|
|
478
|
+
calendar: 'calendar',
|
|
479
|
+
map: 'map',
|
|
480
|
+
gallery: 'layout-grid',
|
|
481
|
+
timeline: 'activity',
|
|
482
|
+
gantt: 'gantt-chart',
|
|
483
|
+
grid: 'table',
|
|
484
|
+
list: 'list',
|
|
485
|
+
detail: 'file-text',
|
|
486
|
+
};
|
|
487
|
+
return {
|
|
488
|
+
type: v.type as ViewType,
|
|
489
|
+
label: v.label,
|
|
490
|
+
icon: iconMap[v.type] || 'table',
|
|
491
|
+
};
|
|
492
|
+
}),
|
|
493
|
+
};
|
|
494
|
+
}, [hasMultiView, viewsPropResolved, activeView, schema.objectName]);
|
|
495
|
+
|
|
496
|
+
// Handle view type change from ViewSwitcher → map back to view ID
|
|
497
|
+
const handleViewTypeChange = useCallback((viewType: ViewType) => {
|
|
498
|
+
if (!viewsPropResolved) return;
|
|
499
|
+
const matched = viewsPropResolved.find(v => v.type === viewType);
|
|
500
|
+
if (matched && onViewChange) {
|
|
501
|
+
onViewChange(matched.id);
|
|
502
|
+
}
|
|
503
|
+
}, [viewsPropResolved, onViewChange]);
|
|
504
|
+
|
|
505
|
+
// Handle named view change
|
|
506
|
+
const handleNamedViewChange = useCallback((viewKey: string) => {
|
|
507
|
+
setActiveNamedView(viewKey);
|
|
508
|
+
}, []);
|
|
509
|
+
|
|
510
|
+
// --- FilterUI schema (auto-generated from objectSchema or filterableFields) ---
|
|
511
|
+
const filterSchema: FilterUISchema | null = useMemo(() => {
|
|
512
|
+
if (schema.showFilters === false) return null;
|
|
513
|
+
|
|
514
|
+
// If filterableFields specified, use only those
|
|
515
|
+
const filterableFieldNames = schema.filterableFields;
|
|
516
|
+
const fields = (objectSchema as any)?.fields || {};
|
|
517
|
+
|
|
518
|
+
const fieldEntries = filterableFieldNames
|
|
519
|
+
? filterableFieldNames.map(name => [name, fields[name] || { label: name }] as [string, any])
|
|
520
|
+
: Object.entries(fields).filter(([, f]: [string, any]) => !f.hidden).slice(0, 8);
|
|
521
|
+
|
|
522
|
+
const filterableFieldDefs = fieldEntries.map(([key, f]: [string, any]) => {
|
|
523
|
+
const fieldType = f.type || 'text';
|
|
524
|
+
let filterType: 'text' | 'number' | 'select' | 'date' | 'boolean' = 'text';
|
|
525
|
+
let options: Array<{ label: string; value: any }> | undefined;
|
|
526
|
+
|
|
527
|
+
if (fieldType === 'number' || fieldType === 'currency' || fieldType === 'percent') {
|
|
528
|
+
filterType = 'number';
|
|
529
|
+
} else if (fieldType === 'boolean' || fieldType === 'toggle') {
|
|
530
|
+
filterType = 'boolean';
|
|
531
|
+
} else if (fieldType === 'date' || fieldType === 'datetime') {
|
|
532
|
+
filterType = 'date';
|
|
533
|
+
} else if (fieldType === 'select' || f.options) {
|
|
534
|
+
filterType = 'select';
|
|
535
|
+
options = (f.options || []).map((o: any) =>
|
|
536
|
+
typeof o === 'string' ? { label: o, value: o } : { label: o.label, value: o.value },
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
return {
|
|
540
|
+
field: key,
|
|
541
|
+
label: f.label || key,
|
|
542
|
+
type: filterType,
|
|
543
|
+
placeholder: `Filter ${f.label || key}...`,
|
|
544
|
+
...(options ? { options } : {}),
|
|
545
|
+
};
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
if (filterableFieldDefs.length === 0) return null;
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
type: 'filter-ui' as const,
|
|
552
|
+
layout: 'popover' as const,
|
|
553
|
+
showClear: true,
|
|
554
|
+
showApply: true,
|
|
555
|
+
filters: filterableFieldDefs,
|
|
556
|
+
values: filterValues,
|
|
557
|
+
};
|
|
558
|
+
}, [schema.showFilters, schema.filterableFields, objectSchema, filterValues]);
|
|
559
|
+
|
|
560
|
+
// --- SortUI schema ---
|
|
561
|
+
const sortSchema: SortUISchema | null = useMemo(() => {
|
|
562
|
+
const fields = (objectSchema as any)?.fields || {};
|
|
563
|
+
const sortableFields = Object.entries(fields)
|
|
564
|
+
.filter(([, f]: [string, any]) => !f.hidden)
|
|
565
|
+
.slice(0, 10)
|
|
566
|
+
.map(([key, f]: [string, any]) => ({ field: key, label: f.label || key }));
|
|
567
|
+
|
|
568
|
+
if (sortableFields.length === 0) return null;
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
type: 'sort-ui' as const,
|
|
572
|
+
variant: 'dropdown' as const,
|
|
573
|
+
multiple: false,
|
|
574
|
+
fields: sortableFields,
|
|
575
|
+
sort: sortConfig,
|
|
576
|
+
};
|
|
577
|
+
}, [objectSchema, sortConfig]);
|
|
578
|
+
|
|
579
|
+
// --- Generate view component schema for non-grid views ---
|
|
580
|
+
const generateViewSchema = useCallback((viewType: string): any => {
|
|
581
|
+
const baseProps: Record<string, any> = {
|
|
582
|
+
objectName: schema.objectName,
|
|
583
|
+
fields: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.fields,
|
|
584
|
+
className: 'h-full w-full',
|
|
585
|
+
showSearch: false,
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
// Resolve type-specific options from current named view or active view
|
|
589
|
+
const viewOptions = currentNamedViewConfig?.options || activeView || {};
|
|
590
|
+
|
|
591
|
+
switch (viewType) {
|
|
592
|
+
case 'kanban':
|
|
593
|
+
return {
|
|
594
|
+
type: 'object-kanban',
|
|
595
|
+
...baseProps,
|
|
596
|
+
groupBy: viewOptions.kanban?.groupField || viewOptions.groupBy || viewOptions.groupField || 'status',
|
|
597
|
+
groupField: viewOptions.kanban?.groupField || viewOptions.groupField || 'status',
|
|
598
|
+
titleField: viewOptions.kanban?.titleField || viewOptions.titleField || 'name',
|
|
599
|
+
cardFields: baseProps.fields || [],
|
|
600
|
+
...(viewOptions.kanban || {}),
|
|
601
|
+
};
|
|
602
|
+
case 'calendar':
|
|
603
|
+
return {
|
|
604
|
+
type: 'object-calendar',
|
|
605
|
+
...baseProps,
|
|
606
|
+
startDateField: viewOptions.calendar?.startDateField || viewOptions.startDateField || 'start_date',
|
|
607
|
+
endDateField: viewOptions.calendar?.endDateField || viewOptions.endDateField || 'end_date',
|
|
608
|
+
titleField: viewOptions.calendar?.titleField || viewOptions.titleField || 'name',
|
|
609
|
+
...(viewOptions.calendar || {}),
|
|
610
|
+
};
|
|
611
|
+
case 'gallery':
|
|
612
|
+
return {
|
|
613
|
+
type: 'object-gallery',
|
|
614
|
+
...baseProps,
|
|
615
|
+
imageField: viewOptions.gallery?.imageField || viewOptions.imageField,
|
|
616
|
+
titleField: viewOptions.gallery?.titleField || viewOptions.titleField || 'name',
|
|
617
|
+
subtitleField: viewOptions.gallery?.subtitleField || viewOptions.subtitleField,
|
|
618
|
+
...(viewOptions.gallery || {}),
|
|
619
|
+
};
|
|
620
|
+
case 'timeline':
|
|
621
|
+
return {
|
|
622
|
+
type: 'object-timeline',
|
|
623
|
+
...baseProps,
|
|
624
|
+
dateField: viewOptions.timeline?.dateField || viewOptions.dateField || 'created_at',
|
|
625
|
+
titleField: viewOptions.timeline?.titleField || viewOptions.titleField || 'name',
|
|
626
|
+
...(viewOptions.timeline || {}),
|
|
627
|
+
};
|
|
628
|
+
case 'gantt':
|
|
629
|
+
return {
|
|
630
|
+
type: 'object-gantt',
|
|
631
|
+
...baseProps,
|
|
632
|
+
startDateField: viewOptions.gantt?.startDateField || viewOptions.startDateField || 'start_date',
|
|
633
|
+
endDateField: viewOptions.gantt?.endDateField || viewOptions.endDateField || 'end_date',
|
|
634
|
+
progressField: viewOptions.gantt?.progressField || viewOptions.progressField || 'progress',
|
|
635
|
+
dependenciesField: viewOptions.gantt?.dependenciesField || viewOptions.dependenciesField || 'dependencies',
|
|
636
|
+
...(viewOptions.gantt || {}),
|
|
637
|
+
};
|
|
638
|
+
case 'map':
|
|
639
|
+
return {
|
|
640
|
+
type: 'object-map',
|
|
641
|
+
...baseProps,
|
|
642
|
+
locationField: viewOptions.map?.locationField || viewOptions.locationField || 'location',
|
|
643
|
+
...(viewOptions.map || {}),
|
|
644
|
+
};
|
|
645
|
+
default:
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
}, [schema.objectName, schema.table?.fields, currentNamedViewConfig, activeView]);
|
|
649
|
+
|
|
650
|
+
// Build grid schema (default content renderer)
|
|
651
|
+
const gridSchema: ObjectGridSchema = useMemo(() => ({
|
|
191
652
|
type: 'object-grid',
|
|
192
653
|
objectName: schema.objectName,
|
|
193
654
|
title: schema.table?.title,
|
|
194
655
|
description: schema.table?.description,
|
|
195
|
-
fields: schema.table?.fields,
|
|
196
|
-
columns: schema.table?.columns,
|
|
656
|
+
fields: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.fields,
|
|
657
|
+
columns: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.columns,
|
|
197
658
|
operations: {
|
|
198
659
|
...operations,
|
|
199
660
|
create: false, // Create is handled by the view's create button
|
|
200
661
|
},
|
|
201
|
-
defaultFilters: schema.table?.defaultFilters,
|
|
202
|
-
defaultSort: schema.table?.defaultSort,
|
|
662
|
+
defaultFilters: currentNamedViewConfig?.filter || activeView?.filter || schema.table?.defaultFilters,
|
|
663
|
+
defaultSort: currentNamedViewConfig?.sort || activeView?.sort || schema.table?.defaultSort,
|
|
203
664
|
pageSize: schema.table?.pageSize,
|
|
204
665
|
selectable: schema.table?.selectable,
|
|
205
666
|
className: schema.table?.className,
|
|
206
|
-
};
|
|
667
|
+
}), [schema, operations, currentNamedViewConfig, activeView]);
|
|
207
668
|
|
|
208
669
|
// Build form schema
|
|
209
670
|
const buildFormSchema = (): ObjectFormSchema => {
|
|
210
|
-
const recordId = selectedRecord
|
|
211
|
-
|
|
671
|
+
const recordId = selectedRecord
|
|
672
|
+
? ((selectedRecord._id || selectedRecord.id) as string | number | undefined)
|
|
673
|
+
: undefined;
|
|
674
|
+
|
|
212
675
|
return {
|
|
213
676
|
type: 'object-form',
|
|
214
677
|
objectName: schema.objectName,
|
|
@@ -237,25 +700,27 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
|
|
|
237
700
|
// Get form title based on mode
|
|
238
701
|
const getFormTitle = (): string => {
|
|
239
702
|
if (schema.form?.title) return schema.form.title;
|
|
240
|
-
|
|
241
703
|
const objectLabel = (objectSchema?.label as string) || schema.objectName;
|
|
242
|
-
|
|
243
704
|
switch (formMode) {
|
|
244
|
-
case 'create':
|
|
245
|
-
|
|
246
|
-
case '
|
|
247
|
-
|
|
248
|
-
case 'view':
|
|
249
|
-
return `View ${objectLabel}`;
|
|
250
|
-
default:
|
|
251
|
-
return objectLabel;
|
|
705
|
+
case 'create': return `Create ${objectLabel}`;
|
|
706
|
+
case 'edit': return `Edit ${objectLabel}`;
|
|
707
|
+
case 'view': return `View ${objectLabel}`;
|
|
708
|
+
default: return objectLabel;
|
|
252
709
|
}
|
|
253
710
|
};
|
|
254
711
|
|
|
712
|
+
// Determine form container width from navigation config
|
|
713
|
+
const formWidthClass = useMemo(() => {
|
|
714
|
+
const w = navigationConfig?.width;
|
|
715
|
+
if (!w) return '';
|
|
716
|
+
if (typeof w === 'number') return `max-w-[${w}px]`;
|
|
717
|
+
return `max-w-[${w}]`;
|
|
718
|
+
}, [navigationConfig]);
|
|
719
|
+
|
|
255
720
|
// Render the form in a drawer
|
|
256
721
|
const renderDrawerForm = () => (
|
|
257
722
|
<Drawer open={isFormOpen} onOpenChange={setIsFormOpen} direction="right">
|
|
258
|
-
<DrawerContent className=
|
|
723
|
+
<DrawerContent className={cn('w-full sm:max-w-2xl', formWidthClass)}>
|
|
259
724
|
<DrawerHeader>
|
|
260
725
|
<DrawerTitle>{getFormTitle()}</DrawerTitle>
|
|
261
726
|
{schema.form?.description && (
|
|
@@ -263,10 +728,7 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
|
|
|
263
728
|
)}
|
|
264
729
|
</DrawerHeader>
|
|
265
730
|
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
|
266
|
-
<ObjectForm
|
|
267
|
-
schema={buildFormSchema()}
|
|
268
|
-
dataSource={dataSource}
|
|
269
|
-
/>
|
|
731
|
+
<ObjectForm schema={buildFormSchema()} dataSource={dataSource} />
|
|
270
732
|
</div>
|
|
271
733
|
</DrawerContent>
|
|
272
734
|
</Drawer>
|
|
@@ -275,109 +737,171 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
|
|
|
275
737
|
// Render the form in a modal
|
|
276
738
|
const renderModalForm = () => (
|
|
277
739
|
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
|
|
278
|
-
<DialogContent className=
|
|
740
|
+
<DialogContent className={cn('max-w-2xl max-h-[90vh] overflow-y-auto', formWidthClass)}>
|
|
279
741
|
<DialogHeader>
|
|
280
742
|
<DialogTitle>{getFormTitle()}</DialogTitle>
|
|
281
743
|
{schema.form?.description && (
|
|
282
744
|
<DialogDescription>{schema.form.description}</DialogDescription>
|
|
283
745
|
)}
|
|
284
746
|
</DialogHeader>
|
|
285
|
-
<ObjectForm
|
|
286
|
-
schema={buildFormSchema()}
|
|
287
|
-
dataSource={dataSource}
|
|
288
|
-
/>
|
|
747
|
+
<ObjectForm schema={buildFormSchema()} dataSource={dataSource} />
|
|
289
748
|
</DialogContent>
|
|
290
749
|
</Dialog>
|
|
291
750
|
);
|
|
292
751
|
|
|
293
|
-
//
|
|
752
|
+
// Compute merged filters for the list
|
|
753
|
+
const mergedFilters = useMemo(() => {
|
|
754
|
+
const hasUserFilters = Object.keys(filterValues).some(
|
|
755
|
+
k => filterValues[k] !== undefined && filterValues[k] !== '' && filterValues[k] !== null,
|
|
756
|
+
);
|
|
757
|
+
if (hasUserFilters) {
|
|
758
|
+
return Object.entries(filterValues)
|
|
759
|
+
.filter(([, v]) => v !== undefined && v !== '' && v !== null)
|
|
760
|
+
.map(([field, value]) => ({ field, operator: 'equals' as const, value }));
|
|
761
|
+
}
|
|
762
|
+
return currentNamedViewConfig?.filter || activeView?.filter || schema.table?.defaultFilters;
|
|
763
|
+
}, [filterValues, currentNamedViewConfig, activeView, schema.table?.defaultFilters]);
|
|
764
|
+
|
|
765
|
+
const mergedSort = useMemo(() => {
|
|
766
|
+
return sortConfig.length > 0
|
|
767
|
+
? sortConfig
|
|
768
|
+
: currentNamedViewConfig?.sort || activeView?.sort || schema.table?.defaultSort;
|
|
769
|
+
}, [sortConfig, currentNamedViewConfig, activeView, schema.table?.defaultSort]);
|
|
770
|
+
|
|
771
|
+
// --- Content renderer ---
|
|
772
|
+
const renderContent = () => {
|
|
773
|
+
const key = `${schema.objectName}-${activeNamedView || activeView?.id || 'default'}-${currentViewType}-${refreshKey}`;
|
|
774
|
+
|
|
775
|
+
// If a custom renderListView is provided, use it
|
|
776
|
+
if (renderListView) {
|
|
777
|
+
return renderListView({
|
|
778
|
+
schema: {
|
|
779
|
+
type: 'list-view',
|
|
780
|
+
objectName: schema.objectName,
|
|
781
|
+
viewType: currentViewType as any,
|
|
782
|
+
fields: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.fields,
|
|
783
|
+
filters: mergedFilters,
|
|
784
|
+
sort: mergedSort,
|
|
785
|
+
options: currentNamedViewConfig?.options || activeView,
|
|
786
|
+
},
|
|
787
|
+
dataSource,
|
|
788
|
+
onEdit: handleEdit,
|
|
789
|
+
onRowClick: handleRowClick,
|
|
790
|
+
className: 'h-full',
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// For non-grid views, use SchemaRenderer with generated schema
|
|
795
|
+
if (currentViewType !== 'grid') {
|
|
796
|
+
const viewSchema = generateViewSchema(currentViewType);
|
|
797
|
+
if (viewSchema && SchemaRendererComponent) {
|
|
798
|
+
return (
|
|
799
|
+
<SchemaRendererComponent
|
|
800
|
+
key={key}
|
|
801
|
+
schema={viewSchema}
|
|
802
|
+
dataSource={dataSource}
|
|
803
|
+
data={data}
|
|
804
|
+
loading={loading}
|
|
805
|
+
/>
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
// Fallback: if SchemaRenderer is not available or schema not generated
|
|
809
|
+
if (!SchemaRendererComponent) {
|
|
810
|
+
return (
|
|
811
|
+
<div className="flex items-center justify-center h-40 text-muted-foreground">
|
|
812
|
+
<p>SchemaRenderer not available. Install @object-ui/react to render {currentViewType} views.</p>
|
|
813
|
+
</div>
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Default: use ObjectGrid
|
|
819
|
+
return (
|
|
820
|
+
<ObjectGrid
|
|
821
|
+
key={key}
|
|
822
|
+
schema={gridSchema}
|
|
823
|
+
dataSource={dataSource}
|
|
824
|
+
onRowClick={handleRowClick}
|
|
825
|
+
onEdit={operations.update !== false ? handleEdit : undefined}
|
|
826
|
+
onDelete={operations.delete !== false ? handleDelete : undefined}
|
|
827
|
+
onBulkDelete={operations.delete !== false ? handleBulkDelete : undefined}
|
|
828
|
+
/>
|
|
829
|
+
);
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
// --- Named list views tabs ---
|
|
833
|
+
const renderNamedViewTabs = () => {
|
|
834
|
+
if (!hasNamedViews) return null;
|
|
835
|
+
const entries = Object.entries(namedListViews!);
|
|
836
|
+
if (entries.length <= 1) return null;
|
|
837
|
+
|
|
838
|
+
return (
|
|
839
|
+
<Tabs value={activeNamedView} onValueChange={handleNamedViewChange} className="w-full">
|
|
840
|
+
<TabsList className="w-auto">
|
|
841
|
+
{entries.map(([key, view]) => (
|
|
842
|
+
<TabsTrigger key={key} value={key} className="text-sm">
|
|
843
|
+
{view.label || key}
|
|
844
|
+
</TabsTrigger>
|
|
845
|
+
))}
|
|
846
|
+
</TabsList>
|
|
847
|
+
</Tabs>
|
|
848
|
+
);
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
// Render toolbar — only named view tabs; filter/sort/search is handled by ListView
|
|
294
852
|
const renderToolbar = () => {
|
|
295
|
-
const showSearchBox = schema.showSearch !== false;
|
|
296
|
-
const showFiltersButton = schema.showFilters !== false;
|
|
297
853
|
const showCreateButton = schema.showCreate !== false && operations.create !== false;
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
854
|
+
const showViewSwitcherToggle = schema.showViewSwitcher === true; // Changed: default to false (hidden)
|
|
855
|
+
|
|
856
|
+
const namedViewTabs = renderNamedViewTabs();
|
|
857
|
+
|
|
858
|
+
// Hide toolbar entirely if there is nothing to show
|
|
859
|
+
if (!namedViewTabs && !showViewSwitcherToggle && !showCreateButton && !toolbarAddon) return null;
|
|
304
860
|
|
|
305
861
|
return (
|
|
306
|
-
<div className="flex flex-col gap-
|
|
307
|
-
{/*
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
className="pl-9"
|
|
862
|
+
<div className="flex flex-col gap-3">
|
|
863
|
+
{/* Named view tabs (if any) */}
|
|
864
|
+
{namedViewTabs}
|
|
865
|
+
|
|
866
|
+
{/* ViewSwitcher + action buttons row */}
|
|
867
|
+
{(showViewSwitcherToggle || showCreateButton || toolbarAddon) && (
|
|
868
|
+
<div className="flex items-center justify-between gap-4">
|
|
869
|
+
<div className="flex items-center gap-2">
|
|
870
|
+
{showViewSwitcherToggle && viewSwitcherSchema && (
|
|
871
|
+
<ViewSwitcher
|
|
872
|
+
schema={viewSwitcherSchema}
|
|
873
|
+
onViewChange={handleViewTypeChange}
|
|
874
|
+
className="overflow-x-auto"
|
|
320
875
|
/>
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
Filters
|
|
335
|
-
{showFilters && (
|
|
336
|
-
<X className="h-3 w-3 ml-1" />
|
|
337
|
-
)}
|
|
338
|
-
</Button>
|
|
339
|
-
)}
|
|
340
|
-
|
|
341
|
-
{showRefreshButton && (
|
|
342
|
-
<Button
|
|
343
|
-
variant="outline"
|
|
344
|
-
size="sm"
|
|
345
|
-
onClick={handleRefresh}
|
|
346
|
-
>
|
|
347
|
-
<RefreshCw className="h-4 w-4" />
|
|
348
|
-
</Button>
|
|
349
|
-
)}
|
|
350
|
-
|
|
351
|
-
{showCreateButton && (
|
|
352
|
-
<Button
|
|
353
|
-
size="sm"
|
|
354
|
-
onClick={handleCreate}
|
|
355
|
-
>
|
|
356
|
-
<Plus className="h-4 w-4" />
|
|
357
|
-
Create
|
|
358
|
-
</Button>
|
|
359
|
-
)}
|
|
360
|
-
</div>
|
|
361
|
-
</div>
|
|
362
|
-
|
|
363
|
-
{/* Filter panel (shown when filters are active) */}
|
|
364
|
-
{showFilters && (
|
|
365
|
-
<div className="p-4 border rounded-md bg-muted/50">
|
|
366
|
-
<p className="text-sm text-muted-foreground">
|
|
367
|
-
Filter functionality will be integrated with FilterBuilder component
|
|
368
|
-
</p>
|
|
369
|
-
{/* TODO: Integrate FilterBuilder component here */}
|
|
876
|
+
)}
|
|
877
|
+
</div>
|
|
878
|
+
|
|
879
|
+
{/* Right side: Actions */}
|
|
880
|
+
<div className="flex items-center gap-2">
|
|
881
|
+
{toolbarAddon}
|
|
882
|
+
{showCreateButton && (
|
|
883
|
+
<Button size="sm" onClick={handleCreate}>
|
|
884
|
+
<Plus className="h-4 w-4" />
|
|
885
|
+
Create
|
|
886
|
+
</Button>
|
|
887
|
+
)}
|
|
888
|
+
</div>
|
|
370
889
|
</div>
|
|
371
890
|
)}
|
|
372
891
|
</div>
|
|
373
892
|
);
|
|
374
893
|
};
|
|
375
894
|
|
|
895
|
+
// Determine which form container to render
|
|
896
|
+
const formLayout = navigationConfig?.mode === 'modal' ? 'modal'
|
|
897
|
+
: navigationConfig?.mode === 'drawer' ? 'drawer'
|
|
898
|
+
: layout;
|
|
899
|
+
|
|
376
900
|
return (
|
|
377
|
-
<div className={className}>
|
|
901
|
+
<div className={cn('flex flex-col h-full', className)}>
|
|
378
902
|
{/* Title and description */}
|
|
379
903
|
{(schema.title || schema.description) && (
|
|
380
|
-
<div className="mb-
|
|
904
|
+
<div className="mb-4 shrink-0">
|
|
381
905
|
{schema.title && (
|
|
382
906
|
<h2 className="text-2xl font-bold tracking-tight">{schema.title}</h2>
|
|
383
907
|
)}
|
|
@@ -386,24 +910,20 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
|
|
|
386
910
|
)}
|
|
387
911
|
</div>
|
|
388
912
|
)}
|
|
389
|
-
|
|
913
|
+
|
|
390
914
|
{/* Toolbar */}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
onDelete={operations.delete !== false ? handleDelete : undefined}
|
|
401
|
-
onBulkDelete={operations.delete !== false ? handleBulkDelete : undefined}
|
|
402
|
-
/>
|
|
403
|
-
|
|
915
|
+
<div className="mb-4 shrink-0">
|
|
916
|
+
{renderToolbar()}
|
|
917
|
+
</div>
|
|
918
|
+
|
|
919
|
+
{/* Content */}
|
|
920
|
+
<div className="flex-1 min-h-0">
|
|
921
|
+
{renderContent()}
|
|
922
|
+
</div>
|
|
923
|
+
|
|
404
924
|
{/* Form (drawer or modal) */}
|
|
405
|
-
{
|
|
406
|
-
{
|
|
925
|
+
{formLayout === 'drawer' && renderDrawerForm()}
|
|
926
|
+
{formLayout === 'modal' && renderModalForm()}
|
|
407
927
|
</div>
|
|
408
928
|
);
|
|
409
929
|
};
|