@object-ui/plugin-view 0.5.0 → 2.0.0

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