@object-ui/plugin-view 3.3.0 → 3.3.2
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/CHANGELOG.md +23 -0
- package/README.md +21 -1
- package/dist/index.js +2679 -2936
- package/dist/index.umd.cjs +1 -5
- package/dist/packages/plugin-view/src/config/view-config-schema.d.ts +20 -0
- package/dist/packages/plugin-view/src/config/view-config-utils.d.ts +58 -0
- package/dist/packages/plugin-view/src/index.d.ts +4 -0
- package/package.json +41 -9
- package/.turbo/turbo-build.log +0 -39
- package/src/FilterUI.tsx +0 -350
- package/src/ObjectView.tsx +0 -1133
- package/src/SharedViewLink.tsx +0 -199
- package/src/SortUI.tsx +0 -210
- package/src/ViewSwitcher.tsx +0 -379
- package/src/ViewTabBar.tsx +0 -656
- package/src/__tests__/FilterUI.test.tsx +0 -641
- package/src/__tests__/ObjectView.test.tsx +0 -705
- package/src/__tests__/SharedViewLinkPassword.test.tsx +0 -172
- package/src/__tests__/SortUI.test.tsx +0 -380
- package/src/__tests__/ViewTabBar.test.tsx +0 -710
- package/src/__tests__/config-sync-integration.test.tsx +0 -588
- package/src/__tests__/toolbar-consistency.test.tsx +0 -755
- package/src/index.tsx +0 -197
- package/tsconfig.json +0 -8
- package/vite.config.ts +0 -48
- package/vitest.config.ts +0 -12
- package/vitest.setup.ts +0 -1
package/src/ObjectView.tsx
DELETED
|
@@ -1,1133 +0,0 @@
|
|
|
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
|
-
/**
|
|
10
|
-
* ObjectView Component
|
|
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
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import React, { useEffect, useState, useCallback, useMemo, useRef } 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';
|
|
38
|
-
import { ObjectGrid } from '@object-ui/plugin-grid';
|
|
39
|
-
import { ObjectForm } from '@object-ui/plugin-form';
|
|
40
|
-
import {
|
|
41
|
-
cn,
|
|
42
|
-
Dialog,
|
|
43
|
-
DialogContent,
|
|
44
|
-
DialogHeader,
|
|
45
|
-
DialogTitle,
|
|
46
|
-
DialogDescription,
|
|
47
|
-
Drawer,
|
|
48
|
-
DrawerContent,
|
|
49
|
-
DrawerHeader,
|
|
50
|
-
DrawerTitle,
|
|
51
|
-
DrawerDescription,
|
|
52
|
-
NavigationOverlay,
|
|
53
|
-
Button,
|
|
54
|
-
Tabs,
|
|
55
|
-
TabsList,
|
|
56
|
-
TabsTrigger,
|
|
57
|
-
} from '@object-ui/components';
|
|
58
|
-
import { Plus } from 'lucide-react';
|
|
59
|
-
import { buildExpandFields } from '@object-ui/core';
|
|
60
|
-
import { SchemaRenderer as ImportedSchemaRenderer } from '@object-ui/react';
|
|
61
|
-
import { ViewSwitcher } from './ViewSwitcher';
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* SchemaRenderer from @object-ui/react, used to render sub-view schemas.
|
|
65
|
-
*/
|
|
66
|
-
const SchemaRendererComponent: React.FC<any> = ImportedSchemaRenderer;
|
|
67
|
-
|
|
68
|
-
export interface ObjectViewProps {
|
|
69
|
-
/**
|
|
70
|
-
* The schema configuration for the view
|
|
71
|
-
*/
|
|
72
|
-
schema: ObjectViewSchema;
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Data source (ObjectQL or ObjectStack adapter).
|
|
76
|
-
* If not provided, falls back to SchemaRendererProvider context.
|
|
77
|
-
*/
|
|
78
|
-
dataSource: DataSource;
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Additional CSS class
|
|
82
|
-
*/
|
|
83
|
-
className?: string;
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Views available for the ViewSwitcher.
|
|
87
|
-
* Each view defines a type (grid, kanban, calendar, etc.) and display columns/config.
|
|
88
|
-
* If not provided, uses schema.listViews or falls back to default grid view.
|
|
89
|
-
*/
|
|
90
|
-
views?: Array<{
|
|
91
|
-
id: string;
|
|
92
|
-
label: string;
|
|
93
|
-
type: ViewType;
|
|
94
|
-
columns?: string[];
|
|
95
|
-
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
|
96
|
-
filter?: any[];
|
|
97
|
-
[key: string]: any;
|
|
98
|
-
}>;
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* The currently active view ID.
|
|
102
|
-
* Used for controlled ViewSwitcher state.
|
|
103
|
-
*/
|
|
104
|
-
activeViewId?: string;
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Callback when the active view changes
|
|
108
|
-
*/
|
|
109
|
-
onViewChange?: (viewId: string) => void;
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Callback when a row is clicked (for record detail navigation)
|
|
113
|
-
*/
|
|
114
|
-
onRowClick?: (record: Record<string, unknown>) => void;
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Callback when edit is triggered on a record
|
|
118
|
-
*/
|
|
119
|
-
onEdit?: (record: Record<string, unknown>) => void;
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Render a custom ListView implementation for multi-view support.
|
|
123
|
-
* When provided, this replaces the default view rendering for the content area.
|
|
124
|
-
*/
|
|
125
|
-
renderListView?: (props: {
|
|
126
|
-
schema: any;
|
|
127
|
-
dataSource: DataSource;
|
|
128
|
-
onEdit?: (record: Record<string, unknown>) => void;
|
|
129
|
-
onRowClick?: (record: Record<string, unknown>) => void;
|
|
130
|
-
className?: string;
|
|
131
|
-
/** Current refresh counter — increment signals that a mutation occurred */
|
|
132
|
-
refreshKey?: number;
|
|
133
|
-
}) => React.ReactNode;
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Toolbar addon: extra elements to render in the toolbar (e.g., MetadataToggle)
|
|
137
|
-
*/
|
|
138
|
-
toolbarAddon?: React.ReactNode;
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Callback when the "+" create view button is clicked in ViewSwitcher.
|
|
142
|
-
*/
|
|
143
|
-
onCreateView?: () => void;
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Callback when a per-view action is triggered in ViewSwitcher.
|
|
147
|
-
*/
|
|
148
|
-
onViewAction?: (action: string, viewType: ViewType) => void;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
type FormMode = 'create' | 'edit' | 'view';
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* ObjectView Component
|
|
155
|
-
*
|
|
156
|
-
* Renders a complete object management interface with multi-view rendering
|
|
157
|
-
* and integrated CRUD operations.
|
|
158
|
-
*
|
|
159
|
-
* @example Basic usage (grid only)
|
|
160
|
-
* ```tsx
|
|
161
|
-
* <ObjectView
|
|
162
|
-
* schema={{
|
|
163
|
-
* type: 'object-view',
|
|
164
|
-
* objectName: 'users',
|
|
165
|
-
* layout: 'drawer',
|
|
166
|
-
* showSearch: true,
|
|
167
|
-
* showFilters: true,
|
|
168
|
-
* }}
|
|
169
|
-
* dataSource={dataSource}
|
|
170
|
-
* />
|
|
171
|
-
* ```
|
|
172
|
-
*
|
|
173
|
-
* @example Named listViews
|
|
174
|
-
* ```tsx
|
|
175
|
-
* <ObjectView
|
|
176
|
-
* schema={{
|
|
177
|
-
* type: 'object-view',
|
|
178
|
-
* objectName: 'contacts',
|
|
179
|
-
* listViews: {
|
|
180
|
-
* all: { label: 'All Contacts', type: 'grid', columns: ['name', 'email', 'phone'] },
|
|
181
|
-
* board: { label: 'By Status', type: 'kanban', options: { kanban: { groupField: 'status' } } },
|
|
182
|
-
* calendar: { label: 'Meetings', type: 'calendar', options: { calendar: { startDateField: 'meeting_date' } } },
|
|
183
|
-
* },
|
|
184
|
-
* defaultListView: 'all',
|
|
185
|
-
* }}
|
|
186
|
-
* dataSource={dataSource}
|
|
187
|
-
* />
|
|
188
|
-
* ```
|
|
189
|
-
*
|
|
190
|
-
* @example With navigation config
|
|
191
|
-
* ```tsx
|
|
192
|
-
* <ObjectView
|
|
193
|
-
* schema={{
|
|
194
|
-
* type: 'object-view',
|
|
195
|
-
* objectName: 'accounts',
|
|
196
|
-
* navigation: { mode: 'drawer', width: '600px' },
|
|
197
|
-
* }}
|
|
198
|
-
* dataSource={dataSource}
|
|
199
|
-
* />
|
|
200
|
-
* ```
|
|
201
|
-
*/
|
|
202
|
-
export const ObjectView: React.FC<ObjectViewProps> = ({
|
|
203
|
-
schema,
|
|
204
|
-
dataSource,
|
|
205
|
-
className,
|
|
206
|
-
views: viewsProp,
|
|
207
|
-
activeViewId,
|
|
208
|
-
onViewChange,
|
|
209
|
-
onRowClick,
|
|
210
|
-
onEdit: onEditProp,
|
|
211
|
-
renderListView,
|
|
212
|
-
toolbarAddon,
|
|
213
|
-
onCreateView,
|
|
214
|
-
onViewAction,
|
|
215
|
-
}) => {
|
|
216
|
-
const [objectSchema, setObjectSchema] = useState<Record<string, unknown> | null>(null);
|
|
217
|
-
// Assigned in the render body (not in an effect) so the fetchData effect always
|
|
218
|
-
// reads the latest objectSchema without needing it as a dependency. This matches
|
|
219
|
-
// the same pattern used in ObjectCalendar's objectSchemaRef.
|
|
220
|
-
const objectSchemaRef = useRef<Record<string, unknown> | null>(null);
|
|
221
|
-
objectSchemaRef.current = objectSchema;
|
|
222
|
-
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
223
|
-
const [formMode, setFormMode] = useState<FormMode>('create');
|
|
224
|
-
const [selectedRecord, setSelectedRecord] = useState<Record<string, unknown> | null>(null);
|
|
225
|
-
const [refreshKey, setRefreshKey] = useState(0);
|
|
226
|
-
|
|
227
|
-
// P2: Auto-subscribe to DataSource mutation events for non-grid views.
|
|
228
|
-
// When a DataSource implements onMutation(), ObjectView auto-refreshes
|
|
229
|
-
// its own data fetch (for non-grid view types like kanban, calendar, etc.)
|
|
230
|
-
// whenever a create/update/delete occurs on the same objectName.
|
|
231
|
-
//
|
|
232
|
-
// ListView-driven configurations already manage refreshKey via
|
|
233
|
-
// form success / delete handlers. To avoid double refreshes and
|
|
234
|
-
// duplicate find() calls, skip auto-subscription when renderListView is provided.
|
|
235
|
-
useEffect(() => {
|
|
236
|
-
if (!dataSource?.onMutation || !schema.objectName) return;
|
|
237
|
-
if (renderListView) return;
|
|
238
|
-
const unsub = dataSource.onMutation((event: any) => {
|
|
239
|
-
if (event.resource === schema.objectName) {
|
|
240
|
-
setRefreshKey(prev => prev + 1);
|
|
241
|
-
}
|
|
242
|
-
});
|
|
243
|
-
return unsub;
|
|
244
|
-
}, [dataSource, schema.objectName, renderListView]);
|
|
245
|
-
|
|
246
|
-
// Data fetching state for non-grid views
|
|
247
|
-
const [data, setData] = useState<any[]>([]);
|
|
248
|
-
const [loading, setLoading] = useState(false);
|
|
249
|
-
|
|
250
|
-
// Filter & Sort state
|
|
251
|
-
const [filterValues, setFilterValues] = useState<Record<string, any>>({});
|
|
252
|
-
const [sortConfig, setSortConfig] = useState<Array<{ field: string; direction: 'asc' | 'desc' }>>([]);
|
|
253
|
-
|
|
254
|
-
// --- Named listViews ---
|
|
255
|
-
const namedListViews = schema.listViews;
|
|
256
|
-
const hasNamedViews = namedListViews != null && Object.keys(namedListViews).length > 0;
|
|
257
|
-
const [activeNamedView, setActiveNamedView] = useState<string>(() => {
|
|
258
|
-
if (schema.defaultListView && namedListViews?.[schema.defaultListView]) {
|
|
259
|
-
return schema.defaultListView;
|
|
260
|
-
}
|
|
261
|
-
if (namedListViews) {
|
|
262
|
-
const keys = Object.keys(namedListViews);
|
|
263
|
-
return keys[0] || '';
|
|
264
|
-
}
|
|
265
|
-
return '';
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
// Get current named view config
|
|
269
|
-
const currentNamedViewConfig: NamedListView | null = useMemo(() => {
|
|
270
|
-
if (!hasNamedViews || !activeNamedView) return null;
|
|
271
|
-
return namedListViews![activeNamedView] || null;
|
|
272
|
-
}, [hasNamedViews, activeNamedView, namedListViews]);
|
|
273
|
-
|
|
274
|
-
// --- Multi-view type state (prop-based views) ---
|
|
275
|
-
const viewsPropResolved = useMemo(() => {
|
|
276
|
-
if (viewsProp && viewsProp.length > 0) return viewsProp;
|
|
277
|
-
return null;
|
|
278
|
-
}, [viewsProp]);
|
|
279
|
-
|
|
280
|
-
const hasMultiView = viewsPropResolved != null && viewsPropResolved.length > 0;
|
|
281
|
-
const currentActiveViewId = activeViewId || viewsPropResolved?.[0]?.id;
|
|
282
|
-
const activeView = viewsPropResolved?.find(v => v.id === currentActiveViewId) || viewsPropResolved?.[0];
|
|
283
|
-
|
|
284
|
-
// Current view type from named view, multi-view prop, or default
|
|
285
|
-
const currentViewType: string = useMemo(() => {
|
|
286
|
-
if (currentNamedViewConfig?.type) return currentNamedViewConfig.type;
|
|
287
|
-
if (activeView?.type) return activeView.type;
|
|
288
|
-
return schema.defaultViewType || 'grid';
|
|
289
|
-
}, [currentNamedViewConfig, activeView, schema.defaultViewType]);
|
|
290
|
-
|
|
291
|
-
// Navigation config
|
|
292
|
-
const navigationConfig: ViewNavigationConfig | undefined = schema.navigation;
|
|
293
|
-
|
|
294
|
-
// Fetch object schema from ObjectQL/ObjectStack
|
|
295
|
-
useEffect(() => {
|
|
296
|
-
let isMounted = true;
|
|
297
|
-
const fetchObjectSchema = async () => {
|
|
298
|
-
try {
|
|
299
|
-
const schemaData = await dataSource.getObjectSchema(schema.objectName);
|
|
300
|
-
if (isMounted) setObjectSchema(schemaData);
|
|
301
|
-
} catch (err) {
|
|
302
|
-
console.error('Failed to fetch object schema:', err);
|
|
303
|
-
}
|
|
304
|
-
};
|
|
305
|
-
if (schema.objectName && dataSource) {
|
|
306
|
-
fetchObjectSchema();
|
|
307
|
-
}
|
|
308
|
-
return () => { isMounted = false; };
|
|
309
|
-
}, [schema.objectName, dataSource]);
|
|
310
|
-
|
|
311
|
-
// Fetch data for non-grid view types (grid handles its own data via ObjectGrid)
|
|
312
|
-
useEffect(() => {
|
|
313
|
-
let isMounted = true;
|
|
314
|
-
|
|
315
|
-
const fetchData = async () => {
|
|
316
|
-
// When renderListView is provided, the custom list view (e.g. ListView)
|
|
317
|
-
// handles its own data fetching — skip to avoid duplicate requests and
|
|
318
|
-
// unnecessary re-renders that can cause duplicate records in child views.
|
|
319
|
-
if (renderListView) return;
|
|
320
|
-
// Only fetch for non-grid views (ObjectGrid has its own data fetching)
|
|
321
|
-
if (currentViewType === 'grid') return;
|
|
322
|
-
if (!dataSource || !schema.objectName) return;
|
|
323
|
-
|
|
324
|
-
setLoading(true);
|
|
325
|
-
try {
|
|
326
|
-
// Build filter
|
|
327
|
-
const baseFilter = currentNamedViewConfig?.filter || activeView?.filter || schema.table?.defaultFilters || [];
|
|
328
|
-
const userFilter = Object.entries(filterValues)
|
|
329
|
-
.filter(([, v]) => v !== undefined && v !== '' && v !== null)
|
|
330
|
-
.map(([field, value]) => [field, '=', value]);
|
|
331
|
-
|
|
332
|
-
let finalFilter: any = [];
|
|
333
|
-
if (baseFilter.length > 0 && userFilter.length > 0) {
|
|
334
|
-
finalFilter = ['and', ...baseFilter, ...userFilter];
|
|
335
|
-
} else if (userFilter.length === 1) {
|
|
336
|
-
finalFilter = userFilter[0];
|
|
337
|
-
} else if (userFilter.length > 1) {
|
|
338
|
-
finalFilter = ['and', ...userFilter];
|
|
339
|
-
} else {
|
|
340
|
-
finalFilter = baseFilter;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Build sort
|
|
344
|
-
const sort = sortConfig.length > 0
|
|
345
|
-
? sortConfig.map(s => ({ field: s.field, order: s.direction }))
|
|
346
|
-
: (currentNamedViewConfig?.sort || activeView?.sort || schema.table?.defaultSort || undefined);
|
|
347
|
-
|
|
348
|
-
// Auto-inject $expand for lookup/master_detail fields.
|
|
349
|
-
// Use a ref instead of the state variable to avoid re-running this effect
|
|
350
|
-
// every time the object schema loads — that would cause a double-fetch and
|
|
351
|
-
// duplicate events in child views like the calendar.
|
|
352
|
-
const expand = buildExpandFields((objectSchemaRef.current as any)?.fields);
|
|
353
|
-
const results = await dataSource.find(schema.objectName, {
|
|
354
|
-
$filter: finalFilter.length > 0 ? finalFilter : undefined,
|
|
355
|
-
$orderby: sort,
|
|
356
|
-
$top: 100,
|
|
357
|
-
...(expand.length > 0 ? { $expand: expand } : {}),
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
let items: any[] = [];
|
|
361
|
-
if (Array.isArray(results)) {
|
|
362
|
-
items = results;
|
|
363
|
-
} else if (results && typeof results === 'object') {
|
|
364
|
-
if (Array.isArray((results as any).data)) {
|
|
365
|
-
items = (results as any).data;
|
|
366
|
-
} else if (Array.isArray((results as any).records)) {
|
|
367
|
-
items = (results as any).records;
|
|
368
|
-
} else if (Array.isArray((results as any).value)) {
|
|
369
|
-
items = (results as any).value;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
if (isMounted) setData(items);
|
|
374
|
-
} catch (err) {
|
|
375
|
-
console.error('ObjectView data fetch error:', err);
|
|
376
|
-
} finally {
|
|
377
|
-
if (isMounted) setLoading(false);
|
|
378
|
-
}
|
|
379
|
-
};
|
|
380
|
-
|
|
381
|
-
fetchData();
|
|
382
|
-
return () => { isMounted = false; };
|
|
383
|
-
// objectSchema intentionally omitted from deps — read via ref to prevent double-fetch
|
|
384
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
385
|
-
}, [schema.objectName, dataSource, currentViewType, filterValues, sortConfig, refreshKey, currentNamedViewConfig, activeView, renderListView]);
|
|
386
|
-
|
|
387
|
-
// Determine layout mode
|
|
388
|
-
const layout = schema.layout || 'drawer';
|
|
389
|
-
|
|
390
|
-
// Determine enabled operations
|
|
391
|
-
const operations = schema.operations || schema.table?.operations || {
|
|
392
|
-
create: true,
|
|
393
|
-
read: true,
|
|
394
|
-
update: true,
|
|
395
|
-
delete: true,
|
|
396
|
-
};
|
|
397
|
-
|
|
398
|
-
// Handle create action
|
|
399
|
-
const handleCreate = useCallback(() => {
|
|
400
|
-
if (layout === 'page' && schema.onNavigate) {
|
|
401
|
-
schema.onNavigate('new', 'edit');
|
|
402
|
-
} else {
|
|
403
|
-
setFormMode('create');
|
|
404
|
-
setSelectedRecord(null);
|
|
405
|
-
setIsFormOpen(true);
|
|
406
|
-
}
|
|
407
|
-
}, [layout, schema]);
|
|
408
|
-
|
|
409
|
-
// Handle edit action
|
|
410
|
-
const handleEdit = useCallback((record: Record<string, unknown>) => {
|
|
411
|
-
if (onEditProp) {
|
|
412
|
-
onEditProp(record);
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
if (layout === 'page' && schema.onNavigate) {
|
|
416
|
-
const recordId = record.id || record._id;
|
|
417
|
-
schema.onNavigate(recordId as string | number, 'edit');
|
|
418
|
-
} else {
|
|
419
|
-
setFormMode('edit');
|
|
420
|
-
setSelectedRecord(record);
|
|
421
|
-
setIsFormOpen(true);
|
|
422
|
-
}
|
|
423
|
-
}, [layout, schema, onEditProp]);
|
|
424
|
-
|
|
425
|
-
// Handle view action (read a record)
|
|
426
|
-
const handleView = useCallback((record: Record<string, unknown>) => {
|
|
427
|
-
if (layout === 'page' && schema.onNavigate) {
|
|
428
|
-
const recordId = record.id || record._id;
|
|
429
|
-
schema.onNavigate(recordId as string | number, 'view');
|
|
430
|
-
} else {
|
|
431
|
-
setFormMode('view');
|
|
432
|
-
setSelectedRecord(record);
|
|
433
|
-
setIsFormOpen(true);
|
|
434
|
-
}
|
|
435
|
-
}, [layout, schema]);
|
|
436
|
-
|
|
437
|
-
// Handle row click - respects NavigationConfig
|
|
438
|
-
const handleRowClick = useCallback((record: Record<string, unknown>) => {
|
|
439
|
-
if (onRowClick) {
|
|
440
|
-
onRowClick(record);
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// Check NavigationConfig
|
|
445
|
-
if (navigationConfig) {
|
|
446
|
-
if (navigationConfig.mode === 'none' || navigationConfig.preventNavigation) {
|
|
447
|
-
return; // Do nothing
|
|
448
|
-
}
|
|
449
|
-
if (navigationConfig.mode === 'new_window' || navigationConfig.openNewTab) {
|
|
450
|
-
const recordId = record.id || record._id;
|
|
451
|
-
const url = `/${schema.objectName}/${encodeURIComponent(String(recordId))}`;
|
|
452
|
-
window.open(url, '_blank');
|
|
453
|
-
return;
|
|
454
|
-
}
|
|
455
|
-
if (navigationConfig.mode === 'drawer') {
|
|
456
|
-
setFormMode('view');
|
|
457
|
-
setSelectedRecord(record);
|
|
458
|
-
setIsFormOpen(true);
|
|
459
|
-
return;
|
|
460
|
-
}
|
|
461
|
-
if (navigationConfig.mode === 'modal') {
|
|
462
|
-
setFormMode('view');
|
|
463
|
-
setSelectedRecord(record);
|
|
464
|
-
setIsFormOpen(true);
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
if (navigationConfig.mode === 'page') {
|
|
468
|
-
const recordId = record.id || record._id;
|
|
469
|
-
if (schema.onNavigate) {
|
|
470
|
-
schema.onNavigate(recordId as string | number, 'view');
|
|
471
|
-
}
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
if (navigationConfig.mode === 'split' || navigationConfig.mode === 'popover') {
|
|
475
|
-
setFormMode('view');
|
|
476
|
-
setSelectedRecord(record);
|
|
477
|
-
setIsFormOpen(true);
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Default behavior
|
|
483
|
-
if (operations.read !== false) {
|
|
484
|
-
handleView(record);
|
|
485
|
-
}
|
|
486
|
-
}, [onRowClick, navigationConfig, operations.read, handleView, schema]);
|
|
487
|
-
|
|
488
|
-
// Handle delete action
|
|
489
|
-
const handleDelete = useCallback((_record: Record<string, unknown>) => {
|
|
490
|
-
setRefreshKey(prev => prev + 1);
|
|
491
|
-
}, []);
|
|
492
|
-
|
|
493
|
-
// Handle bulk delete action
|
|
494
|
-
const handleBulkDelete = useCallback((_records: Record<string, unknown>[]) => {
|
|
495
|
-
setRefreshKey(prev => prev + 1);
|
|
496
|
-
}, []);
|
|
497
|
-
|
|
498
|
-
// Handle form submission
|
|
499
|
-
const handleFormSuccess = useCallback(() => {
|
|
500
|
-
setIsFormOpen(false);
|
|
501
|
-
setSelectedRecord(null);
|
|
502
|
-
setRefreshKey(prev => prev + 1);
|
|
503
|
-
}, []);
|
|
504
|
-
|
|
505
|
-
// Handle form cancellation
|
|
506
|
-
const handleFormCancel = useCallback(() => {
|
|
507
|
-
setIsFormOpen(false);
|
|
508
|
-
setSelectedRecord(null);
|
|
509
|
-
}, []);
|
|
510
|
-
|
|
511
|
-
// Handle refresh
|
|
512
|
-
const handleRefresh = useCallback(() => {
|
|
513
|
-
setRefreshKey(prev => prev + 1);
|
|
514
|
-
}, []);
|
|
515
|
-
|
|
516
|
-
// --- ViewSwitcher schema (for multi-view prop views) ---
|
|
517
|
-
const viewSwitcherSchema: ViewSwitcherSchema | null = useMemo(() => {
|
|
518
|
-
if (!hasMultiView || !viewsPropResolved || viewsPropResolved.length <= 1) return null;
|
|
519
|
-
return {
|
|
520
|
-
type: 'view-switcher' as const,
|
|
521
|
-
variant: 'tabs',
|
|
522
|
-
position: 'top',
|
|
523
|
-
persistPreference: true,
|
|
524
|
-
storageKey: `view-pref-${schema.objectName}`,
|
|
525
|
-
defaultView: (activeView?.type || 'grid') as ViewType,
|
|
526
|
-
activeView: (activeView?.type || 'grid') as ViewType,
|
|
527
|
-
views: viewsPropResolved.map(v => {
|
|
528
|
-
const iconMap: Record<string, string> = {
|
|
529
|
-
kanban: 'kanban',
|
|
530
|
-
calendar: 'calendar',
|
|
531
|
-
map: 'map',
|
|
532
|
-
gallery: 'layout-grid',
|
|
533
|
-
timeline: 'activity',
|
|
534
|
-
gantt: 'gantt-chart',
|
|
535
|
-
grid: 'table',
|
|
536
|
-
list: 'list',
|
|
537
|
-
detail: 'file-text',
|
|
538
|
-
};
|
|
539
|
-
return {
|
|
540
|
-
type: v.type as ViewType,
|
|
541
|
-
label: v.label,
|
|
542
|
-
icon: iconMap[v.type] || 'table',
|
|
543
|
-
};
|
|
544
|
-
}),
|
|
545
|
-
allowCreateView: schema.allowCreateView,
|
|
546
|
-
viewActions: schema.viewActions,
|
|
547
|
-
};
|
|
548
|
-
}, [hasMultiView, viewsPropResolved, activeView, schema.objectName, schema.allowCreateView, schema.viewActions]);
|
|
549
|
-
|
|
550
|
-
// Handle view type change from ViewSwitcher → map back to view ID
|
|
551
|
-
const handleViewTypeChange = useCallback((viewType: ViewType) => {
|
|
552
|
-
if (!viewsPropResolved) return;
|
|
553
|
-
const matched = viewsPropResolved.find(v => v.type === viewType);
|
|
554
|
-
if (matched && onViewChange) {
|
|
555
|
-
onViewChange(matched.id);
|
|
556
|
-
}
|
|
557
|
-
}, [viewsPropResolved, onViewChange]);
|
|
558
|
-
|
|
559
|
-
// Handle named view change
|
|
560
|
-
const handleNamedViewChange = useCallback((viewKey: string) => {
|
|
561
|
-
setActiveNamedView(viewKey);
|
|
562
|
-
}, []);
|
|
563
|
-
|
|
564
|
-
// --- FilterUI schema (auto-generated from objectSchema or filterableFields) ---
|
|
565
|
-
const filterSchema: FilterUISchema | null = useMemo(() => {
|
|
566
|
-
if (schema.showFilters === false) return null;
|
|
567
|
-
|
|
568
|
-
// If filterableFields specified, use only those
|
|
569
|
-
const filterableFieldNames = schema.filterableFields;
|
|
570
|
-
const fields = (objectSchema as any)?.fields || {};
|
|
571
|
-
|
|
572
|
-
const fieldEntries = filterableFieldNames
|
|
573
|
-
? filterableFieldNames.map(name => [name, fields[name] || { label: name }] as [string, any])
|
|
574
|
-
: Object.entries(fields).filter(([, f]: [string, any]) => !f.hidden).slice(0, 8);
|
|
575
|
-
|
|
576
|
-
const filterableFieldDefs = fieldEntries.map(([key, f]: [string, any]) => {
|
|
577
|
-
const fieldType = f.type || 'text';
|
|
578
|
-
let filterType: 'text' | 'number' | 'select' | 'multi-select' | 'date' | 'boolean' = 'text';
|
|
579
|
-
let options: Array<{ label: string; value: any }> | undefined;
|
|
580
|
-
|
|
581
|
-
if (fieldType === 'number' || fieldType === 'currency' || fieldType === 'percent') {
|
|
582
|
-
filterType = 'number';
|
|
583
|
-
} else if (fieldType === 'boolean' || fieldType === 'toggle') {
|
|
584
|
-
filterType = 'boolean';
|
|
585
|
-
} else if (fieldType === 'date' || fieldType === 'datetime') {
|
|
586
|
-
filterType = 'date';
|
|
587
|
-
} else if (fieldType === 'select' || fieldType === 'status' || f.options) {
|
|
588
|
-
filterType = 'select';
|
|
589
|
-
options = (f.options || []).map((o: any) =>
|
|
590
|
-
typeof o === 'string' ? { label: o, value: o } : { label: o.label, value: o.value },
|
|
591
|
-
);
|
|
592
|
-
} else if (fieldType === 'lookup' || fieldType === 'master_detail' || fieldType === 'user' || fieldType === 'owner') {
|
|
593
|
-
if (f.options && f.options.length > 0) {
|
|
594
|
-
filterType = 'multi-select';
|
|
595
|
-
options = (f.options || []).map((o: any) =>
|
|
596
|
-
typeof o === 'string' ? { label: o, value: o } : { label: o.label, value: o.value },
|
|
597
|
-
);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
return {
|
|
601
|
-
field: key,
|
|
602
|
-
label: f.label || key,
|
|
603
|
-
type: filterType,
|
|
604
|
-
placeholder: `Filter ${f.label || key}...`,
|
|
605
|
-
...(options ? { options } : {}),
|
|
606
|
-
};
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
if (filterableFieldDefs.length === 0) return null;
|
|
610
|
-
|
|
611
|
-
return {
|
|
612
|
-
type: 'filter-ui' as const,
|
|
613
|
-
layout: 'popover' as const,
|
|
614
|
-
showClear: true,
|
|
615
|
-
showApply: true,
|
|
616
|
-
filters: filterableFieldDefs,
|
|
617
|
-
values: filterValues,
|
|
618
|
-
};
|
|
619
|
-
}, [schema.showFilters, schema.filterableFields, objectSchema, filterValues]);
|
|
620
|
-
|
|
621
|
-
// --- SortUI schema ---
|
|
622
|
-
const showSort = (schema as ObjectViewSchema).showSort;
|
|
623
|
-
const sortSchema: SortUISchema | null = useMemo(() => {
|
|
624
|
-
if (showSort === false) return null;
|
|
625
|
-
|
|
626
|
-
const fields = (objectSchema as any)?.fields || {};
|
|
627
|
-
const sortableFields = Object.entries(fields)
|
|
628
|
-
.filter(([, f]: [string, any]) => !f.hidden)
|
|
629
|
-
.slice(0, 10)
|
|
630
|
-
.map(([key, f]: [string, any]) => ({ field: key, label: f.label || key }));
|
|
631
|
-
|
|
632
|
-
if (sortableFields.length === 0) return null;
|
|
633
|
-
|
|
634
|
-
return {
|
|
635
|
-
type: 'sort-ui' as const,
|
|
636
|
-
variant: 'dropdown' as const,
|
|
637
|
-
multiple: false,
|
|
638
|
-
fields: sortableFields,
|
|
639
|
-
sort: sortConfig,
|
|
640
|
-
};
|
|
641
|
-
}, [objectSchema, sortConfig, showSort]);
|
|
642
|
-
|
|
643
|
-
// --- Generate view component schema for non-grid views ---
|
|
644
|
-
const generateViewSchema = useCallback((viewType: string): any => {
|
|
645
|
-
const baseProps: Record<string, any> = {
|
|
646
|
-
objectName: schema.objectName,
|
|
647
|
-
fields: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.fields,
|
|
648
|
-
className: 'h-full w-full',
|
|
649
|
-
showSearch: activeView?.showSearch ?? schema.showSearch ?? false,
|
|
650
|
-
showSort: activeView?.showSort ?? schema.showSort ?? false,
|
|
651
|
-
showFilters: activeView?.showFilters ?? schema.showFilters ?? false,
|
|
652
|
-
striped: activeView?.striped ?? false,
|
|
653
|
-
bordered: activeView?.bordered ?? false,
|
|
654
|
-
color: activeView?.color,
|
|
655
|
-
};
|
|
656
|
-
|
|
657
|
-
// Resolve type-specific options from current named view or active view
|
|
658
|
-
// Per @objectstack/spec, type-specific config MUST be nested under the view type key
|
|
659
|
-
const viewOptions = currentNamedViewConfig?.options || activeView || {};
|
|
660
|
-
|
|
661
|
-
// Dev-mode warning for flat property access violations
|
|
662
|
-
if (process.env.NODE_ENV === 'development') {
|
|
663
|
-
const flatKeys = ['startDateField', 'endDateField', 'dateField', 'groupBy', 'groupField',
|
|
664
|
-
'locationField', 'imageField', 'dependenciesField', 'progressField', 'titleField',
|
|
665
|
-
'subtitleField', 'latitudeField', 'longitudeField'];
|
|
666
|
-
const nestedConfig = viewOptions[viewType] || {};
|
|
667
|
-
const found = flatKeys.filter(k => k in viewOptions && !(k in nestedConfig));
|
|
668
|
-
if (found.length > 0) {
|
|
669
|
-
console.warn(
|
|
670
|
-
`[Spec Compliance] View options use flat properties ${JSON.stringify(found)}. ` +
|
|
671
|
-
`Move them under options.${viewType} per @objectstack/spec protocol.`
|
|
672
|
-
);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
switch (viewType) {
|
|
677
|
-
case 'kanban':
|
|
678
|
-
return {
|
|
679
|
-
type: 'object-kanban',
|
|
680
|
-
...baseProps,
|
|
681
|
-
groupBy: viewOptions.kanban?.groupField || 'status',
|
|
682
|
-
groupField: viewOptions.kanban?.groupField || 'status',
|
|
683
|
-
titleField: viewOptions.kanban?.titleField || 'name',
|
|
684
|
-
cardFields: baseProps.fields || [],
|
|
685
|
-
...(viewOptions.kanban || {}),
|
|
686
|
-
};
|
|
687
|
-
case 'calendar':
|
|
688
|
-
return {
|
|
689
|
-
type: 'object-calendar',
|
|
690
|
-
...baseProps,
|
|
691
|
-
startDateField: viewOptions.calendar?.startDateField || 'start_date',
|
|
692
|
-
endDateField: viewOptions.calendar?.endDateField || 'end_date',
|
|
693
|
-
titleField: viewOptions.calendar?.titleField || 'name',
|
|
694
|
-
...(viewOptions.calendar || {}),
|
|
695
|
-
};
|
|
696
|
-
case 'gallery':
|
|
697
|
-
return {
|
|
698
|
-
type: 'object-gallery',
|
|
699
|
-
...baseProps,
|
|
700
|
-
imageField: viewOptions.gallery?.imageField,
|
|
701
|
-
titleField: viewOptions.gallery?.titleField || 'name',
|
|
702
|
-
subtitleField: viewOptions.gallery?.subtitleField,
|
|
703
|
-
...(viewOptions.gallery || {}),
|
|
704
|
-
};
|
|
705
|
-
case 'timeline':
|
|
706
|
-
return {
|
|
707
|
-
type: 'object-timeline',
|
|
708
|
-
...baseProps,
|
|
709
|
-
dateField: viewOptions.timeline?.dateField || 'created_at',
|
|
710
|
-
titleField: viewOptions.timeline?.titleField || 'name',
|
|
711
|
-
...(viewOptions.timeline || {}),
|
|
712
|
-
};
|
|
713
|
-
case 'gantt':
|
|
714
|
-
return {
|
|
715
|
-
type: 'object-gantt',
|
|
716
|
-
...baseProps,
|
|
717
|
-
startDateField: viewOptions.gantt?.startDateField || 'start_date',
|
|
718
|
-
endDateField: viewOptions.gantt?.endDateField || 'end_date',
|
|
719
|
-
progressField: viewOptions.gantt?.progressField || 'progress',
|
|
720
|
-
dependenciesField: viewOptions.gantt?.dependenciesField || 'dependencies',
|
|
721
|
-
...(viewOptions.gantt || {}),
|
|
722
|
-
};
|
|
723
|
-
case 'map':
|
|
724
|
-
return {
|
|
725
|
-
type: 'object-map',
|
|
726
|
-
...baseProps,
|
|
727
|
-
locationField: viewOptions.map?.locationField || 'location',
|
|
728
|
-
...(viewOptions.map || {}),
|
|
729
|
-
};
|
|
730
|
-
default:
|
|
731
|
-
return null;
|
|
732
|
-
}
|
|
733
|
-
}, [schema.objectName, schema.table?.fields, currentNamedViewConfig, activeView]);
|
|
734
|
-
|
|
735
|
-
// Build grid schema (default content renderer)
|
|
736
|
-
const gridSchema: ObjectGridSchema = useMemo(() => ({
|
|
737
|
-
type: 'object-grid',
|
|
738
|
-
objectName: schema.objectName,
|
|
739
|
-
title: schema.table?.title,
|
|
740
|
-
description: schema.table?.description,
|
|
741
|
-
fields: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.fields,
|
|
742
|
-
columns: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.columns,
|
|
743
|
-
operations: {
|
|
744
|
-
...operations,
|
|
745
|
-
create: false, // Create is handled by the view's create button
|
|
746
|
-
},
|
|
747
|
-
defaultFilters: currentNamedViewConfig?.filter || activeView?.filter || schema.table?.defaultFilters,
|
|
748
|
-
defaultSort: currentNamedViewConfig?.sort || activeView?.sort || schema.table?.defaultSort,
|
|
749
|
-
pageSize: schema.table?.pageSize,
|
|
750
|
-
selectable: schema.table?.selectable,
|
|
751
|
-
striped: activeView?.striped ?? schema.table?.striped,
|
|
752
|
-
bordered: activeView?.bordered ?? schema.table?.bordered,
|
|
753
|
-
className: schema.table?.className,
|
|
754
|
-
}), [schema, operations, currentNamedViewConfig, activeView]);
|
|
755
|
-
|
|
756
|
-
// Build form schema
|
|
757
|
-
const buildFormSchema = (): ObjectFormSchema => {
|
|
758
|
-
const recordId = selectedRecord
|
|
759
|
-
? ((selectedRecord.id || selectedRecord._id) as string | number | undefined)
|
|
760
|
-
: undefined;
|
|
761
|
-
|
|
762
|
-
return {
|
|
763
|
-
type: 'object-form',
|
|
764
|
-
objectName: schema.objectName,
|
|
765
|
-
mode: formMode,
|
|
766
|
-
recordId,
|
|
767
|
-
title: schema.form?.title,
|
|
768
|
-
description: schema.form?.description,
|
|
769
|
-
fields: schema.form?.fields,
|
|
770
|
-
customFields: schema.form?.customFields,
|
|
771
|
-
groups: schema.form?.groups,
|
|
772
|
-
layout: schema.form?.layout,
|
|
773
|
-
columns: schema.form?.columns,
|
|
774
|
-
showSubmit: schema.form?.showSubmit,
|
|
775
|
-
submitText: schema.form?.submitText,
|
|
776
|
-
showCancel: schema.form?.showCancel,
|
|
777
|
-
cancelText: schema.form?.cancelText,
|
|
778
|
-
showReset: schema.form?.showReset,
|
|
779
|
-
initialValues: schema.form?.initialValues,
|
|
780
|
-
readOnly: schema.form?.readOnly || formMode === 'view',
|
|
781
|
-
className: schema.form?.className,
|
|
782
|
-
onSuccess: handleFormSuccess,
|
|
783
|
-
onCancel: handleFormCancel,
|
|
784
|
-
};
|
|
785
|
-
};
|
|
786
|
-
|
|
787
|
-
// Get form title based on mode
|
|
788
|
-
const getFormTitle = (): string => {
|
|
789
|
-
if (schema.form?.title) return schema.form.title;
|
|
790
|
-
const objectLabel = (objectSchema?.label as string) || schema.objectName;
|
|
791
|
-
switch (formMode) {
|
|
792
|
-
case 'create': return `Create ${objectLabel}`;
|
|
793
|
-
case 'edit': return `Edit ${objectLabel}`;
|
|
794
|
-
case 'view': return `View ${objectLabel}`;
|
|
795
|
-
default: return objectLabel;
|
|
796
|
-
}
|
|
797
|
-
};
|
|
798
|
-
|
|
799
|
-
// Determine form container width from navigation config
|
|
800
|
-
const formWidthClass = useMemo(() => {
|
|
801
|
-
const w = navigationConfig?.width;
|
|
802
|
-
if (!w) return '';
|
|
803
|
-
if (typeof w === 'number') return `max-w-[${w}px]`;
|
|
804
|
-
return `max-w-[${w}]`;
|
|
805
|
-
}, [navigationConfig]);
|
|
806
|
-
|
|
807
|
-
// Render the form in a drawer
|
|
808
|
-
const renderDrawerForm = () => (
|
|
809
|
-
<Drawer open={isFormOpen} onOpenChange={setIsFormOpen} direction="right">
|
|
810
|
-
<DrawerContent className={cn('w-full sm:max-w-2xl', formWidthClass)}>
|
|
811
|
-
<DrawerHeader>
|
|
812
|
-
<DrawerTitle>{getFormTitle()}</DrawerTitle>
|
|
813
|
-
{schema.form?.description && (
|
|
814
|
-
<DrawerDescription>{schema.form.description}</DrawerDescription>
|
|
815
|
-
)}
|
|
816
|
-
</DrawerHeader>
|
|
817
|
-
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
|
818
|
-
<ObjectForm schema={buildFormSchema()} dataSource={dataSource} />
|
|
819
|
-
</div>
|
|
820
|
-
</DrawerContent>
|
|
821
|
-
</Drawer>
|
|
822
|
-
);
|
|
823
|
-
|
|
824
|
-
// Render the form in a modal
|
|
825
|
-
const renderModalForm = () => (
|
|
826
|
-
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
|
|
827
|
-
<DialogContent className={cn('max-w-2xl max-h-[90vh] overflow-y-auto', formWidthClass)}>
|
|
828
|
-
<DialogHeader>
|
|
829
|
-
<DialogTitle>{getFormTitle()}</DialogTitle>
|
|
830
|
-
{schema.form?.description && (
|
|
831
|
-
<DialogDescription>{schema.form.description}</DialogDescription>
|
|
832
|
-
)}
|
|
833
|
-
</DialogHeader>
|
|
834
|
-
<ObjectForm schema={buildFormSchema()} dataSource={dataSource} />
|
|
835
|
-
</DialogContent>
|
|
836
|
-
</Dialog>
|
|
837
|
-
);
|
|
838
|
-
|
|
839
|
-
// Compute merged filters for the list
|
|
840
|
-
const mergedFilters = useMemo(() => {
|
|
841
|
-
const hasUserFilters = Object.keys(filterValues).some(
|
|
842
|
-
k => filterValues[k] !== undefined && filterValues[k] !== '' && filterValues[k] !== null,
|
|
843
|
-
);
|
|
844
|
-
if (hasUserFilters) {
|
|
845
|
-
return Object.entries(filterValues)
|
|
846
|
-
.filter(([, v]) => v !== undefined && v !== '' && v !== null)
|
|
847
|
-
.map(([field, value]) => ({ field, operator: 'equals' as const, value }));
|
|
848
|
-
}
|
|
849
|
-
return currentNamedViewConfig?.filter || activeView?.filter || schema.table?.defaultFilters;
|
|
850
|
-
}, [filterValues, currentNamedViewConfig, activeView, schema.table?.defaultFilters]);
|
|
851
|
-
|
|
852
|
-
const mergedSort = useMemo(() => {
|
|
853
|
-
return sortConfig.length > 0
|
|
854
|
-
? sortConfig
|
|
855
|
-
: currentNamedViewConfig?.sort || activeView?.sort || schema.table?.defaultSort;
|
|
856
|
-
}, [sortConfig, currentNamedViewConfig, activeView, schema.table?.defaultSort]);
|
|
857
|
-
|
|
858
|
-
// --- Content renderer ---
|
|
859
|
-
const renderContent = () => {
|
|
860
|
-
const key = `${schema.objectName}-${activeNamedView || activeView?.id || 'default'}-${currentViewType}-${refreshKey}`;
|
|
861
|
-
|
|
862
|
-
// If a custom renderListView is provided, use it
|
|
863
|
-
if (renderListView) {
|
|
864
|
-
return renderListView({
|
|
865
|
-
schema: {
|
|
866
|
-
type: 'list-view',
|
|
867
|
-
objectName: schema.objectName,
|
|
868
|
-
viewType: currentViewType as any,
|
|
869
|
-
fields: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.fields,
|
|
870
|
-
filters: mergedFilters,
|
|
871
|
-
sort: mergedSort,
|
|
872
|
-
// Propagate appearance/view-config properties for live preview
|
|
873
|
-
rowHeight: activeView?.rowHeight,
|
|
874
|
-
densityMode: activeView?.densityMode,
|
|
875
|
-
groupBy: activeView?.groupBy,
|
|
876
|
-
options: currentNamedViewConfig?.options || activeView,
|
|
877
|
-
// Propagate toolbar toggle flags
|
|
878
|
-
showSearch: activeView?.showSearch ?? schema.showSearch,
|
|
879
|
-
showFilters: activeView?.showFilters ?? schema.showFilters,
|
|
880
|
-
showSort: activeView?.showSort ?? schema.showSort,
|
|
881
|
-
showHideFields: activeView?.showHideFields ?? (schema as any).showHideFields,
|
|
882
|
-
showGroup: activeView?.showGroup ?? (schema as any).showGroup,
|
|
883
|
-
showColor: activeView?.showColor ?? (schema as any).showColor,
|
|
884
|
-
showDensity: activeView?.showDensity ?? (schema as any).showDensity,
|
|
885
|
-
allowExport: activeView?.allowExport ?? (schema as any).allowExport,
|
|
886
|
-
// Propagate display properties
|
|
887
|
-
striped: activeView?.striped ?? (schema as any).striped,
|
|
888
|
-
bordered: activeView?.bordered ?? (schema as any).bordered,
|
|
889
|
-
color: activeView?.color ?? (schema as any).color,
|
|
890
|
-
// Propagate view-config properties (Bug 4 / items 14-22)
|
|
891
|
-
inlineEdit: activeView?.inlineEdit ?? (schema as any).inlineEdit,
|
|
892
|
-
wrapHeaders: activeView?.wrapHeaders ?? (schema as any).wrapHeaders,
|
|
893
|
-
clickIntoRecordDetails: activeView?.clickIntoRecordDetails ?? (schema as any).clickIntoRecordDetails,
|
|
894
|
-
addRecordViaForm: activeView?.addRecordViaForm ?? (schema as any).addRecordViaForm,
|
|
895
|
-
addDeleteRecordsInline: activeView?.addDeleteRecordsInline ?? (schema as any).addDeleteRecordsInline,
|
|
896
|
-
collapseAllByDefault: activeView?.collapseAllByDefault ?? (schema as any).collapseAllByDefault,
|
|
897
|
-
fieldTextColor: activeView?.fieldTextColor ?? (schema as any).fieldTextColor,
|
|
898
|
-
prefixField: activeView?.prefixField ?? (schema as any).prefixField,
|
|
899
|
-
showDescription: activeView?.showDescription ?? (schema as any).showDescription,
|
|
900
|
-
// Propagate new spec properties (P0/P1/P2)
|
|
901
|
-
navigation: activeView?.navigation ?? (schema as any).navigation,
|
|
902
|
-
selection: activeView?.selection ?? (schema as any).selection,
|
|
903
|
-
pagination: activeView?.pagination ?? (schema as any).pagination,
|
|
904
|
-
searchableFields: activeView?.searchableFields ?? (schema as any).searchableFields,
|
|
905
|
-
filterableFields: activeView?.filterableFields ?? (schema as any).filterableFields,
|
|
906
|
-
resizable: activeView?.resizable ?? (schema as any).resizable,
|
|
907
|
-
hiddenFields: activeView?.hiddenFields ?? (schema as any).hiddenFields,
|
|
908
|
-
rowActions: activeView?.rowActions ?? (schema as any).rowActions,
|
|
909
|
-
bulkActions: activeView?.bulkActions ?? (schema as any).bulkActions,
|
|
910
|
-
sharing: activeView?.sharing ?? (schema as any).sharing,
|
|
911
|
-
addRecord: activeView?.addRecord ?? (schema as any).addRecord,
|
|
912
|
-
conditionalFormatting: activeView?.conditionalFormatting ?? (schema as any).conditionalFormatting,
|
|
913
|
-
quickFilters: activeView?.quickFilters ?? (schema as any).quickFilters,
|
|
914
|
-
userFilters: activeView?.userFilters ?? (schema as any).userFilters,
|
|
915
|
-
showRecordCount: activeView?.showRecordCount ?? (schema as any).showRecordCount,
|
|
916
|
-
allowPrinting: activeView?.allowPrinting ?? (schema as any).allowPrinting,
|
|
917
|
-
virtualScroll: activeView?.virtualScroll ?? (schema as any).virtualScroll,
|
|
918
|
-
emptyState: activeView?.emptyState ?? (schema as any).emptyState,
|
|
919
|
-
aria: activeView?.aria ?? (schema as any).aria,
|
|
920
|
-
tabs: (schema as any).tabs,
|
|
921
|
-
// Propagate refresh signal so ListView re-fetches after mutations
|
|
922
|
-
refreshTrigger: refreshKey,
|
|
923
|
-
},
|
|
924
|
-
dataSource,
|
|
925
|
-
onEdit: handleEdit,
|
|
926
|
-
onRowClick: handleRowClick,
|
|
927
|
-
className: 'h-full',
|
|
928
|
-
refreshKey,
|
|
929
|
-
});
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
// For non-grid views, use SchemaRenderer with generated schema
|
|
933
|
-
if (currentViewType !== 'grid') {
|
|
934
|
-
const viewSchema = generateViewSchema(currentViewType);
|
|
935
|
-
if (viewSchema && SchemaRendererComponent) {
|
|
936
|
-
return (
|
|
937
|
-
<SchemaRendererComponent
|
|
938
|
-
key={key}
|
|
939
|
-
schema={viewSchema}
|
|
940
|
-
dataSource={dataSource}
|
|
941
|
-
data={data}
|
|
942
|
-
loading={loading}
|
|
943
|
-
/>
|
|
944
|
-
);
|
|
945
|
-
}
|
|
946
|
-
// Fallback: if SchemaRenderer is not available or schema not generated
|
|
947
|
-
if (!SchemaRendererComponent) {
|
|
948
|
-
return (
|
|
949
|
-
<div className="flex items-center justify-center h-40 text-muted-foreground">
|
|
950
|
-
<p>SchemaRenderer not available. Install @object-ui/react to render {currentViewType} views.</p>
|
|
951
|
-
</div>
|
|
952
|
-
);
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
// Default: use ObjectGrid
|
|
957
|
-
return (
|
|
958
|
-
<ObjectGrid
|
|
959
|
-
key={key}
|
|
960
|
-
schema={gridSchema}
|
|
961
|
-
dataSource={dataSource}
|
|
962
|
-
onRowClick={handleRowClick}
|
|
963
|
-
onEdit={operations.update !== false ? handleEdit : undefined}
|
|
964
|
-
onDelete={operations.delete !== false ? handleDelete : undefined}
|
|
965
|
-
onBulkDelete={operations.delete !== false ? handleBulkDelete : undefined}
|
|
966
|
-
/>
|
|
967
|
-
);
|
|
968
|
-
};
|
|
969
|
-
|
|
970
|
-
// --- Named list views tabs ---
|
|
971
|
-
const renderNamedViewTabs = () => {
|
|
972
|
-
if (!hasNamedViews) return null;
|
|
973
|
-
const entries = Object.entries(namedListViews!);
|
|
974
|
-
if (entries.length <= 1) return null;
|
|
975
|
-
|
|
976
|
-
return (
|
|
977
|
-
<Tabs value={activeNamedView} onValueChange={handleNamedViewChange} className="w-full">
|
|
978
|
-
<TabsList className="w-auto">
|
|
979
|
-
{entries.map(([key, view]) => (
|
|
980
|
-
<TabsTrigger key={key} value={key} className="text-sm">
|
|
981
|
-
{view.label || key}
|
|
982
|
-
</TabsTrigger>
|
|
983
|
-
))}
|
|
984
|
-
</TabsList>
|
|
985
|
-
</Tabs>
|
|
986
|
-
);
|
|
987
|
-
};
|
|
988
|
-
|
|
989
|
-
// Render toolbar — only named view tabs; filter/sort/search is handled by ListView
|
|
990
|
-
const renderToolbar = () => {
|
|
991
|
-
const showCreateButton = schema.showCreate !== false && operations.create !== false;
|
|
992
|
-
const showViewSwitcherToggle = schema.showViewSwitcher === true; // Changed: default to false (hidden)
|
|
993
|
-
|
|
994
|
-
const namedViewTabs = renderNamedViewTabs();
|
|
995
|
-
|
|
996
|
-
// Hide toolbar entirely if there is nothing to show
|
|
997
|
-
if (!namedViewTabs && !showViewSwitcherToggle && !showCreateButton && !toolbarAddon) return null;
|
|
998
|
-
|
|
999
|
-
return (
|
|
1000
|
-
<div className="flex flex-col gap-3">
|
|
1001
|
-
{/* Named view tabs (if any) */}
|
|
1002
|
-
{namedViewTabs}
|
|
1003
|
-
|
|
1004
|
-
{/* ViewSwitcher + action buttons row */}
|
|
1005
|
-
{(showViewSwitcherToggle || showCreateButton || toolbarAddon) && (
|
|
1006
|
-
<div className="flex items-center justify-between gap-4">
|
|
1007
|
-
<div className="flex items-center gap-2">
|
|
1008
|
-
{showViewSwitcherToggle && viewSwitcherSchema && (
|
|
1009
|
-
<ViewSwitcher
|
|
1010
|
-
schema={viewSwitcherSchema}
|
|
1011
|
-
onViewChange={handleViewTypeChange}
|
|
1012
|
-
onCreateView={onCreateView}
|
|
1013
|
-
onViewAction={onViewAction}
|
|
1014
|
-
className="overflow-x-auto"
|
|
1015
|
-
/>
|
|
1016
|
-
)}
|
|
1017
|
-
</div>
|
|
1018
|
-
|
|
1019
|
-
{/* Right side: Actions */}
|
|
1020
|
-
<div className="flex items-center gap-2">
|
|
1021
|
-
{toolbarAddon}
|
|
1022
|
-
{showCreateButton && (
|
|
1023
|
-
<Button size="sm" onClick={handleCreate}>
|
|
1024
|
-
<Plus className="h-4 w-4" />
|
|
1025
|
-
Create
|
|
1026
|
-
</Button>
|
|
1027
|
-
)}
|
|
1028
|
-
</div>
|
|
1029
|
-
</div>
|
|
1030
|
-
)}
|
|
1031
|
-
</div>
|
|
1032
|
-
);
|
|
1033
|
-
};
|
|
1034
|
-
|
|
1035
|
-
// Determine which form container to render
|
|
1036
|
-
const formLayout = navigationConfig?.mode === 'modal' ? 'modal'
|
|
1037
|
-
: navigationConfig?.mode === 'drawer' ? 'drawer'
|
|
1038
|
-
: navigationConfig?.mode === 'split' ? 'split'
|
|
1039
|
-
: navigationConfig?.mode === 'popover' ? 'popover'
|
|
1040
|
-
: layout;
|
|
1041
|
-
|
|
1042
|
-
// Build the record detail content for NavigationOverlay (split/popover modes)
|
|
1043
|
-
const renderOverlayDetail = (_record: Record<string, unknown>) => (
|
|
1044
|
-
<div className="space-y-3">
|
|
1045
|
-
<ObjectForm schema={buildFormSchema()} dataSource={dataSource} />
|
|
1046
|
-
</div>
|
|
1047
|
-
);
|
|
1048
|
-
|
|
1049
|
-
// Shared handler for NavigationOverlay onOpenChange — close form when overlay is dismissed
|
|
1050
|
-
const handleOverlayOpenChange = useCallback((open: boolean) => {
|
|
1051
|
-
if (!open) handleFormCancel();
|
|
1052
|
-
}, [handleFormCancel]);
|
|
1053
|
-
|
|
1054
|
-
// For split mode, wrap content inside NavigationOverlay with mainContent
|
|
1055
|
-
if (formLayout === 'split') {
|
|
1056
|
-
const objectLabel = (objectSchema?.label as string) || schema.objectName;
|
|
1057
|
-
return (
|
|
1058
|
-
<div className={cn('flex flex-col h-full min-w-0 overflow-hidden', className)}>
|
|
1059
|
-
{(schema.title || schema.description) && (
|
|
1060
|
-
<div className="mb-4 shrink-0">
|
|
1061
|
-
{schema.title && <h2 className="text-2xl font-bold tracking-tight">{schema.title}</h2>}
|
|
1062
|
-
{schema.description && <p className="text-muted-foreground mt-1">{schema.description}</p>}
|
|
1063
|
-
</div>
|
|
1064
|
-
)}
|
|
1065
|
-
<div className="mb-4 shrink-0">{renderToolbar()}</div>
|
|
1066
|
-
<div className="flex-1 min-h-0 min-w-0 overflow-hidden">
|
|
1067
|
-
{isFormOpen && selectedRecord ? (
|
|
1068
|
-
<NavigationOverlay
|
|
1069
|
-
isOpen={isFormOpen}
|
|
1070
|
-
selectedRecord={selectedRecord}
|
|
1071
|
-
mode="split"
|
|
1072
|
-
close={handleFormCancel}
|
|
1073
|
-
setIsOpen={handleOverlayOpenChange}
|
|
1074
|
-
width={navigationConfig?.width}
|
|
1075
|
-
isOverlay={true}
|
|
1076
|
-
title={`${objectLabel} Detail`}
|
|
1077
|
-
mainContent={<div className="h-full overflow-auto">{renderContent()}</div>}
|
|
1078
|
-
>
|
|
1079
|
-
{renderOverlayDetail}
|
|
1080
|
-
</NavigationOverlay>
|
|
1081
|
-
) : (
|
|
1082
|
-
renderContent()
|
|
1083
|
-
)}
|
|
1084
|
-
</div>
|
|
1085
|
-
</div>
|
|
1086
|
-
);
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
return (
|
|
1090
|
-
<div className={cn('flex flex-col h-full min-w-0 overflow-hidden', className)}>
|
|
1091
|
-
{/* Title and description */}
|
|
1092
|
-
{(schema.title || schema.description) && (
|
|
1093
|
-
<div className="mb-4 shrink-0">
|
|
1094
|
-
{schema.title && (
|
|
1095
|
-
<h2 className="text-2xl font-bold tracking-tight">{schema.title}</h2>
|
|
1096
|
-
)}
|
|
1097
|
-
{schema.description && (
|
|
1098
|
-
<p className="text-muted-foreground mt-1">{schema.description}</p>
|
|
1099
|
-
)}
|
|
1100
|
-
</div>
|
|
1101
|
-
)}
|
|
1102
|
-
|
|
1103
|
-
{/* Toolbar */}
|
|
1104
|
-
<div className="mb-4 shrink-0">
|
|
1105
|
-
{renderToolbar()}
|
|
1106
|
-
</div>
|
|
1107
|
-
|
|
1108
|
-
{/* Content */}
|
|
1109
|
-
<div className="flex-1 min-h-0 min-w-0 overflow-hidden">
|
|
1110
|
-
{renderContent()}
|
|
1111
|
-
</div>
|
|
1112
|
-
|
|
1113
|
-
{/* Form (drawer or modal) */}
|
|
1114
|
-
{formLayout === 'drawer' && renderDrawerForm()}
|
|
1115
|
-
{formLayout === 'modal' && renderModalForm()}
|
|
1116
|
-
{/* Popover mode — uses NavigationOverlay Dialog fallback (no popoverTrigger) */}
|
|
1117
|
-
{formLayout === 'popover' && isFormOpen && selectedRecord && (
|
|
1118
|
-
<NavigationOverlay
|
|
1119
|
-
isOpen={isFormOpen}
|
|
1120
|
-
selectedRecord={selectedRecord}
|
|
1121
|
-
mode="popover"
|
|
1122
|
-
close={handleFormCancel}
|
|
1123
|
-
setIsOpen={handleOverlayOpenChange}
|
|
1124
|
-
width={navigationConfig?.width}
|
|
1125
|
-
isOverlay={true}
|
|
1126
|
-
title={getFormTitle()}
|
|
1127
|
-
>
|
|
1128
|
-
{renderOverlayDetail}
|
|
1129
|
-
</NavigationOverlay>
|
|
1130
|
-
)}
|
|
1131
|
-
</div>
|
|
1132
|
-
);
|
|
1133
|
-
};
|