@object-ui/app-shell 3.3.2 → 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.
@@ -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,151 @@ 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
+ 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;
369
700
  const toastHandler = useCallback((message, options) => {
370
701
  if (options?.type === 'error')
371
702
  toast.error(message);
@@ -520,16 +851,16 @@ export function ObjectView({ dataSource, objects, onEdit }) {
520
851
  window.open(`${basePath}/record/${encodeURIComponent(String(recordId))}`, '_blank');
521
852
  return;
522
853
  }
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
- }
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))}`);
533
864
  }
534
865
  }, [navigate, viewId]);
535
866
  const navOverlay = useNavigationOverlay({
@@ -738,13 +1069,27 @@ export function ObjectView({ dataSource, objects, onEdit }) {
738
1069
  actions: objectDef.actions,
739
1070
  size: 'sm',
740
1071
  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) => ({
1072
+ } }))] })] }), views.length >= 1 && (() => {
1073
+ const viewTabItems = views.map((view) => {
1074
+ const saved = savedViews.find((sv) => (sv.id || sv._id) === view.id);
1075
+ return {
742
1076
  id: view.id,
743
1077
  label: view.label,
744
1078
  type: view.type,
745
1079
  hasActiveFilters: Array.isArray(view.filter) && view.filter.length > 0,
746
1080
  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)
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)
748
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) => {
749
1094
  navOverlay.handleClick(record);
750
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) => {
@@ -755,7 +1100,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
755
1100
  }, renderListView: renderListView, onCreateView: handleCreateView, onViewAction: handleViewAction }) }) })), _jsx(MetadataPanel, { open: showDebug && isAdmin, sections: [
756
1101
  { title: 'View Configuration', data: activeView },
757
1102
  { 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)
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)
759
1104
  handleDrawerClose(); }, title: objectDef.label, className: navOverlay.mode === 'drawer' ? 'w-[90vw] sm:max-w-2xl p-0 overflow-hidden' : undefined, children: (record) => {
760
1105
  const recordId = (record.id || record._id);
761
1106
  return (_jsx(DrawerDetailContent, { objectDef: objectDef, recordId: recordId, dataSource: dataSource, onEdit: onEdit }));
@@ -1,10 +1,12 @@
1
1
  /**
2
- * ViewConfigPanel — Schema-Driven
2
+ * ViewConfigPanel — Airtable-style simplified configuration panel.
3
3
  *
4
- * Airtable-style right-side configuration panel for inline view editing.
5
- * Migrated to Schema-Driven architecture using ConfigPanelRenderer +
6
- * useConfigDraft, replacing ~1600 lines of imperative code with a
7
- * declarative schema factory.
4
+ * Renders a focused, opinionated set of sections (Page / Data /
5
+ * Appearance / Toolbar / User actions / Navigation / Advanced) — matching
6
+ * Airtable's Interface designer right rail. Power-user sections (records,
7
+ * sharing, accessibility) are intentionally excluded to keep the panel
8
+ * approachable; they can be re-introduced later as a dedicated "advanced"
9
+ * settings dialog if needed.
8
10
  *
9
11
  * All changes are buffered in a local draft state. Clicking Save commits
10
12
  * the draft via onSave; Discard resets to the original activeView.
@@ -1,11 +1,13 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /**
3
- * ViewConfigPanel — Schema-Driven
3
+ * ViewConfigPanel — Airtable-style simplified configuration panel.
4
4
  *
5
- * Airtable-style right-side configuration panel for inline view editing.
6
- * Migrated to Schema-Driven architecture using ConfigPanelRenderer +
7
- * useConfigDraft, replacing ~1600 lines of imperative code with a
8
- * declarative schema factory.
5
+ * Renders a focused, opinionated set of sections (Page / Data /
6
+ * Appearance / Toolbar / User actions / Navigation / Advanced) — matching
7
+ * Airtable's Interface designer right rail. Power-user sections (records,
8
+ * sharing, accessibility) are intentionally excluded to keep the panel
9
+ * approachable; they can be re-introduced later as a dedicated "advanced"
10
+ * settings dialog if needed.
9
11
  *
10
12
  * All changes are buffered in a local draft state. Clicking Save commits
11
13
  * the draft via onSave; Discard resets to the original activeView.
@@ -51,7 +53,7 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje
51
53
  // Bridge: filter/sort → builder format
52
54
  const filterGroupValue = useMemo(() => toFilterGroup(draft.filter), [draft.filter]);
53
55
  const sortItemsValue = useMemo(() => toSortItems(draft.sort), [draft.sort]);
54
- // Build schema
56
+ // Build schema — always essentials-only (Airtable-style focused layout).
55
57
  const schema = useMemo(() => buildViewConfigSchema({
56
58
  t,
57
59
  fieldOptions,
@@ -59,6 +61,7 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje
59
61
  updateField,
60
62
  filterGroupValue,
61
63
  sortItemsValue,
64
+ essentialOnly: true,
62
65
  }), [t, fieldOptions, objectDef, updateField, filterGroupValue, sortItemsValue]);
63
66
  // Override breadcrumb with dynamic view type
64
67
  const viewType = draft.type || 'grid';