@object-ui/app-shell 3.3.1 → 4.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.
@@ -9,21 +9,21 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  * - useObjectActions for toolbar create button
10
10
  * - ListView delegation for non-grid view types (kanban, calendar, chart, etc.)
11
11
  */
12
- import { useMemo, useState, useCallback, useEffect } from 'react';
12
+ import { useMemo, useState, useCallback, useEffect, lazy, Suspense } from 'react';
13
13
  import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
14
- import { ObjectChart } from '@object-ui/plugin-charts';
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';
18
- // Import plugins for side-effects (registration)
19
- import '@object-ui/plugin-grid';
20
- import '@object-ui/plugin-kanban';
21
- import '@object-ui/plugin-calendar';
22
- import { Button, Empty, EmptyTitle, EmptyDescription, NavigationOverlay, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@object-ui/components';
23
- import { Plus, Table as TableIcon, Settings2, Wrench, KanbanSquare, Calendar, LayoutGrid, Activity, GanttChart, MapPin, BarChart3 } from 'lucide-react';
17
+ import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view';
18
+ // Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx
19
+ // uses ComponentRegistry.registerLazy so heavy plugins stay code-split).
20
+ // Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here.
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';
24
23
  import { getIcon } from '../utils/getIcon';
25
- import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector';
24
+ import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
26
25
  import { ViewConfigPanel } from './ViewConfigPanel';
26
+ import { CreateViewDialog } from './CreateViewDialog';
27
27
  import { useObjectActions } from '../hooks/useObjectActions';
28
28
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
29
29
  import { usePermissions } from '@object-ui/permissions';
@@ -200,12 +200,16 @@ export function ObjectView({ dataSource, objects, onEdit }) {
200
200
  const navigate = useNavigate();
201
201
  const { objectName, viewId } = useParams();
202
202
  const [searchParams, setSearchParams] = useSearchParams();
203
- const { showDebug, toggleDebug } = useMetadataInspector();
203
+ const { showDebug } = useMetadataInspector();
204
204
  const { t } = useObjectTranslation();
205
205
  const { objectLabel, objectDescription: objectDesc } = useObjectLabel();
206
206
  // Inline view config panel state (Airtable-style right sidebar)
207
207
  const [showViewConfigPanel, setShowViewConfigPanel] = useState(false);
208
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);
209
213
  // Draft state for view config edits — cached locally, saved on demand
210
214
  const [viewDraft, setViewDraft] = useState(null);
211
215
  const handleViewConfigSave = useCallback((draft) => {
@@ -231,18 +235,56 @@ export function ObjectView({ dataSource, objects, onEdit }) {
231
235
  /** Create a new view via the config panel */
232
236
  const handleViewCreate = useCallback(async (config) => {
233
237
  try {
238
+ let createdId;
234
239
  if (dataSource?.create) {
235
- const payload = { objectName, ...config };
236
- 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;
237
268
  }
238
269
  setShowViewConfigPanel(false);
239
270
  setViewConfigPanelMode('edit');
240
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
+ }
241
283
  }
242
284
  catch (err) {
243
285
  console.error('[ViewConfigPanel] Failed to create view:', err);
244
286
  }
245
- }, [dataSource, objectName]);
287
+ }, [dataSource, objectName, objects, navigate, viewId]);
246
288
  // Record count tracking for footer
247
289
  const [recordCount, setRecordCount] = useState(undefined);
248
290
  // Admin users automatically get design tools (no toggle needed)
@@ -254,31 +296,66 @@ export function ObjectView({ dataSource, objects, onEdit }) {
254
296
  if (!objectDef) {
255
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')] })] }) }));
256
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]);
257
341
  // Resolve Views from objectDef.listViews (camelCase per @objectstack/spec)
258
342
  const views = useMemo(() => {
259
- const definedViews = objectDef.listViews || objectDef.list_views || {};
260
- const viewList = Object.entries(definedViews).map(([key, value]) => ({
261
- id: key,
262
- ...value,
263
- type: value.type || 'grid'
264
- }));
265
- if (viewList.length === 0) {
266
- // Default column resolution priority:
267
- // 1. `compactLayout` (curated primary business fields).
268
- // 2. Business fields only — exclude system-managed identifiers/audit
269
- // columns (id, created_at, updated_at, …) and fields explicitly
270
- // marked hidden/readonly on the schema. First 5 kept for compactness.
271
- const SYSTEM_FIELDS = new Set([
272
- 'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt',
273
- 'deleted_at', 'deletedAt', 'created_by', 'createdBy',
274
- 'updated_by', 'updatedBy', '_version', '_rev',
275
- ]);
276
- 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 = () => {
277
354
  if (Array.isArray(objectDef.compactLayout) && objectDef.compactLayout.length > 0) {
278
- defaultColumns = objectDef.compactLayout.filter((n) => objectDef.fields?.[n]);
355
+ return objectDef.compactLayout.filter((n) => objectDef.fields?.[n]);
279
356
  }
280
- else if (objectDef.fields) {
281
- defaultColumns = Object.entries(objectDef.fields)
357
+ if (objectDef.fields) {
358
+ return Object.entries(objectDef.fields)
282
359
  .filter(([name, f]) => {
283
360
  if (!f)
284
361
  return false;
@@ -291,17 +368,111 @@ export function ObjectView({ dataSource, objects, onEdit }) {
291
368
  .map(([name]) => name)
292
369
  .slice(0, 5);
293
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) {
294
380
  viewList.push({
295
381
  id: 'all',
296
382
  label: t('console.objectView.allRecords'),
297
383
  type: 'grid',
298
- columns: defaultColumns,
384
+ columns: resolveDefaultColumns(),
299
385
  });
300
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
+ });
301
467
  return viewList;
302
- }, [objectDef]);
303
- // Active View State — merge saved draft if available for this view
304
- 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;
305
476
  const baseView = views.find((v) => v.id === activeViewId) || views[0];
306
477
  const activeView = viewDraft && viewDraft.id === baseView?.id
307
478
  ? { ...baseView, ...viewDraft }
@@ -319,8 +490,12 @@ export function ObjectView({ dataSource, objects, onEdit }) {
319
490
  const matchedView = views.find((v) => v.id === newViewId);
320
491
  if (!matchedView)
321
492
  return;
322
- // Auto-close the config panel when switching views
323
- 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
+ }
324
499
  if (viewId) {
325
500
  navigate(`../${matchedView.id}`, { relative: "path" });
326
501
  }
@@ -330,8 +505,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
330
505
  };
331
506
  // ViewSwitcher callbacks — wired to both PluginObjectView instances
332
507
  const handleCreateView = useCallback(() => {
333
- setViewConfigPanelMode('create');
334
- setShowViewConfigPanel(true);
508
+ setShowCreateViewDialog(true);
335
509
  }, []);
336
510
  const handleViewAction = useCallback((actionType, viewType) => {
337
511
  if (actionType === 'settings') {
@@ -342,19 +516,30 @@ export function ObjectView({ dataSource, objects, onEdit }) {
342
516
  setShowViewConfigPanel(true);
343
517
  }
344
518
  }, [views, handleViewChange]);
345
- // Action system for toolbar operations
346
- const [refreshKey, setRefreshKey] = useState(0);
347
- const actions = useObjectActions({
348
- objectName: objectDef.name,
349
- objectLabel: objectDef.label,
350
- dataSource,
351
- onEdit,
352
- onRefresh: () => setRefreshKey(k => k + 1),
353
- });
354
- // ─── ActionProvider handlers for schema-driven toolbar actions ──────
355
- const currentUser = user
356
- ? { id: user.id, name: user.name, avatar: user.image }
357
- : 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.
358
543
  const [confirmState, setConfirmState] = useState({ open: false, message: '' });
359
544
  const [paramState, setParamState] = useState({ open: false, params: [] });
360
545
  const confirmHandler = useCallback((message, options) => {
@@ -367,6 +552,151 @@ export function ObjectView({ dataSource, objects, onEdit }) {
367
552
  setParamState({ open: true, params, resolve });
368
553
  });
369
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
+ try {
638
+ // Clear `isDefault` on all other saved views, then set this one.
639
+ const updates = savedViews
640
+ .filter((sv) => (sv.id || sv._id) !== vid && sv.isDefault)
641
+ .map((sv) => dataSource.update('sys_view', sv.id || sv._id, { isDefault: false }));
642
+ if (isSavedView(vid)) {
643
+ updates.push(dataSource.update('sys_view', vid, { isDefault: true }));
644
+ }
645
+ await Promise.all(updates);
646
+ setRefreshKey(k => k + 1);
647
+ }
648
+ catch (err) {
649
+ console.error('[ViewTabBar] Failed to set default view:', err);
650
+ toast.error('Failed to set default view');
651
+ }
652
+ }, [dataSource, savedViews, isSavedView]);
653
+ const handleReorderViews = useCallback(async (orderedIds) => {
654
+ // Persist order for ALL views (incl. metadata) in localStorage so the
655
+ // UI immediately reflects the new ordering, including reorderings
656
+ // that involve metadata-only views.
657
+ try {
658
+ if (typeof window !== 'undefined') {
659
+ window.localStorage?.setItem(`viewOrder:${objectName}`, JSON.stringify(orderedIds));
660
+ }
661
+ }
662
+ catch { /* ignore */ }
663
+ // Best-effort: also persist `sortOrder` on each saved view so other
664
+ // sessions / users can pick up the order from the backend.
665
+ if (dataSource?.update) {
666
+ const savedIdSet = new Set(savedViews.map((sv) => sv.id || sv._id));
667
+ const updates = orderedIds
668
+ .filter(id => savedIdSet.has(id))
669
+ .map((id, idx) => dataSource.update('sys_view', id, { sortOrder: idx }));
670
+ try {
671
+ await Promise.all(updates);
672
+ }
673
+ catch (err) {
674
+ console.error('[ViewTabBar] Failed to reorder views:', err);
675
+ }
676
+ }
677
+ setRefreshKey(k => k + 1);
678
+ }, [dataSource, savedViews, objectName]);
679
+ const handleConfigView = useCallback((vid) => {
680
+ if (vid !== activeViewId)
681
+ handleViewChange(vid);
682
+ setViewConfigPanelMode('edit');
683
+ setShowViewConfigPanel(true);
684
+ }, [activeViewId, handleViewChange]);
685
+ const handleAddView = useCallback(() => {
686
+ setShowCreateViewDialog(true);
687
+ }, []);
688
+ // Action system for toolbar operations — refreshKey moved up (declared earlier).
689
+ const actions = useObjectActions({
690
+ objectName: objectDef.name,
691
+ objectLabel: objectDef.label,
692
+ dataSource,
693
+ onEdit,
694
+ onRefresh: () => setRefreshKey(k => k + 1),
695
+ });
696
+ // ─── ActionProvider handlers for schema-driven toolbar actions ──────
697
+ const currentUser = user
698
+ ? { id: user.id, name: user.name, avatar: user.image }
699
+ : FALLBACK_USER;
370
700
  const toastHandler = useCallback((message, options) => {
371
701
  if (options?.type === 'error')
372
702
  toast.error(message);
@@ -521,16 +851,16 @@ export function ObjectView({ dataSource, objects, onEdit }) {
521
851
  window.open(`${basePath}/record/${encodeURIComponent(String(recordId))}`, '_blank');
522
852
  return;
523
853
  }
524
- // page / view mode — navigate to record detail page
525
- // Handles action === 'view' (from useNavigationOverlay page mode) and
526
- // default fallthrough for any unrecognised action
527
- if (action === 'view' || !action || action === 'page') {
528
- if (viewId) {
529
- navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path' });
530
- }
531
- else {
532
- navigate(`record/${encodeURIComponent(String(recordId))}`);
533
- }
854
+ // Default: navigate to record detail page.
855
+ // `action` may be 'view' / 'page' / undefined, OR a custom view name
856
+ // forwarded from `navigation.view` (e.g. 'detail_form'). The view
857
+ // variant is resolved by RecordDetailView from its own config, so
858
+ // any non-`new_window` action lands on the record detail route.
859
+ if (viewId) {
860
+ navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path' });
861
+ }
862
+ else {
863
+ navigate(`record/${encodeURIComponent(String(recordId))}`);
534
864
  }
535
865
  }, [navigate, viewId]);
536
866
  const navOverlay = useNavigationOverlay({
@@ -575,17 +905,17 @@ export function ObjectView({ dataSource, objects, onEdit }) {
575
905
  }
576
906
  if (viewDef.type === 'chart') {
577
907
  const chartConfig = viewDef.chart || {};
578
- return (_jsx(ObjectChart, { dataSource: ds, schema: {
579
- type: 'object-chart',
580
- objectName: objectDef.name,
581
- chartType: chartConfig.chartType,
582
- xAxisField: chartConfig.xAxisField,
583
- yAxisFields: chartConfig.yAxisFields,
584
- aggregation: chartConfig.aggregation,
585
- series: chartConfig.series,
586
- config: chartConfig.config,
587
- filter: chartConfig.filter,
588
- } }, key));
908
+ return (_jsx(Suspense, { fallback: _jsx("div", { className: "p-4 text-sm text-muted-foreground", children: "Loading chart\u2026" }), children: _jsx(ObjectChart, { dataSource: ds, schema: {
909
+ type: 'object-chart',
910
+ objectName: objectDef.name,
911
+ chartType: chartConfig.chartType,
912
+ xAxisField: chartConfig.xAxisField,
913
+ yAxisFields: chartConfig.yAxisFields,
914
+ aggregation: chartConfig.aggregation,
915
+ series: chartConfig.series,
916
+ config: chartConfig.config,
917
+ filter: chartConfig.filter,
918
+ } }) }, key));
589
919
  }
590
920
  const fullSchema = {
591
921
  ...listSchema,
@@ -739,13 +1069,27 @@ export function ObjectView({ dataSource, objects, onEdit }) {
739
1069
  actions: objectDef.actions,
740
1070
  size: 'sm',
741
1071
  variant: 'outline',
742
- } })), 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) => ({
1072
+ } }))] })] }), views.length >= 1 && (() => {
1073
+ const viewTabItems = views.map((view) => {
1074
+ const saved = savedViews.find((sv) => (sv.id || sv._id) === view.id);
1075
+ return {
743
1076
  id: view.id,
744
1077
  label: view.label,
745
1078
  type: view.type,
746
1079
  hasActiveFilters: Array.isArray(view.filter) && view.filter.length > 0,
747
1080
  hasActiveSort: Array.isArray(view.sort) && view.sort.length > 0,
748
- })), 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)
1081
+ isDefault: !!(saved?.isDefault ?? view.isDefault),
1082
+ isPinned: !!(saved?.isPinned ?? view.isPinned),
1083
+ visibility: saved?.visibility ?? view.visibility,
1084
+ };
1085
+ });
1086
+ 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: {
1087
+ reorderable: false,
1088
+ showAddButton: isAdmin,
1089
+ showPinnedSection: true,
1090
+ showVisibilityGroups: true,
1091
+ }, 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 }))] }));
1092
+ })(), _jsxs("div", { className: "flex-1 overflow-hidden relative flex flex-row", children: [navOverlay.mode === 'split' && navOverlay.isOpen ? (_jsx(NavigationOverlay, { ...navOverlay, setIsOpen: (open) => { if (!open)
749
1093
  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) => {
750
1094
  navOverlay.handleClick(record);
751
1095
  }, 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) => {
@@ -756,7 +1100,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
756
1100
  }, renderListView: renderListView, onCreateView: handleCreateView, onViewAction: handleViewAction }) }) })), _jsx(MetadataPanel, { open: showDebug && isAdmin, sections: [
757
1101
  { title: 'View Configuration', data: activeView },
758
1102
  { title: 'Object Definition', data: objectDef },
759
- ] }), _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)
1103
+ ] }), _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)
760
1104
  handleDrawerClose(); }, title: objectDef.label, className: navOverlay.mode === 'drawer' ? 'w-[90vw] sm:max-w-2xl p-0 overflow-hidden' : undefined, children: (record) => {
761
1105
  const recordId = (record.id || record._id);
762
1106
  return (_jsx(DrawerDetailContent, { objectDef: objectDef, recordId: recordId, dataSource: dataSource, onEdit: onEdit }));