@object-ui/app-shell 11.4.0 → 11.5.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/README.md +9 -0
  3. package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
  4. package/dist/console/AppContent.js +145 -26
  5. package/dist/console/ConsoleShell.js +8 -1
  6. package/dist/context/CommandPaletteProvider.js +2 -1
  7. package/dist/hooks/useObjectActions.js +16 -4
  8. package/dist/layout/AppHeader.js +13 -5
  9. package/dist/layout/AppSidebar.js +10 -4
  10. package/dist/observability/sentry.d.ts +5 -0
  11. package/dist/observability/sentry.js +6 -1
  12. package/dist/preview/DraftChangesPanel.d.ts +29 -1
  13. package/dist/preview/DraftChangesPanel.js +141 -14
  14. package/dist/urlParams.d.ts +68 -0
  15. package/dist/urlParams.js +76 -0
  16. package/dist/utils/appRoute.d.ts +15 -0
  17. package/dist/utils/appRoute.js +22 -0
  18. package/dist/utils/index.d.ts +1 -1
  19. package/dist/utils/index.js +1 -1
  20. package/dist/utils/pageTabsUrlSync.d.ts +32 -0
  21. package/dist/utils/pageTabsUrlSync.js +43 -0
  22. package/dist/utils/recordFormNavigation.d.ts +40 -0
  23. package/dist/utils/recordFormNavigation.js +30 -0
  24. package/dist/views/InterfaceListPage.d.ts +1 -0
  25. package/dist/views/InterfaceListPage.js +1 -1
  26. package/dist/views/ObjectDataPage.d.ts +29 -0
  27. package/dist/views/ObjectDataPage.js +227 -0
  28. package/dist/views/ObjectView.js +4 -3
  29. package/dist/views/RecordDetailView.js +61 -20
  30. package/dist/views/RelatedRecordActionsBridge.d.ts +10 -1
  31. package/dist/views/RelatedRecordActionsBridge.js +49 -16
  32. package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
  33. package/dist/views/metadata-admin/i18n.js +214 -4
  34. package/dist/views/metadata-admin/inspectors/AppNavInspector.d.ts +11 -4
  35. package/dist/views/metadata-admin/inspectors/AppNavInspector.js +141 -7
  36. package/dist/views/metadata-admin/inspectors/FlowReferenceField.d.ts +14 -0
  37. package/dist/views/metadata-admin/inspectors/FlowReferenceField.js +76 -5
  38. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +35 -19
  39. package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +8 -1
  40. package/dist/views/metadata-admin/inspectors/flow-node-config.js +3 -2
  41. package/dist/views/metadata-admin/inspectors/nav-target.d.ts +52 -0
  42. package/dist/views/metadata-admin/inspectors/nav-target.js +149 -0
  43. package/dist/views/metadata-admin/nav-selection.d.ts +20 -0
  44. package/dist/views/metadata-admin/nav-selection.js +81 -0
  45. package/dist/views/metadata-admin/previews/AppNavCanvas.js +9 -1
  46. package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
  47. package/dist/views/studio-design/BuilderLanding.d.ts +1 -1
  48. package/dist/views/studio-design/BuilderLanding.js +12 -19
  49. package/dist/views/studio-design/ObjectFormDesigner.d.ts +5 -3
  50. package/dist/views/studio-design/ObjectFormDesigner.js +17 -12
  51. package/dist/views/studio-design/ObjectSettingsPanel.d.ts +1 -1
  52. package/dist/views/studio-design/ObjectSettingsPanel.js +4 -3
  53. package/dist/views/studio-design/ObjectValidationsPanel.js +6 -4
  54. package/dist/views/studio-design/PackageIdInput.d.ts +31 -0
  55. package/dist/views/studio-design/PackageIdInput.js +40 -0
  56. package/dist/views/studio-design/StudioDesignSurface.d.ts +13 -0
  57. package/dist/views/studio-design/StudioDesignSurface.js +227 -57
  58. package/dist/views/studio-design/packageSurfaces.d.ts +49 -0
  59. package/dist/views/studio-design/packageSurfaces.js +34 -0
  60. package/dist/views/studio-design/packages-io.d.ts +11 -0
  61. package/dist/views/studio-design/packages-io.js +12 -0
  62. package/dist/views/studio-design/skeletons.d.ts +16 -0
  63. package/dist/views/studio-design/skeletons.js +51 -0
  64. package/package.json +38 -38
@@ -20,7 +20,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
20
20
  */
21
21
  import { useLocation, useParams, Link, useNavigate } from 'react-router-dom';
22
22
  import { Button, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuGroup, Avatar, AvatarImage, AvatarFallback, cn, } from '@object-ui/components';
23
- import { Search, HelpCircle, ChevronDown, Check, Lock, LogOut, Plus, Layers, Bot, User, BookOpen, ExternalLink, Keyboard, } from 'lucide-react';
23
+ import { Search, HelpCircle, ChevronDown, Check, Lock, LogOut, Plus, Layers, Bot, User, BookOpen, ExternalLink, Keyboard, Hammer, } from 'lucide-react';
24
24
  import { useState, useEffect, useCallback, useRef } from 'react';
25
25
  import { useOffline } from '@object-ui/react';
26
26
  import { PresenceAvatars, useTenantPresence } from '@object-ui/collaboration';
@@ -32,14 +32,15 @@ import { InboxPopover } from './InboxPopover';
32
32
  import { AppSwitcher } from './AppSwitcher';
33
33
  import { useAdapter } from '../providers/AdapterProvider';
34
34
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
35
- import { useAuth, getUserInitials } from '@object-ui/auth';
35
+ import { useAuth, getUserInitials, useIsWorkspaceAdmin } from '@object-ui/auth';
36
36
  import { useMetadata } from '../providers/MetadataProvider';
37
- import { resolveI18nLabel, preferLocal, matchAppBySegment, appRouteSegment } from '../utils';
37
+ import { resolveI18nLabel, preferLocal, matchAppBySegment, appRouteSegment, appStudioDesignPath } from '../utils';
38
38
  import { getIcon } from '../utils/getIcon';
39
39
  import { useMobileViewSwitcher } from './MobileViewSwitcherContext';
40
40
  import { useNavigationContext } from '../context/NavigationContext';
41
41
  import { useCommandPalette } from '../context/CommandPaletteProvider';
42
42
  import { useUrlOverlay } from '../hooks/useUrlOverlay';
43
+ import { KEYBOARD_SHORTCUTS_PARAM } from '../urlParams';
43
44
  import { useAiSurfaceEnabled } from '../hooks/useAiSurface';
44
45
  import { getProductName } from '../runtime-config';
45
46
  import { LocalizedSidebarTrigger } from './LocalizedSidebarTrigger';
@@ -65,13 +66,16 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
65
66
  const { openCommandPalette } = useCommandPalette();
66
67
  // Click-reachable entry for the keyboard-shortcuts dialog (was `?`-key only).
67
68
  // Shares the `?shortcuts=1` URL param with KeyboardShortcutsDialog (C2/C3).
68
- const { openOverlay: openShortcuts } = useUrlOverlay('shortcuts');
69
+ const { openOverlay: openShortcuts } = useUrlOverlay(KEYBOARD_SHORTCUTS_PARAM);
69
70
  const { user, signOut, isAuthEnabled, organizations, activeOrganization, isOrganizationsLoading, getAuthConfig, } = useAuth();
70
71
  const dataSource = useAdapter();
71
72
  // Runtime AI gating: hide the top-bar AI entry point when the server serves
72
73
  // no AI (Community Edition) so it can't dead-end on a chat with no agent.
73
74
  // Same signal as the FAB and the `/ai` route guard.
74
75
  const { enabled: aiEnabled } = useAiSurfaceEnabled();
76
+ // Design entry points mutate shared package metadata, so the app → Studio
77
+ // bridge below is admin-only (mirrors the runtime view/page editors).
78
+ const isWorkspaceAdmin = useIsWorkspaceAdmin();
75
79
  const { t } = useObjectTranslation();
76
80
  const { objectLabel, dashboardLabel, pageLabel, reportLabel, viewLabel, appLabel } = useObjectLabel();
77
81
  const { apps: metadataApps, dashboards: metadataDashboards, pages: metadataPages, reports: metadataReports } = useMetadata();
@@ -489,6 +493,10 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
489
493
  const currentAppDocs = currentAppPackageId
490
494
  ? (helpDocs ?? []).filter((d) => d._packageId === currentAppPackageId)
491
495
  : [];
496
+ // App → Studio reverse bridge (ADR-0080): admins jump from the running app
497
+ // to its owning package's design surface. Null when there is nothing to
498
+ // open (non-admin, or no owning package).
499
+ const studioDesignPath = isApp ? appStudioDesignPath(currentApp, isWorkspaceAdmin) : null;
492
500
  const objectSiblings = appObjects.map((o) => ({
493
501
  label: objectLabel(o),
494
502
  href: `${baseHref}/${o.name}`,
@@ -570,7 +578,7 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
570
578
  if (!isActive)
571
579
  mobileSwitcher.onChange(v.id);
572
580
  }, className: "gap-2", children: [v.icon ? (_jsx("span", { className: "shrink-0 text-muted-foreground [&>svg]:h-4 [&>svg]:w-4", children: v.icon })) : null, _jsx("span", { className: "flex-1 truncate", children: v.label }), v.locked ? (_jsx(Lock, { className: "h-3 w-3 shrink-0 text-muted-foreground", "aria-hidden": true })) : null, isActive ? (_jsx(Check, { className: "h-4 w-4 shrink-0 text-foreground", "aria-hidden": true })) : null] }, v.id));
573
- }) })] })) : (_jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: mobileSwitcher.triggerLabel ?? mobileSwitcher.views[0].label }))) : (_jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: lastSegmentLabel }))] }))] }), _jsxs("div", { className: "flex items-center gap-0.5 sm:gap-1 shrink-0 [&>*+*[data-topbar-group]]:ml-1 [&>[data-topbar-group]+[data-topbar-group]]:border-l [&>[data-topbar-group]+[data-topbar-group]]:border-border/60 [&>[data-topbar-group]+[data-topbar-group]]:pl-1 sm:[&>[data-topbar-group]+[data-topbar-group]]:pl-2 sm:[&>[data-topbar-group]+[data-topbar-group]]:ml-2", children: [!isOnline && (_jsxs("div", { className: "flex items-center gap-1 px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-medium", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-yellow-500 animate-pulse" }), t('topbar.offline', { defaultValue: 'Offline' })] })), isApp && connectionState && _jsx(ConnectionStatus, { state: connectionState }), isApp && activeUsers.length > 0 && (_jsx("div", { className: "hidden md:flex items-center shrink-0", title: t('topbar.usersOnline', { defaultValue: 'Users currently online' }), children: _jsx(PresenceAvatars, { users: activeUsers, size: "sm", maxVisible: 3, showStatus: true }) })), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 sm:gap-1 shrink-0", children: [_jsxs("button", { type: "button", "data-testid": "action:command-palette:open", "aria-label": t('console.search', { defaultValue: 'Search…' }), "aria-keyshortcuts": "Meta+K Control+K", onClick: openCommandPalette, className: "hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors", children: [_jsx(Search, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 text-left text-xs", children: t('console.search', { defaultValue: 'Search…' }) }), _jsxs("kbd", { className: "pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground", children: [_jsx("span", { className: "text-xs", children: "\u2318" }), "K"] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden h-8 w-8 shrink-0", "data-testid": "action:command-palette:open-mobile", onClick: openCommandPalette, "aria-label": t('console.search', { defaultValue: 'Search…' }), children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx(InboxPopover, { notifications: notifications, unreadCount: unreadCount, pendingApprovalsCount: pendingApprovalsCount, activities: activeActivities, onMarkAllRead: markAllRead, onMarkRead: markNotificationRead }), aiEnabled && (_jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0", asChild: true, "aria-label": t('topbar.aiAssistant', { defaultValue: 'AI Assistant' }), children: _jsx(Link, { to: "/ai", children: _jsx(Bot, { className: "h-4 w-4" }) }) })), _jsxs(DropdownMenu, { onOpenChange: (open) => { if (open)
581
+ }) })] })) : (_jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: mobileSwitcher.triggerLabel ?? mobileSwitcher.views[0].label }))) : (_jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: lastSegmentLabel }))] }))] }), _jsxs("div", { className: "flex items-center gap-0.5 sm:gap-1 shrink-0 [&>*+*[data-topbar-group]]:ml-1 [&>[data-topbar-group]+[data-topbar-group]]:border-l [&>[data-topbar-group]+[data-topbar-group]]:border-border/60 [&>[data-topbar-group]+[data-topbar-group]]:pl-1 sm:[&>[data-topbar-group]+[data-topbar-group]]:pl-2 sm:[&>[data-topbar-group]+[data-topbar-group]]:ml-2", children: [!isOnline && (_jsxs("div", { className: "flex items-center gap-1 px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-medium", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-yellow-500 animate-pulse" }), t('topbar.offline', { defaultValue: 'Offline' })] })), isApp && connectionState && _jsx(ConnectionStatus, { state: connectionState }), isApp && activeUsers.length > 0 && (_jsx("div", { className: "hidden md:flex items-center shrink-0", title: t('topbar.usersOnline', { defaultValue: 'Users currently online' }), children: _jsx(PresenceAvatars, { users: activeUsers, size: "sm", maxVisible: 3, showStatus: true }) })), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 sm:gap-1 shrink-0", children: [_jsxs("button", { type: "button", "data-testid": "action:command-palette:open", "aria-label": t('console.search', { defaultValue: 'Search…' }), "aria-keyshortcuts": "Meta+K Control+K", onClick: openCommandPalette, className: "hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors", children: [_jsx(Search, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 text-left text-xs", children: t('console.search', { defaultValue: 'Search…' }) }), _jsxs("kbd", { className: "pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground", children: [_jsx("span", { className: "text-xs", children: "\u2318" }), "K"] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden h-8 w-8 shrink-0", "data-testid": "action:command-palette:open-mobile", onClick: openCommandPalette, "aria-label": t('console.search', { defaultValue: 'Search…' }), children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx(InboxPopover, { notifications: notifications, unreadCount: unreadCount, pendingApprovalsCount: pendingApprovalsCount, activities: activeActivities, onMarkAllRead: markAllRead, onMarkRead: markNotificationRead }), studioDesignPath && (_jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0", asChild: true, "data-testid": "app-design-in-studio-button", "aria-label": t('topbar.designInStudio', { defaultValue: 'Design in Studio' }), title: t('topbar.designInStudio', { defaultValue: 'Design in Studio' }), children: _jsx(Link, { to: studioDesignPath, children: _jsx(Hammer, { className: "h-4 w-4" }) }) })), aiEnabled && (_jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0", asChild: true, "aria-label": t('topbar.aiAssistant', { defaultValue: 'AI Assistant' }), children: _jsx(Link, { to: "/ai", children: _jsx(Bot, { className: "h-4 w-4" }) }) })), _jsxs(DropdownMenu, { onOpenChange: (open) => { if (open)
574
582
  void loadHelpDocs(); }, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 hidden md:flex shrink-0", "aria-label": t('sidebar.helpTooltip', { defaultValue: 'Help & Documentation' }), children: _jsx(HelpCircle, { className: "h-4 w-4" }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-56 rounded-lg", sideOffset: 4, children: [currentAppDocs.length > 0 && currentAppPackageId ? (_jsxs(DropdownMenuItem, { className: "cursor-pointer", onClick: () => navigate(currentAppDocs.length === 1
575
583
  ? `/apps/${currentAppPackageId}/docs/${currentAppDocs[0].name}`
576
584
  : `/apps/${currentAppPackageId}/docs`), children: [_jsx(BookOpen, { className: "mr-2 h-4 w-4" }), t('help.appDocs', { defaultValue: "This app's docs" })] })) : null, _jsxs(DropdownMenuItem, { className: "cursor-pointer", onClick: () => navigate('/docs'), children: [_jsx(Layers, { className: "mr-2 h-4 w-4" }), t('help.allDocs', { defaultValue: 'All documentation' })] }), isApp ? (_jsxs(DropdownMenuItem, { className: "cursor-pointer", "data-testid": "action:keyboard-shortcuts:open", onClick: openShortcuts, children: [_jsx(Keyboard, { className: "mr-2 h-4 w-4" }), t('help.keyboardShortcuts', { defaultValue: 'Keyboard shortcuts' })] })) : null, _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { asChild: true, className: "cursor-pointer", children: _jsxs("a", { href: "https://docs.objectstack.ai", target: "_blank", rel: "noopener noreferrer", children: [_jsx(ExternalLink, { className: "mr-2 h-4 w-4" }), t('help.onlineDocs', { defaultValue: 'Online documentation' })] }) })] })] })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [" ", _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0 rounded-full", children: _jsxs(Avatar, { className: "h-7 w-7 rounded-full", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-full bg-primary text-primary-foreground text-xs", children: getUserInitials(user) })] }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-64 rounded-lg", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-2 py-2", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] })] }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/account/component/account/profile_card'), className: "cursor-pointer", children: [_jsx(User, { className: "mr-2 h-4 w-4" }), t('user.profile', { defaultValue: 'Profile' })] }), hasOrgSection && !multiOrgDisabled && (_jsxs(DropdownMenuItem, { onClick: () => navigate('/organizations?create=1'), className: "cursor-pointer", "data-testid": "header-create-workspace", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('organizations.create', { defaultValue: 'Create workspace' })] })), (metadataApps || [])
@@ -10,12 +10,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
10
10
  * @module
11
11
  */
12
12
  import * as React from 'react';
13
- import { useNavigate, Link } from 'react-router-dom';
13
+ import { useNavigate, useLocation, Link } from 'react-router-dom';
14
14
  import { Layers } from 'lucide-react';
15
15
  import { getIcon as resolveIcon } from '../utils/getIcon';
16
16
  import { Sidebar, SidebarHeader, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarMenuAction, SidebarInput, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuGroup, Avatar, AvatarImage, AvatarFallback, useSidebar, } from '@object-ui/components';
17
- import { ChevronsUpDown, Plus, Settings, LogOut, Database, Clock, Star, StarOff, Search, Pencil, ChevronRight, Home, } from 'lucide-react';
18
- import { NavigationRenderer, resolveHref } from '@object-ui/layout';
17
+ import { ChevronsUpDown, Plus, Settings, LogOut, Database, Clock, Star, StarOff, Search, Pencil, ChevronRight, Home, ListTree, } from 'lucide-react';
18
+ import { NavigationRenderer, resolveHref, resolveActiveNavItem } from '@object-ui/layout';
19
19
  import { useMetadata } from '../providers/MetadataProvider';
20
20
  import { useExpressionContext, evaluateVisibility } from '../providers/ExpressionProvider';
21
21
  import { useAuth, useIsWorkspaceAdmin, getUserInitials } from '@object-ui/auth';
@@ -95,6 +95,7 @@ export function AppSidebar({ activeAppName, onAppChange }) {
95
95
  const { user, signOut, isAuthEnabled } = useAuth();
96
96
  const isWorkspaceAdmin = useIsWorkspaceAdmin();
97
97
  const navigate = useNavigate();
98
+ const location = useLocation();
98
99
  const { t } = useObjectTranslation();
99
100
  const { objectLabel: resolveNavObjectLabel, viewLabel: resolveNavViewLabel } = useObjectLabel();
100
101
  // Swipe-from-left-edge gesture to open sidebar on mobile
@@ -208,7 +209,12 @@ export function AppSidebar({ activeAppName, onAppChange }) {
208
209
  items.push({ id: 'sys-objects', label: t('layout.systemNav.objectManager', { defaultValue: 'Object Manager' }), type: 'url', url: '/apps/setup/system/metadata/object', icon: 'database' }, { id: 'sys-datasources', label: t('layout.systemNav.datasources', { defaultValue: 'Datasources' }), type: 'url', url: '/apps/setup/component/metadata/resource?type=datasource', icon: 'database' }, { id: 'sys-users', label: t('layout.systemNav.users', { defaultValue: 'Users' }), type: 'url', url: '/apps/setup/system/users', icon: 'users' }, { id: 'sys-orgs', label: t('layout.systemNav.organizations', { defaultValue: 'Organizations' }), type: 'url', url: '/apps/setup/system/organizations', icon: 'building-2' }, { id: 'sys-roles', label: t('layout.systemNav.roles', { defaultValue: 'Roles' }), type: 'url', url: '/apps/setup/system/roles', icon: 'shield' }, { id: 'sys-config', label: t('layout.systemNav.configuration', { defaultValue: 'Configuration' }), type: 'url', url: '/apps/setup/system/settings', icon: 'sliders-horizontal' });
209
210
  return items;
210
211
  }, [isWorkspaceAdmin, t]);
211
- return (_jsxs(_Fragment, { children: [_jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx(SidebarMenu, { children: _jsx(SidebarMenuItem, { children: activeApp ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(SidebarMenuButton, { size: "lg", className: "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground", children: [_jsx("div", { className: "flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground", style: primaryColor ? { backgroundColor: primaryColor } : undefined, children: logo ? (_jsx("img", { src: logo, alt: resolveI18nLabel(activeApp.label, t), className: "size-6 object-contain" })) : (React.createElement(getIcon(activeApp.icon), { className: "size-4" })) }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: resolveI18nLabel(activeApp.label, t) }), _jsx("span", { className: "truncate text-xs", children: resolveI18nLabel(activeApp.description, t) || t('layout.appSwitcher.appsAvailable', { defaultValue: '{{count}} apps available', count: activeApps.length }) })] }), _jsx(ChevronsUpDown, { className: "ml-auto" })] }) }), _jsxs(DropdownMenuContent, { className: "w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg", align: "start", side: isMobile ? "bottom" : "right", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground", children: t('layout.appSwitcher.switchApplication', { defaultValue: 'Switch Application' }) }), activeApps.map((app) => (_jsxs(DropdownMenuItem, { onClick: () => onAppChange(appRouteSegment(app) ?? app.name), className: "gap-2 p-2", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-sm border", children: app.icon ? React.createElement(getIcon(app.icon), { className: "size-3" }) : _jsx(Database, { className: "size-3" }) }), resolveI18nLabel(app.label, t), activeApp.name === app.name && _jsx("span", { className: "ml-auto text-xs", children: "\u2713" })] }, app.name))), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate('/home'), "data-testid": "home-link-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Home, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.home') })] }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate(`/apps/${appRouteSegment(activeApp) ?? activeAppName}/create-app`), "data-testid": "add-app-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Plus, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.addApp') })] }), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate(`/apps/${appRouteSegment(activeApp) ?? activeAppName}/edit-app/${activeAppName}`), "data-testid": "edit-app-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Pencil, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.editApp') })] }), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate('/apps/setup/system/apps'), "data-testid": "manage-all-apps-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Settings, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.manageAllApps') })] })] })] })) : (_jsxs(SidebarMenuButton, { size: "lg", onClick: () => navigate('/apps/setup'), "data-testid": "system-sidebar-header", children: [_jsx("div", { className: "flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground", children: _jsx(Settings, { className: "size-4" }) }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: t('layout.appSwitcher.systemConsole') }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: t('layout.appSwitcher.noAppsConfigured') })] })] })) }) }) }), _jsx(SidebarContent, { children: activeApp ? (_jsxs(_Fragment, { children: [areas.length > 1 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Layers, { className: "h-3.5 w-3.5" }), t('sidebar.area', { defaultValue: 'Area' })] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: areas.map((area) => {
212
+ return (_jsxs(_Fragment, { children: [_jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx(SidebarMenu, { children: _jsx(SidebarMenuItem, { children: activeApp ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(SidebarMenuButton, { size: "lg", className: "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground", children: [_jsx("div", { className: "flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground", style: primaryColor ? { backgroundColor: primaryColor } : undefined, children: logo ? (_jsx("img", { src: logo, alt: resolveI18nLabel(activeApp.label, t), className: "size-6 object-contain" })) : (React.createElement(getIcon(activeApp.icon), { className: "size-4" })) }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: resolveI18nLabel(activeApp.label, t) }), _jsx("span", { className: "truncate text-xs", children: resolveI18nLabel(activeApp.description, t) || t('layout.appSwitcher.appsAvailable', { defaultValue: '{{count}} apps available', count: activeApps.length }) })] }), _jsx(ChevronsUpDown, { className: "ml-auto" })] }) }), _jsxs(DropdownMenuContent, { className: "w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg", align: "start", side: isMobile ? "bottom" : "right", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground", children: t('layout.appSwitcher.switchApplication', { defaultValue: 'Switch Application' }) }), activeApps.map((app) => (_jsxs(DropdownMenuItem, { onClick: () => onAppChange(appRouteSegment(app) ?? app.name), className: "gap-2 p-2", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-sm border", children: app.icon ? React.createElement(getIcon(app.icon), { className: "size-3" }) : _jsx(Database, { className: "size-3" }) }), resolveI18nLabel(app.label, t), activeApp.name === app.name && _jsx("span", { className: "ml-auto text-xs", children: "\u2713" })] }, app.name))), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate('/home'), "data-testid": "home-link-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Home, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.home') })] }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate(`/apps/${appRouteSegment(activeApp) ?? activeAppName}/create-app`), "data-testid": "add-app-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Plus, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.addApp') })] }), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate(`/apps/${appRouteSegment(activeApp) ?? activeAppName}/edit-app/${activeAppName}`), "data-testid": "edit-app-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Pencil, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.editApp') })] }), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => {
213
+ const seg = appRouteSegment(activeApp) ?? activeAppName;
214
+ const active = resolveActiveNavItem(processedNavigation, location.pathname, location.search, basePath, { currentUserId: user?.id ?? null, contextValues });
215
+ const sel = active ? `?sel=${encodeURIComponent(`nav:${active.id}`)}` : '';
216
+ navigate(`/apps/${seg}/metadata/app/${activeAppName}${sel}`);
217
+ }, "data-testid": "edit-navigation-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(ListTree, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.editNavigation', { defaultValue: 'Edit Navigation' }) })] }), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate('/apps/setup/system/apps'), "data-testid": "manage-all-apps-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Settings, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.manageAllApps') })] })] })] })) : (_jsxs(SidebarMenuButton, { size: "lg", onClick: () => navigate('/apps/setup'), "data-testid": "system-sidebar-header", children: [_jsx("div", { className: "flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground", children: _jsx(Settings, { className: "size-4" }) }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: t('layout.appSwitcher.systemConsole') }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: t('layout.appSwitcher.noAppsConfigured') })] })] })) }) }) }), _jsx(SidebarContent, { children: activeApp ? (_jsxs(_Fragment, { children: [areas.length > 1 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Layers, { className: "h-3.5 w-3.5" }), t('sidebar.area', { defaultValue: 'Area' })] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: areas.map((area) => {
212
218
  const AreaIcon = getIcon(area.icon);
213
219
  const isActiveArea = area.id === activeAreaId;
214
220
  return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActiveArea, tooltip: area.label, onClick: () => setActiveAreaId(area.id), children: [_jsx(AreaIcon, { className: "h-4 w-4" }), _jsx("span", { children: area.label })] }) }, area.id));
@@ -11,6 +11,11 @@
11
11
  *
12
12
  * Env vars consumed (all optional):
13
13
  * - `VITE_SENTRY_DSN` — DSN; absent disables the integration entirely
14
+ * - `VITE_SENTRY_ENABLED` — set to `"false"` to force-disable reporting
15
+ * even when a DSN is configured (e.g. a fork that keeps the upstream DSN
16
+ * in `.env.production` but doesn't want to report to it). Default: enabled
17
+ * whenever a DSN is present — this is an *additional* off switch, not a
18
+ * change to that default.
14
19
  * - `VITE_SENTRY_ENVIRONMENT` — defaults to `MODE` (production/development)
15
20
  * - `VITE_SENTRY_RELEASE` — defaults to `VITE_APP_VERSION` or `unknown`
16
21
  * - `VITE_SENTRY_TRACES_SAMPLE_RATE` — defaults to `0.1`
@@ -11,6 +11,11 @@
11
11
  *
12
12
  * Env vars consumed (all optional):
13
13
  * - `VITE_SENTRY_DSN` — DSN; absent disables the integration entirely
14
+ * - `VITE_SENTRY_ENABLED` — set to `"false"` to force-disable reporting
15
+ * even when a DSN is configured (e.g. a fork that keeps the upstream DSN
16
+ * in `.env.production` but doesn't want to report to it). Default: enabled
17
+ * whenever a DSN is present — this is an *additional* off switch, not a
18
+ * change to that default.
14
19
  * - `VITE_SENTRY_ENVIRONMENT` — defaults to `MODE` (production/development)
15
20
  * - `VITE_SENTRY_RELEASE` — defaults to `VITE_APP_VERSION` or `unknown`
16
21
  * - `VITE_SENTRY_TRACES_SAMPLE_RATE` — defaults to `0.1`
@@ -38,7 +43,7 @@ export function initSentry() {
38
43
  initPromise = (async () => {
39
44
  const env = import.meta.env ?? {};
40
45
  const dsn = env.VITE_SENTRY_DSN;
41
- if (!dsn)
46
+ if (!dsn || env.VITE_SENTRY_ENABLED === 'false')
42
47
  return false;
43
48
  try {
44
49
  const Sentry = (await import('@sentry/react'));
@@ -12,10 +12,38 @@ export interface DraftChangeEntry {
12
12
  /** `new` = no published version; `update` = overwrites one; undefined = probing. */
13
13
  kind?: 'new' | 'update';
14
14
  }
15
+ export interface EntryChangeDetail {
16
+ /** Field-level diff — present when either side carries a `fields` map. */
17
+ fields: {
18
+ added: string[];
19
+ changed: Array<{
20
+ name: string;
21
+ keys: string[];
22
+ }>;
23
+ removed: string[];
24
+ } | null;
25
+ /** Top-level keys (other than `fields`) whose values differ. */
26
+ changedKeys: string[];
27
+ }
28
+ /**
29
+ * What publishing this draft actually changes, computed client-side from the
30
+ * published body (null when the item is NEW) and the pending draft body.
31
+ * `fields` gets the dedicated designer diff; every other top-level key is
32
+ * compared wholesale — enough to answer "which parts of this item move".
33
+ */
34
+ export declare function computeChangeDetail(published: Record<string, unknown> | null, draft: Record<string, unknown> | null): EntryChangeDetail;
15
35
  export interface DraftChangesPanelProps {
16
36
  open: boolean;
17
37
  onOpenChange: (open: boolean) => void;
18
38
  /** When set, list only pending drafts belonging to this package (Studio is package-scoped). */
19
39
  packageId?: string | null;
40
+ /**
41
+ * When provided, the panel renders a confirm footer whose button invokes
42
+ * this — turning the panel into the review-then-publish step. The caller
43
+ * still owns the actual publish request and closing the panel on success.
44
+ */
45
+ onPublish?: () => void | Promise<void>;
46
+ /** Disables the confirm button and shows a spinner while the caller publishes. */
47
+ publishing?: boolean;
20
48
  }
21
- export declare function DraftChangesPanel({ open, onOpenChange, packageId }: DraftChangesPanelProps): import("react").JSX.Element;
49
+ export declare function DraftChangesPanel({ open, onOpenChange, packageId, onPublish, publishing, }: DraftChangesPanelProps): import("react").JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  /**
3
3
  * ObjectUI
4
4
  * Copyright (c) 2024-present ObjectStack Inc.
@@ -11,16 +11,20 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  * user commits. Lists every pending ADR-0033 draft grouped by metadata type,
12
12
  * and classifies each as NEW (no published version exists — publishing adds
13
13
  * it) or UPDATE (a published version exists — publishing overwrites it).
14
- * This is the review surface that turns Publish from a leap of faith into an
15
- * informed click; the per-item designer diff remains the deep-dive.
14
+ * Each entry expands into a field-level diff (objects) / changed-key summary
15
+ * (everything else), lazily fetched on first expand. This is the review
16
+ * surface that turns Publish from a leap of faith into an informed click.
16
17
  *
17
- * Read-only: fetches `_drafts` + per-item `/published` probes on open, and
18
- * never writes. Publishing stays with the caller (DraftPreviewBar / chat).
18
+ * Read-only by default: fetches `_drafts` + published lists on open, and
19
+ * never writes. When the caller passes `onPublish`, the panel additionally
20
+ * renders a confirm footer — review-then-publish in one surface — but the
21
+ * publish action itself still belongs to the caller.
19
22
  */
20
23
  import { useCallback, useEffect, useState } from 'react';
21
- import { FilePlus2, FilePen, Loader2 } from 'lucide-react';
22
- import { Badge, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from '@object-ui/components';
24
+ import { ChevronDown, ChevronRight, FilePlus2, FilePen, Loader2, Rocket } from 'lucide-react';
25
+ import { Badge, Button, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from '@object-ui/components';
23
26
  import { useObjectTranslation } from '@object-ui/i18n';
27
+ import { diffFields } from '../views/metadata-admin/previews/object-fields-io';
24
28
  /** Pending drafts straight from the ADR-0033 `_drafts` endpoint. */
25
29
  async function listPendingDrafts(packageId) {
26
30
  const qs = packageId ? `?packageId=${encodeURIComponent(packageId)}` : '';
@@ -62,10 +66,126 @@ async function publishedNamesOf(type) {
62
66
  .map((it) => (typeof it?.name === 'string' ? it.name : null))
63
67
  .filter((n) => n !== null));
64
68
  }
65
- export function DraftChangesPanel({ open, onOpenChange, packageId }) {
69
+ /**
70
+ * Some framework reads wrap the body in a `{ type, name, item }` envelope
71
+ * (draft reads do; published reads return the bare body). Unwrap defensively.
72
+ */
73
+ function unwrapItem(payload) {
74
+ if (!payload || typeof payload !== 'object')
75
+ return null;
76
+ const p = payload;
77
+ if (p.item && typeof p.item === 'object')
78
+ return p.item;
79
+ return p;
80
+ }
81
+ async function fetchItemBody(type, name, opts = {}) {
82
+ const params = [];
83
+ if (opts.draft)
84
+ params.push('state=draft');
85
+ if (opts.packageId)
86
+ params.push(`package=${encodeURIComponent(opts.packageId)}`);
87
+ const qs = params.length ? `?${params.join('&')}` : '';
88
+ const res = await fetch(`/api/v1/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`, { credentials: 'include', headers: { Accept: 'application/json' }, cache: 'no-store' });
89
+ if (res.status === 404)
90
+ return null;
91
+ if (!res.ok)
92
+ throw new Error(`HTTP ${res.status}`);
93
+ return unwrapItem(await res.json());
94
+ }
95
+ /** Stable equality for metadata values (small JSON — order-sensitive is fine). */
96
+ function valueEqual(a, b) {
97
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
98
+ }
99
+ /**
100
+ * What publishing this draft actually changes, computed client-side from the
101
+ * published body (null when the item is NEW) and the pending draft body.
102
+ * `fields` gets the dedicated designer diff; every other top-level key is
103
+ * compared wholesale — enough to answer "which parts of this item move".
104
+ */
105
+ export function computeChangeDetail(published, draft) {
106
+ const pub = published ?? {};
107
+ const cur = draft ?? {};
108
+ let fields = null;
109
+ if (pub.fields != null || cur.fields != null) {
110
+ const d = diffFields(pub.fields, cur.fields);
111
+ fields = {
112
+ added: Object.values(d.byName)
113
+ .filter((e) => e.status === 'added')
114
+ .map((e) => e.name)
115
+ .sort(),
116
+ changed: Object.values(d.byName)
117
+ .filter((e) => e.status === 'changed')
118
+ .map((e) => ({ name: e.name, keys: e.changedKeys }))
119
+ .sort((a, b) => a.name.localeCompare(b.name)),
120
+ removed: d.removed.map((e) => e.name).sort(),
121
+ };
122
+ }
123
+ const keys = new Set([...Object.keys(pub), ...Object.keys(cur)]);
124
+ keys.delete('fields');
125
+ const changedKeys = [...keys]
126
+ .filter((k) => !valueEqual(pub[k], cur[k]))
127
+ .sort();
128
+ return { fields, changedKeys };
129
+ }
130
+ /**
131
+ * Lazily-loaded drill-in for one draft entry: published vs draft, rendered as
132
+ * added / changed / removed field rows plus a changed-top-level-keys strip.
133
+ */
134
+ function EntryDetail({ entry }) {
135
+ const { t } = useObjectTranslation();
136
+ const [detail, setDetail] = useState(null);
137
+ const [error, setError] = useState(null);
138
+ useEffect(() => {
139
+ let cancelled = false;
140
+ (async () => {
141
+ try {
142
+ const [published, draft] = await Promise.all([
143
+ // A NEW item 404s on the published read — that's data, not an error.
144
+ fetchItemBody(entry.type, entry.name, { packageId: entry.packageId }),
145
+ fetchItemBody(entry.type, entry.name, { draft: true, packageId: entry.packageId }),
146
+ ]);
147
+ if (!cancelled)
148
+ setDetail(computeChangeDetail(published, draft));
149
+ }
150
+ catch (e) {
151
+ if (!cancelled)
152
+ setError(e.message);
153
+ }
154
+ })();
155
+ return () => {
156
+ cancelled = true;
157
+ };
158
+ }, [entry.type, entry.name, entry.packageId]);
159
+ if (error) {
160
+ return (_jsxs("p", { className: "px-2 py-1 text-xs text-destructive", children: [t('preview.changes.detailLoadFailed', { defaultValue: 'Could not load change detail:' }), ' ', error] }));
161
+ }
162
+ if (!detail) {
163
+ return (_jsxs("p", { className: "flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground", children: [_jsx(Loader2, { className: "h-3 w-3 animate-spin" }), t('preview.changes.detailLoading', { defaultValue: 'Loading detail…' })] }));
164
+ }
165
+ const { fields, changedKeys } = detail;
166
+ const hasFieldRows = !!fields && (fields.added.length > 0 || fields.changed.length > 0 || fields.removed.length > 0);
167
+ if (!hasFieldRows && changedKeys.length === 0) {
168
+ return (_jsx("p", { className: "px-2 py-1 text-xs text-muted-foreground", children: t('preview.changes.detailNone', {
169
+ defaultValue: 'No differences detected — the draft matches the published version.',
170
+ }) }));
171
+ }
172
+ return (_jsxs("div", { className: "flex flex-col gap-0.5 px-2 py-1", "data-testid": "draft-entry-detail", children: [fields?.added.map((name) => (_jsxs("p", { className: "font-mono text-xs text-emerald-700 dark:text-emerald-400", children: ["+ ", name] }, `+${name}`))), fields?.changed.map((f) => (_jsxs("p", { className: "font-mono text-xs text-amber-700 dark:text-amber-400", children: ["~ ", f.name, f.keys.length > 0 && _jsxs("span", { className: "text-muted-foreground", children: [" \u00B7 ", f.keys.join(', ')] })] }, `~${f.name}`))), fields?.removed.map((name) => (_jsxs("p", { className: "font-mono text-xs text-red-700 line-through dark:text-red-400", children: ["\u2212 ", name] }, `-${name}`))), changedKeys.length > 0 && (_jsxs("p", { className: "text-xs text-muted-foreground", children: [t('preview.changes.detailChangedKeys', { defaultValue: 'Also changed:' }), ' ', _jsx("span", { className: "font-mono", children: changedKeys.join(', ') })] }))] }));
173
+ }
174
+ export function DraftChangesPanel({ open, onOpenChange, packageId, onPublish, publishing = false, }) {
66
175
  const { t } = useObjectTranslation();
67
176
  const [entries, setEntries] = useState(null);
68
177
  const [error, setError] = useState(null);
178
+ const [expanded, setExpanded] = useState(new Set());
179
+ const toggleExpanded = useCallback((key) => {
180
+ setExpanded((prev) => {
181
+ const next = new Set(prev);
182
+ if (next.has(key))
183
+ next.delete(key);
184
+ else
185
+ next.add(key);
186
+ return next;
187
+ });
188
+ }, []);
69
189
  const load = useCallback(async () => {
70
190
  setEntries(null);
71
191
  setError(null);
@@ -105,11 +225,18 @@ export function DraftChangesPanel({ open, onOpenChange, packageId }) {
105
225
  bucket.push(entry);
106
226
  byType.set(entry.type, bucket);
107
227
  }
108
- return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, children: _jsxs(SheetContent, { side: "right", className: "w-[420px] sm:max-w-[420px]", "data-testid": "draft-changes-panel", children: [_jsxs(SheetHeader, { children: [_jsx(SheetTitle, { children: t('preview.changes.title', { defaultValue: 'Pending changes' }) }), _jsx(SheetDescription, { children: t('preview.changes.description', {
228
+ return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, children: _jsxs(SheetContent, { side: "right", className: "flex w-[420px] flex-col sm:max-w-[420px]", "data-testid": "draft-changes-panel", children: [_jsxs(SheetHeader, { children: [_jsx(SheetTitle, { children: t('preview.changes.title', { defaultValue: 'Pending changes' }) }), _jsx(SheetDescription, { children: t('preview.changes.description', {
109
229
  defaultValue: 'What publishing will change. New items are added; updates overwrite the live version.',
110
- }) })] }), _jsx("div", { className: "mt-4 flex flex-col gap-4 overflow-y-auto px-4 pb-6", children: error ? (_jsxs("p", { className: "text-sm text-destructive", children: [t('preview.changes.loadFailed', { defaultValue: 'Could not load pending changes:' }), ' ', error] })) : entries === null ? (_jsxs("div", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), t('preview.changes.loading', { defaultValue: 'Loading pending changes…' })] })) : entries.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('preview.changes.empty', { defaultValue: 'Nothing pending — every draft has been published.' }) })) : ([...byType.entries()].map(([type, items]) => (_jsxs("div", { children: [_jsxs("h4", { className: "mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground", children: [type, " \u00B7 ", items.length] }), _jsx("ul", { className: "flex flex-col gap-1", children: items.map((entry) => (_jsxs("li", { className: "flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm", children: [entry.kind === 'new' ? (_jsx(FilePlus2, { className: "h-3.5 w-3.5 shrink-0 text-emerald-600" })) : entry.kind === 'update' ? (_jsx(FilePen, { className: "h-3.5 w-3.5 shrink-0 text-amber-600" })) : (_jsx(Loader2, { className: "h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" })), _jsx("span", { className: "min-w-0 flex-1 truncate font-mono text-xs", children: entry.name }), entry.kind ? (_jsx(Badge, { variant: "outline", className: entry.kind === 'new'
111
- ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
112
- : 'border-amber-200 bg-amber-50 text-amber-700', children: entry.kind === 'new'
113
- ? t('preview.changes.kindNew', { defaultValue: 'New' })
114
- : t('preview.changes.kindUpdate', { defaultValue: 'Update' }) })) : null] }, `${entry.type}:${entry.name}`))) })] }, type)))) })] }) }));
230
+ }) })] }), _jsx("div", { className: "mt-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-6", children: error ? (_jsxs("p", { className: "text-sm text-destructive", children: [t('preview.changes.loadFailed', { defaultValue: 'Could not load pending changes:' }), ' ', error] })) : entries === null ? (_jsxs("div", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), t('preview.changes.loading', { defaultValue: 'Loading pending changes…' })] })) : entries.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('preview.changes.empty', { defaultValue: 'Nothing pending — every draft has been published.' }) })) : ([...byType.entries()].map(([type, items]) => (_jsxs("div", { children: [_jsxs("h4", { className: "mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground", children: [type, " \u00B7 ", items.length] }), _jsx("ul", { className: "flex flex-col gap-1", children: items.map((entry) => {
231
+ const key = `${entry.type}:${entry.name}`;
232
+ const isExpanded = expanded.has(key);
233
+ return (_jsxs("li", { className: "rounded-md border text-sm", children: [_jsxs("button", { type: "button", onClick: () => toggleExpanded(key), "aria-expanded": isExpanded, className: "flex w-full items-center gap-2 px-2.5 py-1.5 text-left hover:bg-muted/50", "data-testid": "draft-entry-toggle", children: [isExpanded ? (_jsx(ChevronDown, { className: "h-3 w-3 shrink-0 text-muted-foreground" })) : (_jsx(ChevronRight, { className: "h-3 w-3 shrink-0 text-muted-foreground" })), entry.kind === 'new' ? (_jsx(FilePlus2, { className: "h-3.5 w-3.5 shrink-0 text-emerald-600" })) : entry.kind === 'update' ? (_jsx(FilePen, { className: "h-3.5 w-3.5 shrink-0 text-amber-600" })) : (_jsx(Loader2, { className: "h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" })), _jsx("span", { className: "min-w-0 flex-1 truncate font-mono text-xs", children: entry.name }), entry.kind ? (_jsx(Badge, { variant: "outline", className: entry.kind === 'new'
234
+ ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
235
+ : 'border-amber-200 bg-amber-50 text-amber-700', children: entry.kind === 'new'
236
+ ? t('preview.changes.kindNew', { defaultValue: 'New' })
237
+ : t('preview.changes.kindUpdate', { defaultValue: 'Update' }) })) : null] }), isExpanded && (_jsx("div", { className: "border-t bg-muted/20", children: _jsx(EntryDetail, { entry: entry }) }))] }, key));
238
+ }) })] }, type)))) }), onPublish && (entries?.length ?? 0) > 0 && !error && (_jsxs("div", { className: "mt-auto flex flex-col gap-2 border-t px-4 py-3", children: [_jsx("p", { className: "text-xs text-muted-foreground", children: t('preview.changes.confirmNote', {
239
+ count: entries.length,
240
+ defaultValue: 'Publishing releases all {{count}} pending drafts of this package atomically.',
241
+ }) }), _jsxs(Button, { size: "sm", onClick: () => void onPublish(), disabled: publishing, "data-testid": "draft-changes-publish", children: [publishing ? (_jsx(Loader2, { className: "mr-1.5 h-3.5 w-3.5 animate-spin" })) : (_jsx(Rocket, { className: "mr-1.5 h-3.5 w-3.5" })), t('preview.changes.publishConfirm', { defaultValue: 'Publish all' })] })] }))] }) }));
115
242
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * urlParams — the single registry of RESERVED console URL query params
10
+ * (objectui#2269 P3; ADR-0054 C3 "URL-addressable state").
11
+ *
12
+ * These params form the console's cross-route URL contract: they are read
13
+ * and written by app-shell chrome (overlays, record surfaces, tabs) and must
14
+ * never be repurposed by a page/view for something else. Every reader/writer
15
+ * imports the constant from here — no string literals — so the contract has
16
+ * ONE definition, collisions are caught at review time, and an AI author
17
+ * (north star: all metadata is AI-authored) has a single place to learn it.
18
+ *
19
+ * | Param | Meaning | History |
20
+ * |-------------|------------------------------------------------|--------------|
21
+ * | `recordId` | Record detail DRAWER over a list (light | push (open) |
22
+ * | | objects; heavy ones use the `/record/:id` | |
23
+ * | | route instead). URL is the drawer's source of | |
24
+ * | | truth. | |
25
+ * | `form` | The global record-form overlay: `new` = create,| |
26
+ * | | a record id = edit (framework#2604 D1/D2). | push (open), |
27
+ * | | Back closes the overlay. | replace (close) |
28
+ * | `formObject`| Child-task override for `form`: the object the | |
29
+ * | | overlay edits when it is NOT the route's | with `form` |
30
+ * | | object (subtable child over a parent detail, | |
31
+ * | | framework#2604 D3). | |
32
+ * | `formLink` | `"<fkField>:<parentId>"` — create-mode parent | |
33
+ * | | pre-link for a child task; refresh-safe. | with `form` |
34
+ * | `tab` | Active record-detail tab (stable semantic | replace |
35
+ * | | values: `details` / `related:<child>` / | |
36
+ * | | `related` / `activity` / `history`, | |
37
+ * | | objectui#2257). Never stacks history. | |
38
+ * | `palette` | Command palette overlay (alias `cmdk`). | replace |
39
+ * | `shortcuts` | Keyboard-shortcuts dialog overlay. | replace |
40
+ *
41
+ * Push-vs-replace rule of thumb: an overlay the user OPENED (form, drawer)
42
+ * pushes one entry so browser Back closes it; passive state that tracks an
43
+ * in-page selection (tab) or transient chrome (palette) replaces, so Back
44
+ * never pages through it.
45
+ *
46
+ * Page-scoped params (`q`, `limit`, `offset`, `view`, `type`, `review`,
47
+ * `package`, …) belong to their page's own contract and are NOT reserved
48
+ * here — but they must not collide with the names above.
49
+ */
50
+ /** Record detail drawer over a list (`?recordId=<id>`). */
51
+ export declare const RECORD_DRAWER_PARAM = "recordId";
52
+ /** Global record-form overlay: `new` | `<recordId>` (framework#2604). */
53
+ export declare const RECORD_FORM_PARAM = "form";
54
+ /** Child-task object override for the record-form overlay (#2604 D3). */
55
+ export declare const RECORD_FORM_OBJECT_PARAM = "formObject";
56
+ /** Child-task parent pre-link `"<fkField>:<parentId>"` (#2604 D3). */
57
+ export declare const RECORD_FORM_LINK_PARAM = "formLink";
58
+ /** Active record-detail tab (objectui#2257; stable semantic values). */
59
+ export declare const RECORD_DETAIL_TAB_PARAM = "tab";
60
+ /** Command palette overlay (ADR-0054 Phase 1; alias `cmdk`). */
61
+ export declare const COMMAND_PALETTE_PARAM = "palette";
62
+ /** Keyboard-shortcuts dialog overlay. */
63
+ export declare const KEYBOARD_SHORTCUTS_PARAM = "shortcuts";
64
+ /**
65
+ * All reserved params, for collision checks (e.g. a lint or a dev-time
66
+ * assertion that a page-scoped param doesn't shadow the console contract).
67
+ */
68
+ export declare const RESERVED_URL_PARAMS: readonly string[];
@@ -0,0 +1,76 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * urlParams — the single registry of RESERVED console URL query params
10
+ * (objectui#2269 P3; ADR-0054 C3 "URL-addressable state").
11
+ *
12
+ * These params form the console's cross-route URL contract: they are read
13
+ * and written by app-shell chrome (overlays, record surfaces, tabs) and must
14
+ * never be repurposed by a page/view for something else. Every reader/writer
15
+ * imports the constant from here — no string literals — so the contract has
16
+ * ONE definition, collisions are caught at review time, and an AI author
17
+ * (north star: all metadata is AI-authored) has a single place to learn it.
18
+ *
19
+ * | Param | Meaning | History |
20
+ * |-------------|------------------------------------------------|--------------|
21
+ * | `recordId` | Record detail DRAWER over a list (light | push (open) |
22
+ * | | objects; heavy ones use the `/record/:id` | |
23
+ * | | route instead). URL is the drawer's source of | |
24
+ * | | truth. | |
25
+ * | `form` | The global record-form overlay: `new` = create,| |
26
+ * | | a record id = edit (framework#2604 D1/D2). | push (open), |
27
+ * | | Back closes the overlay. | replace (close) |
28
+ * | `formObject`| Child-task override for `form`: the object the | |
29
+ * | | overlay edits when it is NOT the route's | with `form` |
30
+ * | | object (subtable child over a parent detail, | |
31
+ * | | framework#2604 D3). | |
32
+ * | `formLink` | `"<fkField>:<parentId>"` — create-mode parent | |
33
+ * | | pre-link for a child task; refresh-safe. | with `form` |
34
+ * | `tab` | Active record-detail tab (stable semantic | replace |
35
+ * | | values: `details` / `related:<child>` / | |
36
+ * | | `related` / `activity` / `history`, | |
37
+ * | | objectui#2257). Never stacks history. | |
38
+ * | `palette` | Command palette overlay (alias `cmdk`). | replace |
39
+ * | `shortcuts` | Keyboard-shortcuts dialog overlay. | replace |
40
+ *
41
+ * Push-vs-replace rule of thumb: an overlay the user OPENED (form, drawer)
42
+ * pushes one entry so browser Back closes it; passive state that tracks an
43
+ * in-page selection (tab) or transient chrome (palette) replaces, so Back
44
+ * never pages through it.
45
+ *
46
+ * Page-scoped params (`q`, `limit`, `offset`, `view`, `type`, `review`,
47
+ * `package`, …) belong to their page's own contract and are NOT reserved
48
+ * here — but they must not collide with the names above.
49
+ */
50
+ /** Record detail drawer over a list (`?recordId=<id>`). */
51
+ export const RECORD_DRAWER_PARAM = 'recordId';
52
+ /** Global record-form overlay: `new` | `<recordId>` (framework#2604). */
53
+ export const RECORD_FORM_PARAM = 'form';
54
+ /** Child-task object override for the record-form overlay (#2604 D3). */
55
+ export const RECORD_FORM_OBJECT_PARAM = 'formObject';
56
+ /** Child-task parent pre-link `"<fkField>:<parentId>"` (#2604 D3). */
57
+ export const RECORD_FORM_LINK_PARAM = 'formLink';
58
+ /** Active record-detail tab (objectui#2257; stable semantic values). */
59
+ export const RECORD_DETAIL_TAB_PARAM = 'tab';
60
+ /** Command palette overlay (ADR-0054 Phase 1; alias `cmdk`). */
61
+ export const COMMAND_PALETTE_PARAM = 'palette';
62
+ /** Keyboard-shortcuts dialog overlay. */
63
+ export const KEYBOARD_SHORTCUTS_PARAM = 'shortcuts';
64
+ /**
65
+ * All reserved params, for collision checks (e.g. a lint or a dev-time
66
+ * assertion that a page-scoped param doesn't shadow the console contract).
67
+ */
68
+ export const RESERVED_URL_PARAMS = [
69
+ RECORD_DRAWER_PARAM,
70
+ RECORD_FORM_PARAM,
71
+ RECORD_FORM_OBJECT_PARAM,
72
+ RECORD_FORM_LINK_PARAM,
73
+ RECORD_DETAIL_TAB_PARAM,
74
+ COMMAND_PALETTE_PARAM,
75
+ KEYBOARD_SHORTCUTS_PARAM,
76
+ ];
@@ -18,4 +18,19 @@ type AppLike = {
18
18
  } & Record<string, unknown>;
19
19
  export declare function appRouteSegment(app: AppLike | null | undefined): string | undefined;
20
20
  export declare function matchAppBySegment<T extends AppLike>(apps: readonly T[] | null | undefined, seg: string | null | undefined): T | undefined;
21
+ /**
22
+ * App → Studio reverse bridge (ADR-0080). Resolves a running app to its owning
23
+ * package's design surface (`/studio/:packageId/data`), or `null` when there is
24
+ * nothing to open:
25
+ * - the viewer is not a workspace admin (designing mutates shared package
26
+ * metadata, so the entry point is admin-only — mirrors the runtime editors);
27
+ * - the app has no owning package (runtime/DB apps), or its container is the
28
+ * DB-authored `sys_metadata` pseudo-package, which is not a package the
29
+ * Studio design surface can open.
30
+ *
31
+ * Writability of the target package is NOT checked here — the ADR-0070 D4 gate
32
+ * stays the server-side authority, and the Studio surface itself renders
33
+ * read-only packages as browse-only.
34
+ */
35
+ export declare function appStudioDesignPath(app: AppLike | null | undefined, isWorkspaceAdmin: boolean): string | null;
21
36
  export {};
@@ -23,3 +23,25 @@ export function matchAppBySegment(apps, seg) {
23
23
  return undefined;
24
24
  return apps.find((a) => a?._packageId === seg) ?? apps.find((a) => a?.name === seg);
25
25
  }
26
+ /**
27
+ * App → Studio reverse bridge (ADR-0080). Resolves a running app to its owning
28
+ * package's design surface (`/studio/:packageId/data`), or `null` when there is
29
+ * nothing to open:
30
+ * - the viewer is not a workspace admin (designing mutates shared package
31
+ * metadata, so the entry point is admin-only — mirrors the runtime editors);
32
+ * - the app has no owning package (runtime/DB apps), or its container is the
33
+ * DB-authored `sys_metadata` pseudo-package, which is not a package the
34
+ * Studio design surface can open.
35
+ *
36
+ * Writability of the target package is NOT checked here — the ADR-0070 D4 gate
37
+ * stays the server-side authority, and the Studio surface itself renders
38
+ * read-only packages as browse-only.
39
+ */
40
+ export function appStudioDesignPath(app, isWorkspaceAdmin) {
41
+ if (!isWorkspaceAdmin)
42
+ return null;
43
+ const packageId = app?._packageId;
44
+ if (typeof packageId !== 'string' || !packageId || packageId === 'sys_metadata')
45
+ return null;
46
+ return `/studio/${encodeURIComponent(packageId)}/data`;
47
+ }