@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.
- package/CHANGELOG.md +178 -0
- package/README.md +9 -0
- package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
- package/dist/console/AppContent.js +145 -26
- package/dist/console/ConsoleShell.js +8 -1
- package/dist/context/CommandPaletteProvider.js +2 -1
- package/dist/hooks/useObjectActions.js +16 -4
- package/dist/layout/AppHeader.js +13 -5
- package/dist/layout/AppSidebar.js +10 -4
- package/dist/observability/sentry.d.ts +5 -0
- package/dist/observability/sentry.js +6 -1
- package/dist/preview/DraftChangesPanel.d.ts +29 -1
- package/dist/preview/DraftChangesPanel.js +141 -14
- package/dist/urlParams.d.ts +68 -0
- package/dist/urlParams.js +76 -0
- package/dist/utils/appRoute.d.ts +15 -0
- package/dist/utils/appRoute.js +22 -0
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/pageTabsUrlSync.d.ts +32 -0
- package/dist/utils/pageTabsUrlSync.js +43 -0
- package/dist/utils/recordFormNavigation.d.ts +40 -0
- package/dist/utils/recordFormNavigation.js +30 -0
- package/dist/views/InterfaceListPage.d.ts +1 -0
- package/dist/views/InterfaceListPage.js +1 -1
- package/dist/views/ObjectDataPage.d.ts +29 -0
- package/dist/views/ObjectDataPage.js +227 -0
- package/dist/views/ObjectView.js +4 -3
- package/dist/views/RecordDetailView.js +61 -20
- package/dist/views/RelatedRecordActionsBridge.d.ts +10 -1
- package/dist/views/RelatedRecordActionsBridge.js +49 -16
- package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
- package/dist/views/metadata-admin/i18n.js +214 -4
- package/dist/views/metadata-admin/inspectors/AppNavInspector.d.ts +11 -4
- package/dist/views/metadata-admin/inspectors/AppNavInspector.js +141 -7
- package/dist/views/metadata-admin/inspectors/FlowReferenceField.d.ts +14 -0
- package/dist/views/metadata-admin/inspectors/FlowReferenceField.js +76 -5
- package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +35 -19
- package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +8 -1
- package/dist/views/metadata-admin/inspectors/flow-node-config.js +3 -2
- package/dist/views/metadata-admin/inspectors/nav-target.d.ts +52 -0
- package/dist/views/metadata-admin/inspectors/nav-target.js +149 -0
- package/dist/views/metadata-admin/nav-selection.d.ts +20 -0
- package/dist/views/metadata-admin/nav-selection.js +81 -0
- package/dist/views/metadata-admin/previews/AppNavCanvas.js +9 -1
- package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
- package/dist/views/studio-design/BuilderLanding.d.ts +1 -1
- package/dist/views/studio-design/BuilderLanding.js +12 -19
- package/dist/views/studio-design/ObjectFormDesigner.d.ts +5 -3
- package/dist/views/studio-design/ObjectFormDesigner.js +17 -12
- package/dist/views/studio-design/ObjectSettingsPanel.d.ts +1 -1
- package/dist/views/studio-design/ObjectSettingsPanel.js +4 -3
- package/dist/views/studio-design/ObjectValidationsPanel.js +6 -4
- package/dist/views/studio-design/PackageIdInput.d.ts +31 -0
- package/dist/views/studio-design/PackageIdInput.js +40 -0
- package/dist/views/studio-design/StudioDesignSurface.d.ts +13 -0
- package/dist/views/studio-design/StudioDesignSurface.js +227 -57
- package/dist/views/studio-design/packageSurfaces.d.ts +49 -0
- package/dist/views/studio-design/packageSurfaces.js +34 -0
- package/dist/views/studio-design/packages-io.d.ts +11 -0
- package/dist/views/studio-design/packages-io.js +12 -0
- package/dist/views/studio-design/skeletons.d.ts +16 -0
- package/dist/views/studio-design/skeletons.js +51 -0
- package/package.json +38 -38
package/dist/layout/AppHeader.js
CHANGED
|
@@ -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(
|
|
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: () =>
|
|
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 {
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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` +
|
|
18
|
-
* never writes.
|
|
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
|
-
|
|
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) =>
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
];
|
package/dist/utils/appRoute.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/utils/appRoute.js
CHANGED
|
@@ -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
|
+
}
|