@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.
@@ -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
- };