@object-ui/plugin-view 3.1.5 → 3.3.1

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