@object-ui/app-shell 3.3.2 → 4.0.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.
@@ -14,15 +14,16 @@ import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
14
14
  const ObjectChart = lazy(() => import('@object-ui/plugin-charts').then((m) => ({ default: m.ObjectChart })));
15
15
  import { ListView } from '@object-ui/plugin-list';
16
16
  import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
17
- import { ObjectView as PluginObjectView, ViewTabBar } from '@object-ui/plugin-view';
17
+ import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view';
18
18
  // Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx
19
19
  // uses ComponentRegistry.registerLazy so heavy plugins stay code-split).
20
20
  // Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here.
21
- import { Button, Empty, EmptyTitle, EmptyDescription, NavigationOverlay, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@object-ui/components';
22
- import { Plus, Table as TableIcon, Settings2, Wrench, KanbanSquare, Calendar, LayoutGrid, Activity, GanttChart, MapPin, BarChart3 } from 'lucide-react';
21
+ import { Button, Empty, EmptyTitle, EmptyDescription, NavigationOverlay } from '@object-ui/components';
22
+ import { Plus, Table as TableIcon, KanbanSquare, Calendar, LayoutGrid, Activity, GanttChart, MapPin, BarChart3 } from 'lucide-react';
23
23
  import { getIcon } from '../utils/getIcon';
24
- import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector';
24
+ import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
25
25
  import { ViewConfigPanel } from './ViewConfigPanel';
26
+ import { CreateViewDialog } from './CreateViewDialog';
26
27
  import { useObjectActions } from '../hooks/useObjectActions';
27
28
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
28
29
  import { usePermissions } from '@object-ui/permissions';
@@ -199,12 +200,16 @@ export function ObjectView({ dataSource, objects, onEdit }) {
199
200
  const navigate = useNavigate();
200
201
  const { objectName, viewId } = useParams();
201
202
  const [searchParams, setSearchParams] = useSearchParams();
202
- const { showDebug, toggleDebug } = useMetadataInspector();
203
+ const { showDebug } = useMetadataInspector();
203
204
  const { t } = useObjectTranslation();
204
205
  const { objectLabel, objectDescription: objectDesc } = useObjectLabel();
205
206
  // Inline view config panel state (Airtable-style right sidebar)
206
207
  const [showViewConfigPanel, setShowViewConfigPanel] = useState(false);
207
208
  const [viewConfigPanelMode, setViewConfigPanelMode] = useState('edit');
209
+ // Airtable-style "Create view" dialog (type picker + name input)
210
+ const [showCreateViewDialog, setShowCreateViewDialog] = useState(false);
211
+ // Manage Views dialog (vertical sortable list of all views)
212
+ const [manageViewsOpen, setManageViewsOpen] = useState(false);
208
213
  // Draft state for view config edits — cached locally, saved on demand
209
214
  const [viewDraft, setViewDraft] = useState(null);
210
215
  const handleViewConfigSave = useCallback((draft) => {
@@ -230,18 +235,56 @@ export function ObjectView({ dataSource, objects, onEdit }) {
230
235
  /** Create a new view via the config panel */
231
236
  const handleViewCreate = useCallback(async (config) => {
232
237
  try {
238
+ let createdId;
233
239
  if (dataSource?.create) {
234
- const payload = { objectName, ...config };
235
- await dataSource.create('sys_view', payload);
240
+ // Prefill sensible defaults so the saved view renders rows
241
+ // immediately even if the user didn't pick columns yet.
242
+ const objectDef = objects?.find?.((o) => o.name === objectName);
243
+ const SYSTEM_FIELDS = new Set([
244
+ 'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt',
245
+ 'deleted_at', 'deletedAt', 'created_by', 'createdBy',
246
+ 'updated_by', 'updatedBy', '_version', '_rev',
247
+ ]);
248
+ let defaultColumns = [];
249
+ if (Array.isArray(objectDef?.compactLayout) && objectDef.compactLayout.length > 0) {
250
+ defaultColumns = objectDef.compactLayout.filter((n) => objectDef.fields?.[n]);
251
+ }
252
+ else if (objectDef?.fields) {
253
+ defaultColumns = Object.entries(objectDef.fields)
254
+ .filter(([name, f]) => f && !f.hidden && !SYSTEM_FIELDS.has(name))
255
+ .map(([name]) => name)
256
+ .slice(0, 5);
257
+ }
258
+ const incomingColumns = Array.isArray(config.columns) && config.columns.length > 0
259
+ ? config.columns
260
+ : defaultColumns;
261
+ const payload = {
262
+ objectName,
263
+ ...config,
264
+ columns: incomingColumns,
265
+ };
266
+ const created = await dataSource.create('sys_view', payload);
267
+ createdId = created?.id ?? created?._id;
236
268
  }
237
269
  setShowViewConfigPanel(false);
238
270
  setViewConfigPanelMode('edit');
239
271
  setRefreshKey(k => k + 1);
272
+ // Auto-activate the newly created view (Airtable parity).
273
+ // Routing falls back to the default view if `createdId` doesn't
274
+ // resolve yet — re-render after refresh will pick it up.
275
+ if (createdId) {
276
+ if (viewId) {
277
+ navigate(`../${createdId}`, { relative: 'path' });
278
+ }
279
+ else {
280
+ navigate(`view/${createdId}`);
281
+ }
282
+ }
240
283
  }
241
284
  catch (err) {
242
285
  console.error('[ViewConfigPanel] Failed to create view:', err);
243
286
  }
244
- }, [dataSource, objectName]);
287
+ }, [dataSource, objectName, objects, navigate, viewId]);
245
288
  // Record count tracking for footer
246
289
  const [recordCount, setRecordCount] = useState(undefined);
247
290
  // Admin users automatically get design tools (no toggle needed)
@@ -253,31 +296,66 @@ export function ObjectView({ dataSource, objects, onEdit }) {
253
296
  if (!objectDef) {
254
297
  return (_jsx("div", { className: "h-full p-4 flex items-center justify-center", children: _jsxs(Empty, { children: [_jsx("div", { className: "mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted", children: _jsx(TableIcon, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('console.objectView.objectNotFound') }), _jsxs(EmptyDescription, { children: [t('console.objectView.objectNotFoundDescription', { objectName }), ' ', t('console.objectView.objectNotFoundHint')] })] }) }));
255
298
  }
299
+ // Refresh trigger — bumped after view CRUD or external data mutations.
300
+ const [refreshKey, setRefreshKey] = useState(0);
301
+ // ─── User-defined views (sys_view) ──────────────────────────────────
302
+ // Saved views created via the ViewConfigPanel ("Add View") are persisted
303
+ // to the `sys_view` object. We fetch them here and merge into `views` so
304
+ // the ViewTabBar can render them alongside metadata-defined listViews.
305
+ const [savedViews, setSavedViews] = useState([]);
306
+ useEffect(() => {
307
+ let cancelled = false;
308
+ if (!dataSource?.find || !objectName) {
309
+ setSavedViews([]);
310
+ return;
311
+ }
312
+ dataSource
313
+ .find('sys_view', {
314
+ $filter: ['objectName', '=', objectName],
315
+ $orderby: [{ field: 'created_at', order: 'asc' }],
316
+ $top: 200,
317
+ })
318
+ .then((res) => {
319
+ if (cancelled)
320
+ return;
321
+ const rows = Array.isArray(res)
322
+ ? res
323
+ : Array.isArray(res?.data) ? res.data
324
+ : Array.isArray(res?.records) ? res.records
325
+ : Array.isArray(res?.value) ? res.value
326
+ : [];
327
+ // Defensive client-side filter: only keep rows that look like
328
+ // sys_view records for *this* object. Adapters that don't
329
+ // honour $filter (or test mocks that ignore it) won't pollute
330
+ // the view list with arbitrary records.
331
+ const filtered = rows.filter(r => r && r.objectName === objectName);
332
+ setSavedViews(filtered);
333
+ })
334
+ .catch((err) => {
335
+ console.error('[ObjectView] Failed to load sys_view records:', err);
336
+ if (!cancelled)
337
+ setSavedViews([]);
338
+ });
339
+ return () => { cancelled = true; };
340
+ }, [dataSource, objectName, refreshKey]);
256
341
  // Resolve Views from objectDef.listViews (camelCase per @objectstack/spec)
257
342
  const views = useMemo(() => {
258
- const definedViews = objectDef.listViews || objectDef.list_views || {};
259
- const viewList = Object.entries(definedViews).map(([key, value]) => ({
260
- id: key,
261
- ...value,
262
- type: value.type || 'grid'
263
- }));
264
- if (viewList.length === 0) {
265
- // Default column resolution priority:
266
- // 1. `compactLayout` (curated primary business fields).
267
- // 2. Business fields only — exclude system-managed identifiers/audit
268
- // columns (id, created_at, updated_at, …) and fields explicitly
269
- // marked hidden/readonly on the schema. First 5 kept for compactness.
270
- const SYSTEM_FIELDS = new Set([
271
- 'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt',
272
- 'deleted_at', 'deletedAt', 'created_by', 'createdBy',
273
- 'updated_by', 'updatedBy', '_version', '_rev',
274
- ]);
275
- let defaultColumns = [];
343
+ // Default column resolution priority:
344
+ // 1. `compactLayout` (curated primary business fields).
345
+ // 2. Business fields only — exclude system-managed identifiers/audit
346
+ // columns (id, created_at, updated_at, …) and fields explicitly
347
+ // marked hidden/readonly on the schema. First 5 kept for compactness.
348
+ const SYSTEM_FIELDS = new Set([
349
+ 'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt',
350
+ 'deleted_at', 'deletedAt', 'created_by', 'createdBy',
351
+ 'updated_by', 'updatedBy', '_version', '_rev',
352
+ ]);
353
+ const resolveDefaultColumns = () => {
276
354
  if (Array.isArray(objectDef.compactLayout) && objectDef.compactLayout.length > 0) {
277
- defaultColumns = objectDef.compactLayout.filter((n) => objectDef.fields?.[n]);
355
+ return objectDef.compactLayout.filter((n) => objectDef.fields?.[n]);
278
356
  }
279
- else if (objectDef.fields) {
280
- defaultColumns = Object.entries(objectDef.fields)
357
+ if (objectDef.fields) {
358
+ return Object.entries(objectDef.fields)
281
359
  .filter(([name, f]) => {
282
360
  if (!f)
283
361
  return false;
@@ -290,17 +368,111 @@ export function ObjectView({ dataSource, objects, onEdit }) {
290
368
  .map(([name]) => name)
291
369
  .slice(0, 5);
292
370
  }
371
+ return [];
372
+ };
373
+ const definedViews = objectDef.listViews || objectDef.list_views || {};
374
+ const viewList = Object.entries(definedViews).map(([key, value]) => ({
375
+ id: key,
376
+ ...value,
377
+ type: value.type || 'grid'
378
+ }));
379
+ if (viewList.length === 0) {
293
380
  viewList.push({
294
381
  id: 'all',
295
382
  label: t('console.objectView.allRecords'),
296
383
  type: 'grid',
297
- columns: defaultColumns,
384
+ columns: resolveDefaultColumns(),
298
385
  });
299
386
  }
387
+ // Merge user-defined views (sys_view) after metadata-defined views.
388
+ // Dedup by id so a saved view that shadows a metadata view wins.
389
+ const metaIds = new Set(viewList.map(v => v.id));
390
+ for (const sv of savedViews) {
391
+ const id = sv.id || sv._id;
392
+ if (!id)
393
+ continue;
394
+ const normalized = {
395
+ label: sv.label || sv.name || id,
396
+ type: sv.type || 'grid',
397
+ columns: sv.columns,
398
+ filter: sv.filter,
399
+ sort: sv.sort,
400
+ showSearch: sv.showSearch,
401
+ showFilters: sv.showFilters,
402
+ showSort: sv.showSort,
403
+ isPinned: sv.isPinned,
404
+ isDefault: sv.isDefault,
405
+ visibility: sv.visibility,
406
+ sortOrder: sv.sortOrder,
407
+ ...sv,
408
+ id,
409
+ };
410
+ if (metaIds.has(id)) {
411
+ const idx = viewList.findIndex(v => v.id === id);
412
+ viewList[idx] = { ...viewList[idx], ...normalized };
413
+ }
414
+ else {
415
+ viewList.push(normalized);
416
+ }
417
+ }
418
+ // Apply default columns to any grid-like view that has no explicit
419
+ // columns (e.g. saved views created via "Add View" before the user
420
+ // configured fields). Without this, the grid renders an empty header
421
+ // row and the data fetch omits a `select` clause.
422
+ const GRID_LIKE = new Set(['grid', 'list', 'table']);
423
+ for (const v of viewList) {
424
+ if (!GRID_LIKE.has(v.type))
425
+ continue;
426
+ if (!Array.isArray(v.columns) || v.columns.length === 0) {
427
+ v.columns = resolveDefaultColumns();
428
+ }
429
+ }
430
+ // Stable sort: respect a per-user view-order preference (for both
431
+ // metadata and saved views). Falls back to: metadata views first
432
+ // in declared order, then saved views by `sortOrder` / created_at.
433
+ const indexOf = new Map(viewList.map((v, i) => [v.id, i]));
434
+ let userOrder = [];
435
+ try {
436
+ const raw = typeof window !== 'undefined'
437
+ ? window.localStorage?.getItem(`viewOrder:${objectDef.name}`)
438
+ : null;
439
+ if (raw)
440
+ userOrder = JSON.parse(raw);
441
+ }
442
+ catch { /* ignore */ }
443
+ const userOrderIndex = new Map(userOrder.map((id, i) => [id, i]));
444
+ viewList.sort((a, b) => {
445
+ const aUser = userOrderIndex.get(a.id);
446
+ const bUser = userOrderIndex.get(b.id);
447
+ if (aUser !== undefined && bUser !== undefined)
448
+ return aUser - bUser;
449
+ if (aUser !== undefined)
450
+ return -1;
451
+ if (bUser !== undefined)
452
+ return 1;
453
+ const aSaved = savedViews.find((sv) => (sv.id || sv._id) === a.id);
454
+ const bSaved = savedViews.find((sv) => (sv.id || sv._id) === b.id);
455
+ if (!aSaved && !bSaved)
456
+ return (indexOf.get(a.id) ?? 0) - (indexOf.get(b.id) ?? 0);
457
+ if (!aSaved)
458
+ return -1;
459
+ if (!bSaved)
460
+ return 1;
461
+ const ao = typeof aSaved.sortOrder === 'number' ? aSaved.sortOrder : Number.MAX_SAFE_INTEGER;
462
+ const bo = typeof bSaved.sortOrder === 'number' ? bSaved.sortOrder : Number.MAX_SAFE_INTEGER;
463
+ if (ao !== bo)
464
+ return ao - bo;
465
+ return (aSaved.created_at || '').localeCompare(bSaved.created_at || '');
466
+ });
300
467
  return viewList;
301
- }, [objectDef]);
302
- // Active View State — merge saved draft if available for this view
303
- const activeViewId = viewId || searchParams.get('view') || views[0]?.id;
468
+ }, [objectDef, savedViews, t]);
469
+ // Active View State — merge saved draft if available for this view.
470
+ // Resolution priority: URL viewId ?view= user-marked default → first.
471
+ const defaultViewId = useMemo(() => {
472
+ const def = views.find((v) => v.isDefault);
473
+ return def?.id;
474
+ }, [views]);
475
+ const activeViewId = viewId || searchParams.get('view') || defaultViewId || views[0]?.id;
304
476
  const baseView = views.find((v) => v.id === activeViewId) || views[0];
305
477
  const activeView = viewDraft && viewDraft.id === baseView?.id
306
478
  ? { ...baseView, ...viewDraft }
@@ -318,8 +490,12 @@ export function ObjectView({ dataSource, objects, onEdit }) {
318
490
  const matchedView = views.find((v) => v.id === newViewId);
319
491
  if (!matchedView)
320
492
  return;
321
- // Auto-close the config panel when switching views
322
- setShowViewConfigPanel(false);
493
+ // Auto-close the config panel only when actually switching to a
494
+ // different view. Same-view clicks (e.g., bubbling from the actions
495
+ // dropdown menu item) must not stomp on a freshly-opened panel.
496
+ if (matchedView.id !== activeViewId) {
497
+ setShowViewConfigPanel(false);
498
+ }
323
499
  if (viewId) {
324
500
  navigate(`../${matchedView.id}`, { relative: "path" });
325
501
  }
@@ -329,8 +505,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
329
505
  };
330
506
  // ViewSwitcher callbacks — wired to both PluginObjectView instances
331
507
  const handleCreateView = useCallback(() => {
332
- setViewConfigPanelMode('create');
333
- setShowViewConfigPanel(true);
508
+ setShowCreateViewDialog(true);
334
509
  }, []);
335
510
  const handleViewAction = useCallback((actionType, viewType) => {
336
511
  if (actionType === 'settings') {
@@ -341,19 +516,30 @@ export function ObjectView({ dataSource, objects, onEdit }) {
341
516
  setShowViewConfigPanel(true);
342
517
  }
343
518
  }, [views, handleViewChange]);
344
- // Action system for toolbar operations
345
- const [refreshKey, setRefreshKey] = useState(0);
346
- const actions = useObjectActions({
347
- objectName: objectDef.name,
348
- objectLabel: objectDef.label,
349
- dataSource,
350
- onEdit,
351
- onRefresh: () => setRefreshKey(k => k + 1),
352
- });
353
- // ─── ActionProvider handlers for schema-driven toolbar actions ──────
354
- const currentUser = user
355
- ? { id: user.id, name: user.name, avatar: user.image }
356
- : FALLBACK_USER;
519
+ // ─── ViewTabBar CRUD callbacks (Phase 2) ────────────────────────────
520
+ /** Returns true if the view is backed by a sys_view record (mutable). */
521
+ const isSavedView = useCallback((vid) => {
522
+ return savedViews.some((sv) => (sv.id || sv._id) === vid);
523
+ }, [savedViews]);
524
+ const handleRenameView = useCallback(async (vid, newName) => {
525
+ if (!dataSource?.update)
526
+ return;
527
+ if (!isSavedView(vid)) {
528
+ toast.error(t('console.objectView.cannotEditMetaView') || 'Built-in views cannot be renamed.');
529
+ return;
530
+ }
531
+ try {
532
+ await dataSource.update('sys_view', vid, { label: newName });
533
+ setRefreshKey(k => k + 1);
534
+ }
535
+ catch (err) {
536
+ console.error('[ViewTabBar] Failed to rename view:', err);
537
+ toast.error('Failed to rename view');
538
+ }
539
+ }, [dataSource, isSavedView, t]);
540
+ // Promise-based confirm/param dialogs — declared early so destructive
541
+ // handlers (delete, etc.) can `await confirmHandler(...)` for a proper
542
+ // Airtable-style confirmation flow.
357
543
  const [confirmState, setConfirmState] = useState({ open: false, message: '' });
358
544
  const [paramState, setParamState] = useState({ open: false, params: [] });
359
545
  const confirmHandler = useCallback((message, options) => {
@@ -366,6 +552,162 @@ export function ObjectView({ dataSource, objects, onEdit }) {
366
552
  setParamState({ open: true, params, resolve });
367
553
  });
368
554
  }, []);
555
+ const handleDeleteView = useCallback(async (vid) => {
556
+ if (!dataSource?.delete)
557
+ return;
558
+ if (!isSavedView(vid)) {
559
+ toast.error(t('console.objectView.cannotDeleteMetaView') || 'Built-in views cannot be deleted.');
560
+ return;
561
+ }
562
+ const targetView = views.find((v) => v.id === vid);
563
+ const viewLabel = targetView?.label || vid;
564
+ const confirmed = await confirmHandler(t('console.objectView.deleteViewConfirm', { name: viewLabel }) ||
565
+ `Are you sure you want to delete the view "${viewLabel}"? This cannot be undone.`, {
566
+ title: t('console.objectView.deleteViewTitle') || 'Delete view',
567
+ confirmText: t('console.objectView.delete') || 'Delete',
568
+ cancelText: t('console.objectView.cancel') || 'Cancel',
569
+ });
570
+ if (!confirmed)
571
+ return;
572
+ try {
573
+ await dataSource.delete('sys_view', vid);
574
+ // If we deleted the active view, fall back to the first remaining view.
575
+ if (vid === activeViewId) {
576
+ const fallback = views.find((v) => v.id !== vid);
577
+ if (fallback)
578
+ navigate(viewId ? `../${fallback.id}` : `view/${fallback.id}`, viewId ? { relative: 'path' } : undefined);
579
+ }
580
+ setRefreshKey(k => k + 1);
581
+ }
582
+ catch (err) {
583
+ console.error('[ViewTabBar] Failed to delete view:', err);
584
+ toast.error('Failed to delete view');
585
+ }
586
+ }, [dataSource, isSavedView, activeViewId, views, viewId, navigate, t, confirmHandler]);
587
+ const handleDuplicateView = useCallback(async (vid) => {
588
+ if (!dataSource?.create)
589
+ return;
590
+ const source = views.find((v) => v.id === vid);
591
+ if (!source)
592
+ return;
593
+ try {
594
+ const { id: _omit, created_at, updated_at, ...rest } = source;
595
+ const newId = `view_${Date.now()}`;
596
+ const payload = {
597
+ ...rest,
598
+ objectName,
599
+ id: newId,
600
+ label: `${source.label || vid} (Copy)`,
601
+ isDefault: false,
602
+ isPinned: false,
603
+ };
604
+ await dataSource.create('sys_view', payload);
605
+ setRefreshKey(k => k + 1);
606
+ // Auto-activate the duplicate (Airtable parity).
607
+ if (viewId) {
608
+ navigate(`../${newId}`, { relative: 'path' });
609
+ }
610
+ else {
611
+ navigate(`view/${newId}`);
612
+ }
613
+ }
614
+ catch (err) {
615
+ console.error('[ViewTabBar] Failed to duplicate view:', err);
616
+ toast.error('Failed to duplicate view');
617
+ }
618
+ }, [dataSource, views, objectName, navigate, viewId]);
619
+ const handlePinView = useCallback(async (vid, pinned) => {
620
+ if (!dataSource?.update)
621
+ return;
622
+ if (!isSavedView(vid)) {
623
+ toast.error(t('console.objectView.cannotEditMetaView') || 'Built-in views cannot be pinned.');
624
+ return;
625
+ }
626
+ try {
627
+ await dataSource.update('sys_view', vid, { isPinned: pinned });
628
+ setRefreshKey(k => k + 1);
629
+ }
630
+ catch (err) {
631
+ console.error('[ViewTabBar] Failed to pin view:', err);
632
+ }
633
+ }, [dataSource, isSavedView, t]);
634
+ const handleSetDefaultView = useCallback(async (vid) => {
635
+ if (!dataSource?.update)
636
+ return;
637
+ if (!isSavedView(vid)) {
638
+ toast.error(t('console.objectView.cannotEditMetaView')
639
+ || 'System view — duplicate it to mark a default.');
640
+ return;
641
+ }
642
+ try {
643
+ // Clear `isDefault` on all other saved views, then set this one.
644
+ const updates = savedViews
645
+ .filter((sv) => (sv.id || sv._id) !== vid && sv.isDefault)
646
+ .map((sv) => dataSource.update('sys_view', sv.id || sv._id, { isDefault: false }));
647
+ updates.push(dataSource.update('sys_view', vid, { isDefault: true }));
648
+ await Promise.all(updates);
649
+ setRefreshKey(k => k + 1);
650
+ }
651
+ catch (err) {
652
+ console.error('[ViewTabBar] Failed to set default view:', err);
653
+ toast.error('Failed to set default view');
654
+ }
655
+ }, [dataSource, savedViews, isSavedView, t]);
656
+ const handleReorderViews = useCallback(async (orderedIds) => {
657
+ // Persist order for ALL views (incl. metadata) in localStorage so the
658
+ // UI immediately reflects the new ordering, including reorderings
659
+ // that involve metadata-only views.
660
+ try {
661
+ if (typeof window !== 'undefined') {
662
+ window.localStorage?.setItem(`viewOrder:${objectName}`, JSON.stringify(orderedIds));
663
+ }
664
+ }
665
+ catch { /* ignore */ }
666
+ // Best-effort: also persist `sortOrder` on each saved view so other
667
+ // sessions / users can pick up the order from the backend.
668
+ if (dataSource?.update) {
669
+ const savedIdSet = new Set(savedViews.map((sv) => sv.id || sv._id));
670
+ const updates = orderedIds
671
+ .filter(id => savedIdSet.has(id))
672
+ .map((id, idx) => dataSource.update('sys_view', id, { sortOrder: idx }));
673
+ try {
674
+ await Promise.all(updates);
675
+ }
676
+ catch (err) {
677
+ console.error('[ViewTabBar] Failed to reorder views:', err);
678
+ }
679
+ }
680
+ setRefreshKey(k => k + 1);
681
+ }, [dataSource, savedViews, objectName]);
682
+ const handleConfigView = useCallback((vid) => {
683
+ // System (metadata-defined) views are read-only — opening the
684
+ // ViewConfigPanel against one would let the user save changes that
685
+ // never persist. Steer them to "Duplicate view" instead.
686
+ if (!isSavedView(vid)) {
687
+ toast.error(t('console.objectView.cannotEditMetaView')
688
+ || 'System view — duplicate it to make changes.');
689
+ return;
690
+ }
691
+ if (vid !== activeViewId)
692
+ handleViewChange(vid);
693
+ setViewConfigPanelMode('edit');
694
+ setShowViewConfigPanel(true);
695
+ }, [activeViewId, handleViewChange, isSavedView, t]);
696
+ const handleAddView = useCallback(() => {
697
+ setShowCreateViewDialog(true);
698
+ }, []);
699
+ // Action system for toolbar operations — refreshKey moved up (declared earlier).
700
+ const actions = useObjectActions({
701
+ objectName: objectDef.name,
702
+ objectLabel: objectDef.label,
703
+ dataSource,
704
+ onEdit,
705
+ onRefresh: () => setRefreshKey(k => k + 1),
706
+ });
707
+ // ─── ActionProvider handlers for schema-driven toolbar actions ──────
708
+ const currentUser = user
709
+ ? { id: user.id, name: user.name, avatar: user.image }
710
+ : FALLBACK_USER;
369
711
  const toastHandler = useCallback((message, options) => {
370
712
  if (options?.type === 'error')
371
713
  toast.error(message);
@@ -520,16 +862,16 @@ export function ObjectView({ dataSource, objects, onEdit }) {
520
862
  window.open(`${basePath}/record/${encodeURIComponent(String(recordId))}`, '_blank');
521
863
  return;
522
864
  }
523
- // page / view mode — navigate to record detail page
524
- // Handles action === 'view' (from useNavigationOverlay page mode) and
525
- // default fallthrough for any unrecognised action
526
- if (action === 'view' || !action || action === 'page') {
527
- if (viewId) {
528
- navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path' });
529
- }
530
- else {
531
- navigate(`record/${encodeURIComponent(String(recordId))}`);
532
- }
865
+ // Default: navigate to record detail page.
866
+ // `action` may be 'view' / 'page' / undefined, OR a custom view name
867
+ // forwarded from `navigation.view` (e.g. 'detail_form'). The view
868
+ // variant is resolved by RecordDetailView from its own config, so
869
+ // any non-`new_window` action lands on the record detail route.
870
+ if (viewId) {
871
+ navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path' });
872
+ }
873
+ else {
874
+ navigate(`record/${encodeURIComponent(String(recordId))}`);
533
875
  }
534
876
  }, [navigate, viewId]);
535
877
  const navOverlay = useNavigationOverlay({
@@ -670,7 +1012,13 @@ export function ObjectView({ dataSource, objects, onEdit }) {
670
1012
  center: viewDef.map?.center,
671
1013
  },
672
1014
  gallery: {
673
- imageField: viewDef.gallery?.imageField || 'image',
1015
+ // Spread the full view-defined gallery first so spec
1016
+ // fields (cardSize, visibleFields, coverField, coverFit)
1017
+ // make it through; then layer legacy field aliases that
1018
+ // ObjectGallery still consults.
1019
+ ...(viewDef.gallery || {}),
1020
+ imageField: viewDef.gallery?.imageField || viewDef.gallery?.coverField || 'image',
1021
+ coverField: viewDef.gallery?.coverField || viewDef.gallery?.imageField,
674
1022
  titleField: viewDef.gallery?.titleField || objectDef.titleField || 'name',
675
1023
  subtitleField: viewDef.gallery?.subtitleField,
676
1024
  },
@@ -738,13 +1086,36 @@ export function ObjectView({ dataSource, objects, onEdit }) {
738
1086
  actions: objectDef.actions,
739
1087
  size: 'sm',
740
1088
  variant: 'outline',
741
- } })), isAdmin && (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { size: "sm", variant: "ghost", className: "shadow-none h-8 sm:h-9 px-2", title: t('console.objectView.designTools'), children: _jsx(Wrench, { className: "h-4 w-4" }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-48", children: [_jsxs(DropdownMenuItem, { onClick: toggleDebug, children: [_jsx(MetadataToggle, { open: showDebug, onToggle: toggleDebug, className: "hidden" }), t('console.objectView.metadataInspector')] }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: () => { setViewConfigPanelMode('edit'); setShowViewConfigPanel(prev => !prev); }, children: [_jsx(Settings2, { className: "h-4 w-4 mr-2" }), t('console.objectView.editView')] }), _jsxs(DropdownMenuItem, { onClick: () => { setViewConfigPanelMode('create'); setShowViewConfigPanel(true); }, children: [_jsx(Plus, { className: "h-4 w-4 mr-2" }), t('console.objectView.addView')] })] })] }))] })] }), views.length > 1 && (_jsx("div", { className: "border-b px-3 sm:px-4 bg-background overflow-x-auto shrink-0", children: _jsx(ViewTabBar, { views: views.map((view) => ({
1089
+ } }))] })] }), views.length >= 1 && (() => {
1090
+ const viewTabItems = views.map((view) => {
1091
+ const saved = savedViews.find((sv) => (sv.id || sv._id) === view.id);
1092
+ // System views (loaded from objectDef.listViews / metadata) are
1093
+ // *read-only*. Only sys_view-backed records can be mutated by
1094
+ // the user; admins must duplicate a system view to customize it.
1095
+ const isSystem = !saved;
1096
+ return {
742
1097
  id: view.id,
743
1098
  label: view.label,
744
1099
  type: view.type,
745
1100
  hasActiveFilters: Array.isArray(view.filter) && view.filter.length > 0,
746
1101
  hasActiveSort: Array.isArray(view.sort) && view.sort.length > 0,
747
- })), activeViewId: activeViewId, onViewChange: handleViewChange, viewTypeIcons: VIEW_TYPE_ICONS, config: { reorderable: false } }) })), _jsxs("div", { className: "flex-1 overflow-hidden relative flex flex-row", children: [navOverlay.mode === 'split' && navOverlay.isOpen ? (_jsx(NavigationOverlay, { ...navOverlay, setIsOpen: (open) => { if (!open)
1102
+ isDefault: !!(saved?.isDefault ?? view.isDefault),
1103
+ isPinned: !!(saved?.isPinned ?? view.isPinned),
1104
+ visibility: saved?.visibility ?? view.visibility,
1105
+ readonly: isSystem,
1106
+ readonlyReason: isSystem
1107
+ ? (t('console.objectView.systemViewReadonly')
1108
+ || 'System view defined in code — duplicate to customize.')
1109
+ : undefined,
1110
+ };
1111
+ });
1112
+ return (_jsxs("div", { className: "border-b px-3 sm:px-4 bg-background overflow-x-auto shrink-0", children: [_jsx(ViewTabBar, { views: viewTabItems, activeViewId: activeViewId, onViewChange: handleViewChange, viewTypeIcons: VIEW_TYPE_ICONS, config: {
1113
+ reorderable: false,
1114
+ showAddButton: isAdmin,
1115
+ showPinnedSection: true,
1116
+ showVisibilityGroups: true,
1117
+ }, onAddView: isAdmin ? handleAddView : undefined, onRenameView: isAdmin ? handleRenameView : undefined, onDeleteView: isAdmin ? handleDeleteView : undefined, onDuplicateView: isAdmin ? handleDuplicateView : undefined, onPinView: isAdmin ? handlePinView : undefined, onSetDefaultView: isAdmin ? handleSetDefaultView : undefined, onConfigView: isAdmin ? handleConfigView : undefined, onManageViews: isAdmin ? () => setManageViewsOpen(true) : undefined }), isAdmin && (_jsx(ManageViewsDialog, { open: manageViewsOpen, onOpenChange: setManageViewsOpen, views: viewTabItems, activeViewId: activeViewId, viewTypeIcons: VIEW_TYPE_ICONS, onRename: handleRenameView, onDelete: handleDeleteView, onDuplicate: handleDuplicateView, onSetDefault: handleSetDefaultView, onSetPinned: handlePinView, onReorder: handleReorderViews, onAddView: handleAddView, onConfigView: handleConfigView }))] }));
1118
+ })(), _jsxs("div", { className: "flex-1 overflow-hidden relative flex flex-row", children: [navOverlay.mode === 'split' && navOverlay.isOpen ? (_jsx(NavigationOverlay, { ...navOverlay, setIsOpen: (open) => { if (!open)
748
1119
  handleDrawerClose(); }, title: objectLabel(objectDef), mainContent: _jsxs("div", { className: "flex-1 min-w-0 relative h-full flex flex-col", children: [_jsx("div", { className: "flex-1 relative overflow-auto p-3 sm:p-4", children: _jsx(PluginObjectView, { schema: objectViewSchema, dataSource: dataSource, views: mergedViews, activeViewId: activeViewId, onViewChange: handleViewChange, onEdit: (record) => onEdit?.(record), onRowClick: (record) => {
749
1120
  navOverlay.handleClick(record);
750
1121
  }, renderListView: renderListView, onCreateView: handleCreateView, onViewAction: handleViewAction }) }), typeof recordCount === 'number' && (_jsx("div", { "data-testid": "record-count-footer", className: "border-t px-3 sm:px-4 py-1.5 text-xs text-muted-foreground bg-muted/5 shrink-0", children: t('console.objectView.recordCount', { count: recordCount }) }))] }), children: (record) => {
@@ -755,7 +1126,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
755
1126
  }, renderListView: renderListView, onCreateView: handleCreateView, onViewAction: handleViewAction }) }) })), _jsx(MetadataPanel, { open: showDebug && isAdmin, sections: [
756
1127
  { title: 'View Configuration', data: activeView },
757
1128
  { title: 'Object Definition', data: objectDef },
758
- ] }), _jsx("div", { "data-testid": "view-config-panel-wrapper", className: `transition-[max-width,opacity] duration-300 ease-in-out overflow-hidden ${showViewConfigPanel && isAdmin ? 'max-w-[280px] opacity-100' : 'max-w-0 opacity-0'}`, children: _jsx(ViewConfigPanel, { open: showViewConfigPanel && isAdmin, onClose: () => { setShowViewConfigPanel(false); setViewConfigPanelMode('edit'); }, mode: viewConfigPanelMode, activeView: activeView, objectDef: objectDef, recordCount: recordCount, onSave: handleViewConfigSave, onViewUpdate: handleViewUpdate, onCreate: handleViewCreate }) })] }), navOverlay.mode !== 'split' && (_jsx(NavigationOverlay, { ...navOverlay, setIsOpen: (open) => { if (!open)
1129
+ ] }), _jsx("div", { "data-testid": "view-config-panel-wrapper", className: `transition-[max-width,opacity] duration-300 ease-in-out overflow-hidden ${showViewConfigPanel && isAdmin ? 'max-w-[280px] opacity-100' : 'max-w-0 opacity-0'}`, children: _jsx(ViewConfigPanel, { open: showViewConfigPanel && isAdmin, onClose: () => { setShowViewConfigPanel(false); setViewConfigPanelMode('edit'); }, mode: viewConfigPanelMode, activeView: activeView, objectDef: objectDef, recordCount: recordCount, onSave: handleViewConfigSave, onViewUpdate: handleViewUpdate, onCreate: handleViewCreate }) }), _jsx(CreateViewDialog, { open: showCreateViewDialog && isAdmin, onOpenChange: setShowCreateViewDialog, existingLabels: views.map((v) => v.label).filter(Boolean), objectDef: objectDef, onCreate: (cfg) => handleViewCreate(cfg) })] }), navOverlay.mode !== 'split' && (_jsx(NavigationOverlay, { ...navOverlay, setIsOpen: (open) => { if (!open)
759
1130
  handleDrawerClose(); }, title: objectDef.label, className: navOverlay.mode === 'drawer' ? 'w-[90vw] sm:max-w-2xl p-0 overflow-hidden' : undefined, children: (record) => {
760
1131
  const recordId = (record.id || record._id);
761
1132
  return (_jsx(DrawerDetailContent, { objectDef: objectDef, recordId: recordId, dataSource: dataSource, onEdit: onEdit }));