From 5ee1976e5fe3e7b664b06496fdf2aa5509d78a88 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:18:05 +0000 Subject: [PATCH 1/4] fix(app-shell): the Edit app pencil and the sidebar's Design follow the package's posture (WIP) The app bar's Edit app pencil, the app switcher's Studio row it feeds and the sidebar menu's Design entry now read one gate, useStudioDesignGate: a workspace admin holding manage_metadata, on a package Studio's package list serves as writable. Kernel packages (Setup, Studio, Account) are not in that list, so they fall out with no app-id list. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .changeset/12615-edit-app-pencil-posture.md | 5 + .../src/hooks/useStudioDesignGate.ts | 115 ++++++ packages/app-shell/src/layout/AppHeader.tsx | 38 +- .../app-shell/src/layout/SidebarItemMenu.tsx | 31 +- .../AppHeader.appBarLeftGroup-12461.test.tsx | 22 +- .../AppHeader.appIdentity-12201.test.tsx | 26 +- .../AppHeader.centeredSearch-12472.test.tsx | 18 +- ...esignInStudioNeedsAuthoring-10899.test.tsx | 18 +- ...ditAppFollowsPackagePosture-12615.test.tsx | 342 ++++++++++++++++++ ...pageRouteNoAuthoringHandles-12198.test.tsx | 19 +- .../__tests__/SidebarItemMenu-12427.test.tsx | 25 +- ...designFollowsPackagePosture-12615.test.tsx | 162 +++++++++ ...rItemMenu.designOpensStudio-12520.test.tsx | 75 ++-- ...nifiedSidebar.favoritesMenu-12427.test.tsx | 24 +- 14 files changed, 831 insertions(+), 89 deletions(-) create mode 100644 .changeset/12615-edit-app-pencil-posture.md create mode 100644 packages/app-shell/src/hooks/useStudioDesignGate.ts create mode 100644 packages/app-shell/src/layout/__tests__/AppHeader.editAppFollowsPackagePosture-12615.test.tsx create mode 100644 packages/app-shell/src/layout/__tests__/SidebarItemMenu.designFollowsPackagePosture-12615.test.tsx diff --git a/.changeset/12615-edit-app-pencil-posture.md b/.changeset/12615-edit-app-pencil-posture.md new file mode 100644 index 0000000000..dac43b3e90 --- /dev/null +++ b/.changeset/12615-edit-app-pencil-posture.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': patch +--- + +The app bar's Edit app pencil now appears only where Studio can edit: for a metadata author, on an app whose package Studio lists as writable. A read-only package's app (such as a code-shipped package) and the console's own system apps (Setup, Studio, Account) no longer show it, and neither does the app switcher's "Edit app in Studio" row. The sidebar entry menu's Design entry follows the same rule. While the package list is loading, or if it cannot be read, these entries stay hidden. diff --git a/packages/app-shell/src/hooks/useStudioDesignGate.ts b/packages/app-shell/src/hooks/useStudioDesignGate.ts new file mode 100644 index 0000000000..0f8d83a07a --- /dev/null +++ b/packages/app-shell/src/hooks/useStudioDesignGate.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `useStudioDesignGate(path)` — the one gate the app → Studio "design this" + * entries read (objectui#12615): the app bar's Edit app pencil (and the app + * switcher's Studio row, which takes the pencil's own target) and the sidebar + * ⋯ menu's Design entry. Hand it the Studio path the entry would open + * (`appStudioRoutePath`'s answer); it hands the path back when the entry may + * be drawn, and `null` when it may not. + * + * An entry is drawn only when BOTH hold: + * + * 1. the principal may author metadata: a workspace admin holding the + * server-reported `manage_metadata` capability (`useCanAuthorMetadata`, + * objectui#10899), the check Home's builder CTAs read (objectui#5521); + * 2. the package the path opens is one Studio lists as WRITABLE: the server's + * `writable` verdict as `fetchPackages` reads it (`PkgEntry.writable`, + * ADR-0070 D2), the answer Studio's own read-only posture reads. + * ⛔ Never a second derivation of "writable" here. + * + * Studio opened a read-only package in its browse-only posture with nothing + * to edit, so an entry drawn there promised an edit Studio cannot deliver. + * + * ## A package the list does not carry is not designable + * + * `parsePackages` leaves out the kernel packages (manifest scope `system` or + * `cloud`), and the server judges those read-only by the same scopes + * (`READ_ONLY_PACKAGE_SCOPES` in the server's package-writability predicate). + * The console's own Setup, Studio and Account apps belong to such packages, so + * they fall out of this read with no app-id list (ADR-0060). An id the list + * does not name has no Studio package page to open either. + * + * ## Unknown is not designable + * + * While the list is being read, and when the read fails, the entry is absent. + * That is the opposite of `useCanAuthorMetadata`'s fail-open, on purpose: an + * unreported capability set is a deployment that never reports one, where + * failing closed would remove the builder CTAs for good, while this read is + * one request that answers or fails per visit. The entry is a shortcut, not + * the only way in (Studio's landing lists every package), so a missing entry + * costs a click; an entry drawn without a verdict is the dead end this gate + * exists to remove. The server's write gate stays the authority either way. + * + * ## Cost + * + * One `GET /api/v1/packages` per package asked about, and only for a principal + * who passes (1): the list is read when the asked package changes (an app + * switch), never per render. Readers mounted in one commit (the app bar and + * the sidebar's rows) share one request through `fetchPackages`' in-flight + * read (objectui#11797). Nothing caches the answer: `fetchPackages` is + * deliberately not a response cache, and no provider holds the package list + * outside Studio. + * + * @module + */ + +import { useEffect, useState } from 'react'; +import { useWorkspaceAdminStatus } from '@object-ui/auth'; +import { useCanAuthorMetadata } from './useCanAuthorMetadata.js'; +import { fetchPackages, type PkgEntry } from '../views/studio-design/packages-io.js'; + +/** + * The package a Studio path opens: its first segment after `/studio/`, decoded + * the way `appStudioRoutePath` encoded it. `null` for a path that names none. + */ +export function studioPathPackageId(path: string | null | undefined): string | null { + const segment = path ? /^\/studio\/([^/?#]+)/.exec(path)?.[1] : undefined; + if (!segment) return null; + try { + return decodeURIComponent(segment); + } catch { + return null; + } +} + +/** + * May `packageId` be designed? Only when Studio's package list carries it with + * a writable verdict; a package the list leaves out (a kernel package) is not. + */ +export function isDesignablePackage(packages: readonly PkgEntry[], packageId: string): boolean { + return packages.some((pkg) => pkg.id === packageId && pkg.writable); +} + +/** + * The verdict for `packageId`, read once per id. It carries the id it answers + * for, so an answer for the previous app is never read as this one's. + */ +function usePackageDesignable(packageId: string | null): boolean { + const [verdict, setVerdict] = useState<{ packageId: string; designable: boolean } | null>(null); + useEffect(() => { + if (packageId === null) return undefined; + let cancelled = false; + fetchPackages().then( + (packages) => { + if (!cancelled) setVerdict({ packageId, designable: isDesignablePackage(packages, packageId) }); + }, + () => { + if (!cancelled) setVerdict({ packageId, designable: false }); + }, + ); + return () => { + cancelled = true; + }; + }, [packageId]); + return packageId !== null && verdict?.packageId === packageId && verdict.designable; +} + +/** `path` when this principal may design the package it opens, else `null`. */ +export function useStudioDesignGate(path: string | null | undefined): string | null { + const { isAdmin } = useWorkspaceAdminStatus(); + const canAuthorMetadata = useCanAuthorMetadata(); + const packageId = isAdmin && canAuthorMetadata ? studioPathPackageId(path) : null; + const designable = usePackageDesignable(packageId); + return designable && path ? path : null; +} diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index a9f24b57b2..f5b2acaa3b 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -82,7 +82,7 @@ import type { ConnectionState } from '@object-ui/data-objectstack'; import { useAdapter } from '../providers/AdapterProvider.js'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import type { BreadcrumbItem as BreadcrumbItemType } from '@object-ui/types'; -import { useAuth, getUserInitials, useWorkspaceAdminStatus } from '@object-ui/auth'; +import { useAuth, getUserInitials } from '@object-ui/auth'; import { useMetadata } from '../providers/MetadataProvider.js'; import { resolveKeyedI18nLabel, preferLocal, matchAppBySegment, appRouteSegment, appStudioRoutePath } from '../utils/index.js'; import { getIcon } from '../utils/getIcon.js'; @@ -92,7 +92,7 @@ import { useCommandPalette, useCommandPaletteProviderMounted } from '../context/ import { useUrlOverlay } from '../hooks/useUrlOverlay.js'; import { KEYBOARD_SHORTCUTS_PARAM } from '../urlParams.js'; import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js'; -import { useCanAuthorMetadata } from '../hooks/useCanAuthorMetadata.js'; +import { useStudioDesignGate } from '../hooks/useStudioDesignGate.js'; import { useSharedActivityFeed, type ActivityFeedReading } from '../hooks/sharedUserFeeds.js'; import { useInboxBell } from '../hooks/useInboxBell.js'; import { useHomePath } from '../hooks/useHomePath.js'; @@ -201,12 +201,6 @@ export function AppHeader({ // no AI (Community Edition) so it can't dead-end on a chat with no agent. // Same signal as the FAB and the `/ai` route guard. const { enabled: aiEnabled } = useAiSurfaceEnabled(); - // Design entry points mutate shared package metadata, so the app → Studio - // bridge below is admin-only (mirrors the runtime view/page editors) AND - // needs the server-reported metadata-authoring capability — see - // `canDesignInStudio` below (objectui#10899). - const { isAdmin: isWorkspaceAdmin } = useWorkspaceAdminStatus(); - const canAuthorMetadata = useCanAuthorMetadata(); const { t } = useObjectTranslation(); const { objectPluralLabel, dashboardLabel, pageLabel, reportLabel, viewLabel, appLabel } = useObjectLabel(); const { apps: metadataApps, dashboards: metadataDashboards, pages: metadataPages, reports: metadataReports } = useMetadata(); @@ -359,7 +353,7 @@ export function AppHeader({ // App → Studio reverse bridge (ADR-0080): admins jump from the running app // to its owning package's design surface. Null when there is nothing to open - // (non-admin, or no owning package). When the current route names a specific + // (no owning package, or a principal or package the gate below refuses). When the current route names a specific // interface (a dashboard, page, or report), deep-link straight to THAT surface // in the Interfaces pillar instead of the package's generic Data tab — the // surface's design page replaces the retired in-page inline editor. The route @@ -367,16 +361,17 @@ export function AppHeader({ // (absent on the interface list routes, which fall back to the Data tab); the // mapping lives in `appStudioRoutePath`. // - // Who may cross the bridge is the SERVER's answer, not the role's: an - // organization owner is a workspace admin, yet `organization_admin` - // deliberately withholds `manage_metadata` (ADR-0066), and on the cloud - // control plane the hammer opened the platform's own metadata to a - // signed-up customer (objectui#10899). Same doctrine as HomePage's builder - // CTAs; the server still refuses the write either way. - const canDesignInStudio = isWorkspaceAdmin && canAuthorMetadata; - const studioDesignPath = isApp - ? appStudioRoutePath(currentApp, canDesignInStudio, { type: routeType, name: pathParts[3] }) - : null; + // Who may cross the bridge, and into which package, is the SERVER's answer: + // a workspace admin holding `manage_metadata` (an organization owner is a + // workspace admin, yet `organization_admin` deliberately withholds it, + // ADR-0066, objectui#10899), into a package Studio lists as writable + // (objectui#12615). A read-only package, and the console's own Setup, Studio + // and Account apps, get no bridge: Studio would open them browse-only, with + // nothing to edit. `useStudioDesignGate` holds both halves for this bar and + // the sidebar's Design entry; the server still refuses the write either way. + const studioDesignPath = useStudioDesignGate( + isApp ? appStudioRoutePath(currentApp, true, { type: routeType, name: pathParts[3] }) : null, + ); // The crumbs drawn after the app pill from `sm` up: a report or `system/*` // route's. @@ -761,8 +756,9 @@ export function AppHeader({ surface; on a dashboard route it deep-links to that dashboard's design page in the Interfaces pillar (/studio/:packageId/interfaces?surface=dashboard:NAME), otherwise - the package's Data tab. Hidden for everyone else and for apps - with no owning package. An icon button at every width, like the + the package's Data tab. Hidden for everyone else, for apps + with no owning package and for a package that is not writable + (objectui#12615): not rendered, never disabled. An icon button at every width, like the bell beside it (objectui#12461): the pencil alone, named 「Edit app」 by its aria-label and its tooltip. */} {studioDesignPath && ( diff --git a/packages/app-shell/src/layout/SidebarItemMenu.tsx b/packages/app-shell/src/layout/SidebarItemMenu.tsx index 2341f0ea95..7ff62ef078 100644 --- a/packages/app-shell/src/layout/SidebarItemMenu.tsx +++ b/packages/app-shell/src/layout/SidebarItemMenu.tsx @@ -11,9 +11,11 @@ * `action` entry runs something and has no address to open or copy). * 3. Design, for an entry whose target has a designer: it opens what the app * bar's Edit app opens for that item, the item's Studio surface - * ({@link sidebarItemDesignPath}, objectui#12520). Shown only to a principal - * who may author metadata: `isAdmin && canAuthorMetadata`, the gate the app - * bar's Edit app reads. The server refuses the write either way; this is the + * ({@link sidebarItemDesignPath}, objectui#12520). Shown on the rule the app + * bar's Edit app follows, `useStudioDesignGate` (objectui#12615): to a + * principal who may author metadata, for a package Studio lists as + * writable. A read-only package and the console's own system apps get no + * Design entry. The server refuses the write either way; this is the * presentational half. * * The trigger is hover-revealed, as the pin icon it replaces was @@ -40,8 +42,7 @@ import { ExternalLink, Link2, MoreHorizontal, PencilRuler, Star, StarOff } from import { toast } from 'sonner'; import type { NavigationItem } from '@object-ui/types'; import { useObjectTranslation } from '@object-ui/i18n'; -import { useWorkspaceAdminStatus } from '@object-ui/auth'; -import { useCanAuthorMetadata } from '../hooks/useCanAuthorMetadata.js'; +import { useStudioDesignGate } from '../hooks/useStudioDesignGate.js'; import { useFavorites } from '../hooks/useFavorites.js'; import { useNavPins } from '../hooks/useNavPins.js'; import { useNavMemberGates, navEntriesShownToMember } from '../hooks/useNavMemberGates.js'; @@ -76,8 +77,9 @@ function designableRoute( * has no owning package Studio can open, so the entry hides as the app bar's * pencil does; there is no other destination to fall back to. * - * The principal gate is not read here: `SidebarItemMenu` reads it where the - * entry is drawn, so this answers for a principal who passes it. + * The gate is not read here: `SidebarItemMenu` reads it where the entry is + * drawn (`useStudioDesignGate`, the app bar's), so this answers for a principal + * and a package that pass it. */ function studioDesignPath( app: NavApp | null | undefined, @@ -260,8 +262,10 @@ export interface SidebarItemMenuProps { onToggleFavorite: () => void; /** * The router path of the entry's designer ({@link sidebarItemDesignPath}). - * The Design entry shows only when this is set AND the viewer may author - * metadata; the gate is read here, so no host can forget it. + * The Design entry shows only when this is set AND the app bar's gate passes + * it (`useStudioDesignGate`: the viewer may author metadata, and the package + * the path opens is writable); the gate is read here, so no host can forget + * it. */ designPath?: string | null; } @@ -287,8 +291,8 @@ export function SidebarItemMenu({ }: SidebarItemMenuProps) { const { t } = useObjectTranslation(); const navigate = useNavigate(); - const { isAdmin } = useWorkspaceAdminStatus(); - const canAuthorMetadata = useCanAuthorMetadata(); + // objectui#12615 — the app bar's own gate, on the package this path opens. + const gatedDesignPath = useStudioDesignGate(designPath); // A router path is opened and copied as the href the row's own `Link` // renders for it, which carries the basename the host mounted the console // under (`/_console`): a new tab and a copied link resolve against the @@ -315,7 +319,6 @@ export function SidebarItemMenu({ }, []); const documentAddress = !href ? null : offConsole ? href : routerHref; - const canDesign = !!designPath && isAdmin && canAuthorMetadata; const copyLink = async () => { if (!documentAddress) return; @@ -356,10 +359,10 @@ export function SidebarItemMenu({ {t('console.nav.copyLink', { defaultValue: 'Copy link' })} )} - {canDesign && ( + {gatedDesignPath && ( { if (designPath) navigate(designPath); }} + onSelect={() => navigate(gatedDesignPath)} > {t('console.nav.design', { defaultValue: 'Design' })} diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.appBarLeftGroup-12461.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.appBarLeftGroup-12461.test.tsx index 8ab38d0acb..3f09e27242 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.appBarLeftGroup-12461.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.appBarLeftGroup-12461.test.tsx @@ -199,18 +199,30 @@ const crumbLabels = () => separators().map((sep) => (sep.parentElement?.textCont const pillOf = () => screen.getByTestId('app-switcher-stub').parentElement!; beforeEach(() => { - vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ data: [] }) }))); + // objectui#12615 — the app's package is served writable, so the Edit app + // gate's package half passes and this file's own questions are the ones asked. + vi.stubGlobal( + 'fetch', + vi.fn(async (url: unknown) => ({ + ok: true, + status: 200, + json: async () => + String(url).endsWith('/api/v1/packages') + ? { data: { packages: [{ manifest: { id: 'com.example.crm', name: 'CRM' }, writable: true }] } } + : { data: [] }, + })), + ); }); describe('the app bar on the Leads list (objectui#12461)', () => { - it('renders the pill, the PREVIEW chip, the search and an icon-only 「Edit app」', () => { + it('renders the pill, the PREVIEW chip, the search and an icon-only 「Edit app」', async () => { mountAt('/apps/crm/lead'); expect(screen.getByTestId('app-switcher-stub')).toHaveAttribute('data-active', 'crm'); expect(screen.getByTestId('platform-preview-badge')).toHaveTextContent(/preview/i); expect(screen.getByTestId('action:command-palette:open')).toBeInTheDocument(); - const editApp = screen.getByRole('link', { name: 'Edit app' }); + const editApp = await screen.findByRole('link', { name: 'Edit app' }); expect(editApp).toHaveAttribute('data-testid', 'app-design-in-studio-button'); expect(editApp).toHaveAttribute('title', 'Edit app'); expect(editApp.getAttribute('href')).toContain('/studio/com.example.crm/'); @@ -247,10 +259,10 @@ describe('the app bar on the Leads list (objectui#12461)', () => { expect(screen.queryByText('Switch Object')).toBeNull(); }); - it('Edit app is an icon button at every width — no text, nothing that grows one', () => { + it('Edit app is an icon button at every width — no text, nothing that grows one', async () => { mountAt('/apps/crm/lead'); - const editApp = screen.getByRole('link', { name: 'Edit app' }); + const editApp = await screen.findByRole('link', { name: 'Edit app' }); expect(editApp.textContent?.trim()).toBe(''); expect(editApp.querySelector('svg')).not.toBeNull(); const classes = [editApp, ...editApp.querySelectorAll('*')].flatMap((el) => diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.appIdentity-12201.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.appIdentity-12201.test.tsx index 70a20727b4..a75beee6a9 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.appIdentity-12201.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.appIdentity-12201.test.tsx @@ -208,7 +208,19 @@ const follows = (a: Node, b: Node) => Boolean(a.compareDocumentPosition(b) & Nod beforeEach(() => { viewer.isAdmin = true; - vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ data: [] }) }))); + // objectui#12615 — the app's package is served writable, so the Edit app + // gate's package half passes and this file's own questions are the ones asked. + vi.stubGlobal( + 'fetch', + vi.fn(async (url: unknown) => ({ + ok: true, + status: 200, + json: async () => + String(url).endsWith('/api/v1/packages') + ? { data: { packages: [{ manifest: { id: 'com.objectstack.cloud', name: 'Cloud' }, writable: true }] } } + : { data: [] }, + })), + ); }); describe('inside an app the bar starts with the app pill (objectui#12201 item 4)', () => { @@ -261,9 +273,9 @@ describe('the PREVIEW chip (objectui#12201 item 5, objectui#12461)', () => { }); describe('the Studio entry 「Edit app」 (objectui#12201 item 6, objectui#12461)', () => { - it('a pencil icon button at every width, named and tooltipped 「Edit app」 (objectui#12461)', () => { + it('a pencil icon button at every width, named and tooltipped 「Edit app」 (objectui#12461)', async () => { mountApp(); - const bridge = screen.getByTestId('app-design-in-studio-button'); + const bridge = await screen.findByTestId('app-design-in-studio-button'); expect(bridge).toHaveAttribute('data-variant', 'ghost'); expect(bridge.textContent?.trim()).toBe(''); expect(bridge.className.split(' ')).toEqual(expect.arrayContaining(['h-8', 'w-8'])); @@ -272,18 +284,18 @@ describe('the Studio entry 「Edit app」 (objectui#12201 item 6, objectui#12461 expect(bridge).toHaveAttribute('title', 'Edit app'); }); - it('sits at the left edge of the right-hand utility cluster, before the bell', () => { + it('sits at the left edge of the right-hand utility cluster, before the bell', async () => { mountApp(); - const bridge = screen.getByTestId('app-design-in-studio-button'); + const bridge = await screen.findByTestId('app-design-in-studio-button'); const group = bridge.parentElement!; expect(group).toHaveAttribute('data-topbar-group'); expect(group.firstElementChild).toBe(bridge); expect(follows(bridge, screen.getByTestId('inbox-stub'))).toBe(true); }); - it('the pill\'s Studio row gets the button\'s very target — same gate, same deep link', () => { + it('the pill\'s Studio row gets the button\'s very target — same gate, same deep link', async () => { mountApp(); - const href = screen.getByTestId('app-design-in-studio-button').querySelector('a')!.getAttribute('href'); + const href = (await screen.findByTestId('app-design-in-studio-button')).querySelector('a')!.getAttribute('href'); expect(href).toContain('/studio/com.objectstack.cloud/'); expect(screen.getByTestId('app-switcher-stub')).toHaveAttribute('data-studio-path', href!); }); diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.centeredSearch-12472.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.centeredSearch-12472.test.tsx index 13f65dcf56..0b32a98f00 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.centeredSearch-12472.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.centeredSearch-12472.test.tsx @@ -182,7 +182,19 @@ const rootOf = () => clusterOf().parentElement!; const leftOf = () => rootOf().firstElementChild!; beforeEach(() => { - vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ data: [] }) }))); + // objectui#12615 — the app's package is served writable, so the Edit app + // gate's package half passes and this file's own questions are the ones asked. + vi.stubGlobal( + 'fetch', + vi.fn(async (url: unknown) => ({ + ok: true, + status: 200, + json: async () => + String(url).endsWith('/api/v1/packages') + ? { data: { packages: [{ manifest: { id: 'com.example.crm', name: 'CRM' }, writable: true }] } } + : { data: [] }, + })), + ); }); describe('inside an app, from `lg` up, the search is the bar\'s middle zone (objectui#12472)', () => { @@ -255,7 +267,7 @@ describe('inside an app, from `lg` up, the search is the bar\'s middle zone (obj for (const box of chain) expect(classesOf(box)).toContain('min-w-0'); }); - it('the cluster\'s search group holds only the icon button and leaves at `lg`, with no divider after it', () => { + it('the cluster\'s search group holds only the icon button and leaves at `lg`, with no divider after it', async () => { mount(); const mobile = screen.getByTestId(MOBILE); @@ -267,7 +279,7 @@ describe('inside an app, from `lg` up, the search is the bar\'s middle zone (obj // The group after it is the Edit app group; the cluster's `lg:` rules, // keyed on `data-topbar-search`, drop the divider it would draw. - expect(group.nextElementSibling).toContainElement(screen.getByTestId('app-design-in-studio-button')); + expect(group.nextElementSibling).toContainElement(await screen.findByTestId('app-design-in-studio-button')); expect(classesOf(clusterOf())).toEqual( expect.arrayContaining([ 'lg:[&>[data-topbar-search]+[data-topbar-group]]:border-l-0', diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.designInStudioNeedsAuthoring-10899.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.designInStudioNeedsAuthoring-10899.test.tsx index 2012950069..156d75de2a 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.designInStudioNeedsAuthoring-10899.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.designInStudioNeedsAuthoring-10899.test.tsx @@ -179,7 +179,19 @@ function mount(perms: MePermissionsResponse) { beforeEach(() => { viewer.isAdmin = true; - vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ data: [] }) }))); + // objectui#12615 — the app's package is served writable, so the Edit app + // gate's package half passes and this file's own questions are the ones asked. + vi.stubGlobal( + 'fetch', + vi.fn(async (url: unknown) => ({ + ok: true, + status: 200, + json: async () => + String(url).endsWith('/api/v1/packages') + ? { data: { packages: [{ manifest: { id: 'com.objectstack.cloud', name: 'Cloud' }, writable: true }] } } + : { data: [] }, + })), + ); }); describe('the top-bar Studio bridge follows the server-reported authoring capability (objectui#10899)', () => { @@ -189,9 +201,9 @@ describe('the top-bar Studio bridge follows the server-reported authoring capabi expect(screen.queryByTestId('app-design-in-studio-button')).not.toBeInTheDocument(); }); - it('POSITIVE CONTROL — a workspace admin WITH `manage_metadata` keeps the hammer, deep-linked to the page surface', () => { + it('POSITIVE CONTROL — a workspace admin WITH `manage_metadata` keeps the hammer, deep-linked to the page surface', async () => { mount(AUTHOR); - const bridge = screen.getByTestId('app-design-in-studio-button'); + const bridge = await screen.findByTestId('app-design-in-studio-button'); expect(bridge.querySelector('a')?.getAttribute('href') ?? bridge.getAttribute('href')).toContain( '/studio/com.objectstack.cloud/', ); diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.editAppFollowsPackagePosture-12615.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.editAppFollowsPackagePosture-12615.test.tsx new file mode 100644 index 0000000000..b58797f58e --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/AppHeader.editAppFollowsPackagePosture-12615.test.tsx @@ -0,0 +1,342 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The app bar's Edit app pencil follows the owning package's posture + * (objectui#12615): it is drawn only for a principal who may author metadata + * (`AppHeader.designInStudioNeedsAuthoring-10899`) AND only when the app's + * package is one Studio lists as writable. A read-only package's app gets no + * pencil, and neither do the console's own system apps (Setup, Studio, + * Account): their packages are kernel packages the list leaves out. Absent, + * never disabled. The app switcher's Studio row takes the pencil's own + * target, so it follows the same rule. + * + * The package list is served the way `GET /api/v1/packages` serves it: one row + * per package, the manifest nested, and the server's `writable` verdict on the + * row (ADR-0070 D2). Each absence is read after the list has answered, beside + * a writable CONTROL read the same way, so an absence is a reading and not a + * read that has not landed yet. + * + * Harness: `AppHeader` in its `app` variant under the real router and the real + * `MePermissionsProvider`, seeded with a metadata author's `/me/permissions`. + * The chrome this card does not touch is stubbed the way + * `SidebarItemMenu.designOpensStudio-12520` stubs it. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import { SidebarMenu, SidebarMenuItem, SidebarProvider } from '@object-ui/components'; +import type { NavigationItem } from '@object-ui/types'; + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + language: 'en', + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), + useObjectLabel: () => { + const label = (x: { name?: string; label?: string } | string) => + typeof x === 'string' ? x : String(x?.label ?? x?.name ?? ''); + return { + objectLabel: label, + objectPluralLabel: label, + dashboardLabel: label, + pageLabel: label, + reportLabel: label, + viewLabel: (_o: string, _v: string, fallback?: string) => fallback ?? '', + appLabel: label, + }; + }, +})); + +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal>(); + const strip = (p: Record | undefined) => { + const { asChild: _a, variant: _v, size: _s, align: _al, sideOffset: _so, ...rest } = p ?? {}; + return rest; + }; + const Pass = ({ children, ...p }: { children?: React.ReactNode } & Record) => ( +
{children}
+ ); + return { + ...actual, + Button: ({ + children, + asChild, + variant: _v, + size: _s, + ...p + }: { children?: React.ReactNode; asChild?: boolean } & Record) => + asChild ? {children} : , + DropdownMenu: Pass, + DropdownMenuTrigger: Pass, + DropdownMenuContent: () => null, + DropdownMenuItem: Pass, + DropdownMenuLabel: Pass, + DropdownMenuSeparator: () => null, + DropdownMenuGroup: Pass, + Avatar: Pass, + AvatarImage: () => null, + AvatarFallback: Pass, + Popover: Pass, + PopoverTrigger: Pass, + PopoverContent: () => null, + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useOffline: () => ({ isOnline: true }), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + PresenceAvatars: () => null, + useTenantPresence: () => [], +})); + +vi.mock('../ModeToggle', () => ({ ModeToggle: () => null })); +vi.mock('../WorkspaceSwitcher', () => ({ WorkspaceSwitcher: () => null })); +vi.mock('../LocaleSwitcher', () => ({ LocaleSwitcher: () => null })); +vi.mock('../ConnectionStatus', () => ({ ConnectionStatus: () => null })); +vi.mock('../LocalizedSidebarTrigger', () => ({ LocalizedSidebarTrigger: () => null })); +vi.mock('../PreviewBadge', () => ({ PreviewBadge: () => null })); +vi.mock('../InboxPopover', () => ({ InboxPopover: () => null })); +vi.mock('../../hooks/useAiSurface', () => ({ + useAiSurfaceEnabled: () => ({ enabled: false, isLoading: false }), +})); +// Recorder: the target the header hands the pill's Studio row. +vi.mock('../AppSwitcher', () => ({ + AppSwitcher: (p: { designInStudioPath?: string | null }) => ( +
+ ), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u1', name: 'Lin Yue', email: 'linyue@example.com' }, + signOut: vi.fn(), + isAuthEnabled: true, + organizations: [], + activeOrganization: null, + isOrganizationsLoading: false, + getAuthConfig: undefined, + }), + getUserInitials: () => 'LY', + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); + +/** The console's own Setup app, in its kernel package (the card's walk). */ +const SETUP = { name: 'setup', label: 'Setup', _packageId: 'com.objectstack.setup' }; +/** A read-only code package's app (the card's walk: HotCRM). */ +const HOTCRM = { name: 'crm', label: 'CRM', _packageId: 'app.objectstack.hotcrm' }; +/** An app in a Studio-created base, which the server judges writable. */ +const BASE_APP = { name: 'my_app', label: 'My app', _packageId: 'com.example.my_base' }; +/** An app whose package the list does not name at all. */ +const UNLISTED = { name: 'gone', label: 'Gone', _packageId: 'com.example.unlisted' }; + +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ + apps: [SETUP, HOTCRM, BASE_APP, UNLISTED], + dashboards: [ + { name: 'system_overview', label: 'System overview' }, + { name: 'pipeline', label: 'Pipeline' }, + ], + pages: [], + reports: [], + }), +})); + +vi.mock('../../providers/AdapterProvider', () => ({ + useAdapter: () => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined }), +})); + +import { AppHeader } from '../AppHeader'; +import { SidebarItemMenu, sidebarItemDesignPath } from '../SidebarItemMenu'; + +const AUTHOR: MePermissionsResponse = { + authenticated: true, + userId: 'u1', + tenantId: 'org_1', + roles: [], + permissionSets: [], + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + objects: {}, + fields: {}, +}; +/** framework `organization_admin`: a workspace admin the server refuses `manage_metadata`. */ +const ORG_OWNER: MePermissionsResponse = { ...AUTHOR, systemPermissions: ['manage_org_users', 'setup.access'] }; + +/** One `GET /api/v1/packages` row, as the server's list door serves it. */ +const row = (manifest: Record, writable: boolean) => ({ + manifest, + status: 'installed', + enabled: true, + writable, +}); + +/** The list: the kernel Setup package, a read-only code package, a writable base. */ +const PACKAGES = { + success: true, + data: { + packages: [ + row({ id: 'com.objectstack.setup', name: 'Setup', type: 'app', scope: 'system' }, false), + row({ id: 'app.objectstack.hotcrm', name: 'HotCRM', type: 'app', scope: 'project' }, false), + row({ id: 'com.example.my_base', name: 'My Base' }, true), + ], + }, +}; + +const packageReads = (fetchMock: ReturnType) => + fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/api/v1/packages')).length; + +function serve(answer: () => Promise) { + const fetchMock = vi.fn(async (url: unknown) => { + if (String(url).endsWith('/api/v1/packages')) { + const body = await answer(); + return { ok: true, status: 200, json: async () => body }; + } + return { ok: true, status: 200, json: async () => ({ data: [] }) }; + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +/** One macrotask: every read the stub answers has settled. */ +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); + +function mountAt(pathname: string, perms: MePermissionsResponse = AUTHOR) { + const segment = pathname.split('/').filter(Boolean)[1]; + return render( + + + + + , + ); +} + +/** What the bar offers on `pathname`, once the list has answered. */ +async function editAppOn(pathname: string): Promise<{ pencil: string | null; switcherRow: string }> { + mountAt(pathname); + await settle(); + const button = screen.queryByTestId('app-design-in-studio-button'); + const reading = { + pencil: button ? (button.querySelector('a')?.getAttribute('href') ?? '') : null, + switcherRow: screen.getByTestId('app-switcher-stub').getAttribute('data-studio-path') ?? '', + }; + // Absent, not disabled: no element is named for the pencil at all. + if (reading.pencil === null) expect(screen.queryByLabelText('Edit app')).toBeNull(); + cleanup(); + return reading; +} + +const SETUP_ROUTE = '/apps/com.objectstack.setup/dashboard/system_overview'; +const HOTCRM_ROUTE = '/apps/app.objectstack.hotcrm/dashboard/pipeline'; +const BASE_ROUTE = '/apps/com.example.my_base/dashboard/pipeline'; +const BASE_PENCIL = '/studio/com.example.my_base/interfaces?surface=dashboard:pipeline'; + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = serve(async () => PACKAGES); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('the app bar\'s Edit app follows the owning package\'s posture (objectui#12615)', () => { + it('no pencil, and no switcher Studio row, inside the Setup app (`com.objectstack.setup`)', async () => { + expect(await editAppOn(SETUP_ROUTE)).toEqual({ pencil: null, switcherRow: '' }); + }); + + it('no pencil for a package whose posture is read-only (HotCRM, `writable: false`)', async () => { + expect(await editAppOn(HOTCRM_ROUTE)).toEqual({ pencil: null, switcherRow: '' }); + }); + + it('CONTROL — the pencil for a writable package, deep-linked, and the switcher row to the same target', async () => { + expect(await editAppOn(BASE_ROUTE)).toEqual({ pencil: BASE_PENCIL, switcherRow: BASE_PENCIL }); + }); + + it('no pencil for a package the list does not name', async () => { + expect(await editAppOn('/apps/com.example.unlisted/dashboard/pipeline')).toEqual({ pencil: null, switcherRow: '' }); + }); + + it('a system package served in the registry\'s flat shape answers no pencil either', async () => { + // The registry half of the list door hands back the package document with + // its fields top level; the list's id reader takes both positions, and the + // server's verdict on the row says read-only. + serve(async () => ({ + data: { packages: [{ id: 'com.objectstack.setup', name: 'Setup', scope: 'system', writable: false }] }, + })); + expect(await editAppOn(SETUP_ROUTE)).toEqual({ pencil: null, switcherRow: '' }); + }); +}); + +describe('no verdict, no pencil (objectui#12615)', () => { + it('absent while the list is being read, drawn once it answers writable', async () => { + let answer: (body: unknown) => void = () => undefined; + serve(() => new Promise((resolve) => { answer = resolve; })); + mountAt(BASE_ROUTE); + await settle(); + expect(screen.queryByTestId('app-design-in-studio-button')).toBeNull(); + expect(screen.queryByLabelText('Edit app')).toBeNull(); + await act(async () => { answer(PACKAGES); }); + expect((await screen.findByTestId('app-design-in-studio-button')).querySelector('a')).toHaveAttribute('href', BASE_PENCIL); + }); + + it('a failed list read draws no pencil', async () => { + vi.stubGlobal('fetch', vi.fn(async (url: unknown) => + String(url).endsWith('/api/v1/packages') + ? { ok: false, status: 403, json: async () => ({ success: false, error: { code: 'FORBIDDEN', message: 'no' } }) } + : { ok: true, status: 200, json: async () => ({ data: [] }) }, + )); + expect(await editAppOn(BASE_ROUTE)).toEqual({ pencil: null, switcherRow: '' }); + }); +}); + +describe('what the posture read costs (objectui#12615)', () => { + it('a principal the capability half refuses never reads the package list', async () => { + mountAt(BASE_ROUTE, ORG_OWNER); + await settle(); + expect(screen.queryByTestId('app-design-in-studio-button')).toBeNull(); + expect(packageReads(fetchMock)).toBe(0); + }); + + it('the bar and the sidebar rows mounted with it share one read', async () => { + const entries: NavigationItem[] = ['lead', 'account', 'contact'].map( + (objectName) => ({ id: `nav_${objectName}`, type: 'object', objectName, label: objectName }) as NavigationItem, + ); + render( + + + + + + {entries.map((item) => ( + + + + ))} + + + + , + ); + await settle(); + expect(await screen.findByTestId('app-design-in-studio-button')).toBeInTheDocument(); + expect(packageReads(fetchMock)).toBe(1); + }); +}); diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.pageRouteNoAuthoringHandles-12198.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.pageRouteNoAuthoringHandles-12198.test.tsx index 20a5971974..ca81794350 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.pageRouteNoAuthoringHandles-12198.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.pageRouteNoAuthoringHandles-12198.test.tsx @@ -35,7 +35,7 @@ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { act, render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; @@ -239,6 +239,9 @@ async function studioEntriesOnPageRoute(perms: MePermissionsResponse): Promise new Promise((resolve) => setTimeout(resolve, 0))); const button = hrefOf(screen.queryByTestId('app-design-in-studio-button')); const row = hrefOf(screen.queryByTestId('app-switcher-edit-in-studio')); header.unmount(); @@ -285,7 +288,19 @@ function crumbLabels(): string[] { beforeEach(() => { viewer.isAdmin = true; - vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ data: [] }) }))); + // objectui#12615 — the app's package is served writable, so the Edit app + // gate's package half passes and this file's own questions are the ones asked. + vi.stubGlobal( + 'fetch', + vi.fn(async (url: unknown) => ({ + ok: true, + status: 200, + json: async () => + String(url).endsWith('/api/v1/packages') + ? { data: { packages: [{ manifest: { id: 'com.objectstack.cloud', name: 'Cloud' }, writable: true }] } } + : { data: [] }, + })), + ); }); describe('a page route\'s breadcrumb (objectui#12198, objectui#12526)', () => { diff --git a/packages/app-shell/src/layout/__tests__/SidebarItemMenu-12427.test.tsx b/packages/app-shell/src/layout/__tests__/SidebarItemMenu-12427.test.tsx index e79ee0075a..6bed62bd54 100644 --- a/packages/app-shell/src/layout/__tests__/SidebarItemMenu-12427.test.tsx +++ b/packages/app-shell/src/layout/__tests__/SidebarItemMenu-12427.test.tsx @@ -13,13 +13,15 @@ * and the real `resolveHref` of `@object-ui/layout` for the entry's address; * the translator returns each call site's inline default, and the two * principal answers are stand-ins. The entry's app is owned by a package, so - * Design has a Studio surface to open (objectui#12520). + * Design has a Studio surface to open (objectui#12520), and the package list + * serves that package writable, so the gate's package half passes + * (objectui#12615). */ import '@testing-library/jest-dom/vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import React from 'react'; -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import type { NavigationItem } from '@object-ui/types'; @@ -108,7 +110,11 @@ function renderRow({ item = LEADS, isFavorite = false, onToggleFavorite = vi.fn( const trigger = () => screen.getByTestId('sidebar-item-menu-trigger'); +/** objectui#12615 — one macrotask, so the package list's verdict has settled. */ +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); + async function openMenu(): Promise { + await settle(); fireEvent.keyDown(trigger(), { key: 'Enter' }); const items = await screen.findAllByRole('menuitem'); return items.map((el) => el.textContent ?? ''); @@ -117,12 +123,26 @@ async function openMenu(): Promise { beforeEach(() => { viewer.isAdmin = true; viewer.canAuthorMetadata = true; + // objectui#12615 — the app's package is served writable, so the Design + // gate's package half passes and this file's own questions are the ones asked. + vi.stubGlobal( + 'fetch', + vi.fn(async (url: unknown) => ({ + ok: true, + status: 200, + json: async () => + String(url).endsWith('/api/v1/packages') + ? { data: { packages: [{ manifest: { id: 'com.acme.crm', name: 'CRM' }, writable: true }] } } + : { data: [] }, + })), + ); toastSpy.success.mockReset(); toastSpy.error.mockReset(); }); afterEach(() => { cleanup(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -216,6 +236,7 @@ describe('SidebarItemMenu: entries an entry type does not offer (objectui#12427) describe('SidebarItemMenu: right-click (objectui#12427)', () => { it('a right-click on the row opens the same menu and keeps the browser menu shut', async () => { renderRow(); + await settle(); expect(screen.queryAllByRole('menuitem')).toHaveLength(0); const notPrevented = fireEvent.contextMenu(screen.getByTestId('row-link')); expect(notPrevented).toBe(false); diff --git a/packages/app-shell/src/layout/__tests__/SidebarItemMenu.designFollowsPackagePosture-12615.test.tsx b/packages/app-shell/src/layout/__tests__/SidebarItemMenu.designFollowsPackagePosture-12615.test.tsx new file mode 100644 index 0000000000..68804608a7 --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/SidebarItemMenu.designFollowsPackagePosture-12615.test.tsx @@ -0,0 +1,162 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The sidebar ⋯ menu's Design entry follows the app bar's Edit app rule + * (objectui#12615): it is listed only when the package the entry's designer + * opens is one Studio lists as writable, for a principal who may author + * metadata. A Setup entry and a read-only package's entry list no Design; a + * writable base's entry does. A favorite is judged by the package its own + * designer opens, which need not be the sidebar's. + * + * Harness: the real component in a real `SidebarMenuItem` row under the real + * router, as `SidebarItemMenu-12427` draws it; the two principal answers are + * stand-ins and the package list is served the way `GET /api/v1/packages` + * serves it. Each menu is opened after the list has answered. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { NavigationItem } from '@object-ui/types'; + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => + String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_hole, name: string) => + String(options?.[name] ?? ''), + ), + language: 'en', + }), +})); + +const viewer = vi.hoisted(() => ({ isAdmin: true, canAuthorMetadata: true })); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useWorkspaceAdminStatus: () => ({ isAdmin: viewer.isAdmin, isResolved: true }), +})); +vi.mock('../../hooks/useCanAuthorMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useCanAuthorMetadata: () => viewer.canAuthorMetadata, +})); + +import { SidebarMenu, SidebarMenuItem, SidebarProvider } from '@object-ui/components'; +import { SidebarItemMenu, sidebarItemDesignPath, favoriteDesignPath } from '../SidebarItemMenu'; +import { appStudioRoutePath } from '../../utils/appRoute'; +import { studioPathPackageId } from '../../hooks/useStudioDesignGate'; + +const SETUP = { name: 'setup', _packageId: 'com.objectstack.setup' }; +const HOTCRM = { name: 'crm', _packageId: 'app.objectstack.hotcrm' }; +const BASE_APP = { name: 'my_app', _packageId: 'com.example.my_base' }; +const APPS = [SETUP, HOTCRM, BASE_APP]; + +/** `GET /api/v1/packages`: the kernel Setup package, a read-only code package, a writable base. */ +const PACKAGES = { + success: true, + data: { + packages: [ + { manifest: { id: 'com.objectstack.setup', name: 'Setup', scope: 'system' }, writable: false }, + { manifest: { id: 'app.objectstack.hotcrm', name: 'HotCRM', scope: 'project' }, writable: false }, + { manifest: { id: 'com.example.my_base', name: 'My Base' }, writable: true }, + ], + }, +}; + +const LEADS = { id: 'nav_leads', type: 'object', objectName: 'lead', label: 'Leads' } as NavigationItem; + +/** One sidebar row whose menu's Design opens `designPath`. */ +function renderRow(designPath: string | null) { + return render( + + + + + + + + + , + ); +} + +/** One macrotask: every read the stub answers has settled. */ +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); + +/** The menu's entries, opened once the package list has answered. */ +async function menuOf(designPath: string | null): Promise { + renderRow(designPath); + await settle(); + fireEvent.keyDown(screen.getByTestId('sidebar-item-menu-trigger'), { key: 'Enter' }); + const items = (await screen.findAllByRole('menuitem')).map((el) => el.textContent ?? ''); + cleanup(); + return items; +} + +const WITH_DESIGN = ['Add to favorites', 'Open in new tab', 'Copy link', 'Design']; +const WITHOUT_DESIGN = ['Add to favorites', 'Open in new tab', 'Copy link']; + +beforeEach(() => { + viewer.isAdmin = true; + viewer.canAuthorMetadata = true; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: unknown) => ({ + ok: true, + status: 200, + json: async () => (String(url).endsWith('/api/v1/packages') ? PACKAGES : { data: [] }), + })), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('the sidebar\'s Design entry follows the app bar\'s Edit app rule (objectui#12615)', () => { + it('no Design for an entry of the Setup app', async () => { + expect(await menuOf(sidebarItemDesignPath(LEADS, SETUP))).toEqual(WITHOUT_DESIGN); + }); + + it('no Design for an entry of a read-only package\'s app', async () => { + expect(await menuOf(sidebarItemDesignPath(LEADS, HOTCRM))).toEqual(WITHOUT_DESIGN); + }); + + it('CONTROL — Design for an entry of a writable base\'s app', async () => { + expect(await menuOf(sidebarItemDesignPath(LEADS, BASE_APP))).toEqual(WITH_DESIGN); + }); + + it('a favorite is judged by the package its own designer opens', async () => { + const readOnly = favoriteDesignPath({ id: 'object:deal', href: '/apps/app.objectstack.hotcrm/deal' }, APPS); + const writable = favoriteDesignPath({ id: 'object:deal', href: '/apps/com.example.my_base/deal' }, APPS); + expect(await menuOf(readOnly)).toEqual(WITHOUT_DESIGN); + expect(await menuOf(writable)).toEqual(WITH_DESIGN); + }); + + it('the principal half still stands: no Design for an admin the server refuses authoring', async () => { + viewer.canAuthorMetadata = false; + expect(await menuOf(sidebarItemDesignPath(LEADS, BASE_APP))).toEqual(WITHOUT_DESIGN); + }); +}); + +describe('studioPathPackageId reads the package back off the path appStudioRoutePath builds (objectui#12615)', () => { + it('for each route shape the bridge builds', () => { + for (const route of [{ type: 'dashboard', name: 'pipeline' }, { type: 'lead' }, { type: 'system' }]) { + expect(studioPathPackageId(appStudioRoutePath(BASE_APP, true, route))).toBe('com.example.my_base'); + } + }); + + it('a path that names no Studio package answers null', () => { + expect(studioPathPackageId(null)).toBeNull(); + expect(studioPathPackageId('/apps/com.example.my_base/lead')).toBeNull(); + expect(studioPathPackageId('/studio')).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/layout/__tests__/SidebarItemMenu.designOpensStudio-12520.test.tsx b/packages/app-shell/src/layout/__tests__/SidebarItemMenu.designOpensStudio-12520.test.tsx index 02d7009371..4d0e952ff6 100644 --- a/packages/app-shell/src/layout/__tests__/SidebarItemMenu.designOpensStudio-12520.test.tsx +++ b/packages/app-shell/src/layout/__tests__/SidebarItemMenu.designOpensStudio-12520.test.tsx @@ -21,13 +21,15 @@ * * Harness: `AppHeader` in its `app` variant under the real router and the real * `MePermissionsProvider`, seeded with a metadata author's `/me/permissions`. + * The package list serves both packaged apps' packages writable, so the gate's + * package half passes for both (objectui#12615). * The chrome this card does not touch is stubbed the way * `AppHeader.designInStudioNeedsAuthoring-10899` stubs it. */ import '@testing-library/jest-dom/vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, render, screen } from '@testing-library/react'; +import { act, cleanup, render, screen } from '@testing-library/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; @@ -127,8 +129,8 @@ vi.mock('@object-ui/auth', async (importOriginal) => ({ useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), })); -/** HotCRM's app, owned by its package, as on the card's reading. */ -const CRM = { name: 'crm', label: 'CRM', _packageId: 'app.objectstack.hotcrm' }; +/** A CRM app, owned by its package. */ +const CRM = { name: 'crm', label: 'CRM', _packageId: 'com.acme.crm' }; /** A second packaged app, where a favorite may have been starred. */ const SALES = { name: 'sales', label: 'Sales', _packageId: 'com.acme.sales' }; /** A runtime app with no owning package, and one in the DB-authored pseudo-package. */ @@ -173,7 +175,7 @@ const OBJECTS = [ * The href the app bar's Edit app button links to on `pathname`, in the app * the route names, or `null` when the bar draws no such button. */ -function appBarEditAppPath(pathname: string): string | null { +async function appBarEditAppPath(pathname: string): Promise { const segment = pathname.split('/').filter(Boolean)[1]; render( @@ -182,6 +184,9 @@ function appBarEditAppPath(pathname: string): string | null { , ); + // objectui#12615 — the pencil waits on the package list's verdict; one + // macrotask lets the stub's answer settle before the bar is read. + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); const button = screen.queryByTestId('app-design-in-studio-button'); const href = button ? (button.querySelector('a')?.getAttribute('href') ?? null) : null; cleanup(); @@ -197,7 +202,19 @@ const entryRoute = (item: NavigationItem, app: { _packageId?: string; name: stri resolveHref(item, `/apps/${app._packageId ?? app.name}`).href; beforeEach(() => { - vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ data: [] }) }))); + // objectui#12615 — both packaged apps' packages are served writable, so the + // gate's package half passes and this file's own questions are the ones asked. + vi.stubGlobal( + 'fetch', + vi.fn(async (url: unknown) => ({ + ok: true, + status: 200, + json: async () => + String(url).endsWith('/api/v1/packages') + ? { data: { packages: [{ manifest: { id: 'com.acme.crm', name: 'CRM' }, writable: true }, { manifest: { id: 'com.acme.sales', name: 'Sales' }, writable: true }] } } + : { data: [] }, + })), + ); }); afterEach(() => { @@ -206,21 +223,21 @@ afterEach(() => { }); describe('a navigation entry\'s Design opens what the app bar\'s Edit app opens (objectui#12520)', () => { - it('an object entry: the Data surface for the object, the bar\'s path on the object\'s list', () => { - const appBar = appBarEditAppPath(entryRoute(LEADS, CRM)); - expect(appBar).toBe('/studio/app.objectstack.hotcrm/data?surface=object:crm_lead'); + it('an object entry: the Data surface for the object, the bar\'s path on the object\'s list', async () => { + const appBar = await appBarEditAppPath(entryRoute(LEADS, CRM)); + expect(appBar).toBe('/studio/com.acme.crm/data?surface=object:crm_lead'); expect(sidebarItemDesignPath(LEADS, CRM)).toBe(appBar); }); - it('a dashboard entry: the Interfaces surface for the dashboard, the bar\'s path on the dashboard', () => { - const appBar = appBarEditAppPath(entryRoute(OVERVIEW, CRM)); - expect(appBar).toBe('/studio/app.objectstack.hotcrm/interfaces?surface=dashboard:sales_overview'); + it('a dashboard entry: the Interfaces surface for the dashboard, the bar\'s path on the dashboard', async () => { + const appBar = await appBarEditAppPath(entryRoute(OVERVIEW, CRM)); + expect(appBar).toBe('/studio/com.acme.crm/interfaces?surface=dashboard:sales_overview'); expect(sidebarItemDesignPath(OVERVIEW, CRM)).toBe(appBar); }); - it('a page entry: whatever the bar opens on the page', () => { - const appBar = appBarEditAppPath(entryRoute(WORKBENCH, CRM)); - expect(appBar).toBe('/studio/app.objectstack.hotcrm/interfaces?surface=page:lead_workbench'); + it('a page entry: whatever the bar opens on the page', async () => { + const appBar = await appBarEditAppPath(entryRoute(WORKBENCH, CRM)); + expect(appBar).toBe('/studio/com.acme.crm/interfaces?surface=page:lead_workbench'); expect(sidebarItemDesignPath(WORKBENCH, CRM)).toBe(appBar); }); }); @@ -228,43 +245,43 @@ describe('a navigation entry\'s Design opens what the app bar\'s Edit app opens describe('a favorite\'s Design opens what the app bar\'s Edit app opens once it is open (objectui#12520)', () => { const favorite = (id: string, href: string) => ({ id, href }); - it('object, dashboard and page favorites match the bar on the favorite\'s own route', () => { + it('object, dashboard and page favorites match the bar on the favorite\'s own route', async () => { const cases = [ - favorite('object:crm_lead', '/apps/app.objectstack.hotcrm/crm_lead'), - favorite('dashboard:sales_overview', '/apps/app.objectstack.hotcrm/dashboard/sales_overview'), - favorite('page:lead_workbench', '/apps/app.objectstack.hotcrm/page/lead_workbench'), + favorite('object:crm_lead', '/apps/com.acme.crm/crm_lead'), + favorite('dashboard:sales_overview', '/apps/com.acme.crm/dashboard/sales_overview'), + favorite('page:lead_workbench', '/apps/com.acme.crm/page/lead_workbench'), ]; for (const fav of cases) { - const appBar = appBarEditAppPath(fav.href); - expect(appBar, fav.id).toMatch(/^\/studio\/app\.objectstack\.hotcrm\//); + const appBar = await appBarEditAppPath(fav.href); + expect(appBar, fav.id).toMatch(/^\/studio\/com\.acme\.crm\//); expect(favoriteDesignPath(fav, APPS), fav.id).toBe(appBar); } }); - it('a favorite starred in another app resolves in that app\'s package, not the one the sidebar is in', () => { + it('a favorite starred in another app resolves in that app\'s package, not the one the sidebar is in', async () => { const fav = favorite('object:sales_quote', '/apps/com.acme.sales/sales_quote'); - const appBar = appBarEditAppPath(fav.href); + const appBar = await appBarEditAppPath(fav.href); expect(appBar).toBe('/studio/com.acme.sales/data?surface=object:sales_quote'); expect(favoriteDesignPath(fav, APPS)).toBe(appBar); }); it('record, report and app favorites have no designer', () => { - expect(favoriteDesignPath(favorite('record:crm_lead:1', '/apps/app.objectstack.hotcrm/crm_lead/record/1'), APPS)).toBeNull(); - expect(favoriteDesignPath(favorite('report:pipeline', '/apps/app.objectstack.hotcrm/report/pipeline'), APPS)).toBeNull(); - expect(favoriteDesignPath(favorite('app:crm', '/apps/app.objectstack.hotcrm'), APPS)).toBeNull(); + expect(favoriteDesignPath(favorite('record:crm_lead:1', '/apps/com.acme.crm/crm_lead/record/1'), APPS)).toBeNull(); + expect(favoriteDesignPath(favorite('report:pipeline', '/apps/com.acme.crm/report/pipeline'), APPS)).toBeNull(); + expect(favoriteDesignPath(favorite('app:crm', '/apps/com.acme.crm'), APPS)).toBeNull(); }); }); describe('no Studio surface ⇒ no Design entry, and no metadata-admin fallback (objectui#12520)', () => { - it('an entry of an app with no owning package: the bar draws no pencil, and Design answers null', () => { - expect(appBarEditAppPath(entryRoute(LEADS, RUNTIME))).toBeNull(); + it('an entry of an app with no owning package: the bar draws no pencil, and Design answers null', async () => { + expect(await appBarEditAppPath(entryRoute(LEADS, RUNTIME))).toBeNull(); expect(sidebarItemDesignPath(LEADS, RUNTIME)).toBeNull(); expect(sidebarItemDesignPath(OVERVIEW, RUNTIME)).toBeNull(); expect(sidebarItemDesignPath(WORKBENCH, RUNTIME)).toBeNull(); }); - it('an entry of an app in the DB-authored pseudo-package answers null the same way', () => { - expect(appBarEditAppPath(entryRoute(LEADS, DB_AUTHORED))).toBeNull(); + it('an entry of an app in the DB-authored pseudo-package answers null the same way', async () => { + expect(await appBarEditAppPath(entryRoute(LEADS, DB_AUTHORED))).toBeNull(); expect(sidebarItemDesignPath(LEADS, DB_AUTHORED)).toBeNull(); }); diff --git a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.favoritesMenu-12427.test.tsx b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.favoritesMenu-12427.test.tsx index e50af47464..ad3910180f 100644 --- a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.favoritesMenu-12427.test.tsx +++ b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.favoritesMenu-12427.test.tsx @@ -155,10 +155,12 @@ function Where() { function renderSidebar() { // Each app is owned by a package, so Design has a Studio surface to open - // (objectui#12520); `sales` is the app another favorite was starred in. + // (objectui#12520); `sales` is the app another favorite was starred in. Both + // packages are served writable (`beforeEach`), so the gate's package half + // passes (objectui#12615). metadataState = { apps: [ - { name: 'crm', label: 'CRM', active: true, _packageId: 'app.objectstack.hotcrm', navigation: NAVIGATION }, + { name: 'crm', label: 'CRM', active: true, _packageId: 'com.acme.crm', navigation: NAVIGATION }, { name: 'sales', label: 'Sales', active: true, _packageId: 'com.acme.sales', navigation: [] }, ], objects: [], @@ -195,6 +197,8 @@ const menuRows = () => ); async function openMenu(label: string, scope: HTMLElement = document.body): Promise { + // objectui#12615 — Design waits on the package list's verdict; let it settle. + await tick(); fireEvent.keyDown(within(scope).getByRole('button', { name: `More actions for ${label}` }), { key: 'Enter' }); return (await screen.findAllByRole('menuitem')).map((el) => el.textContent ?? ''); } @@ -203,12 +207,26 @@ beforeEach(() => { localStorage.clear(); viewer.isAdmin = true; viewer.canAuthorMetadata = true; + // objectui#12615 — both apps' packages are served writable, so the Design + // gate's package half passes and this file's own questions are the ones asked. + vi.stubGlobal( + 'fetch', + vi.fn(async (url: unknown) => ({ + ok: true, + status: 200, + json: async () => + String(url).endsWith('/api/v1/packages') + ? { data: { packages: [{ manifest: { id: 'com.acme.crm', name: 'CRM' }, writable: true }, { manifest: { id: 'com.acme.sales', name: 'Sales' }, writable: true }] } } + : { data: [] }, + })), + ); recent.items = []; setViewportWidth(1280); }); afterEach(() => { cleanup(); + vi.unstubAllGlobals(); setViewportWidth(1024); }); @@ -322,7 +340,7 @@ describe('UnifiedSidebar: Design opens the Studio surface the app bar opens (obj await openMenu('Leads'); fireEvent.click(screen.getByRole('menuitem', { name: 'Design' })); await waitFor(() => - expect(screen.getByTestId('where')).toHaveTextContent('/studio/app.objectstack.hotcrm/data?surface=object:crm_lead'), + expect(screen.getByTestId('where')).toHaveTextContent('/studio/com.acme.crm/data?surface=object:crm_lead'), ); }); From 9e74f5d2f69840debac457a6228acadde0a2622c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 09:32:02 +0000 Subject: [PATCH 2/4] test(app-shell): hold one adapter object in the app bar harnesses that now wait on the posture read The shared feeds key on the adapter's identity, so a stub that hands out a fresh adapter per render re-attaches them on every commit: a case that waits with the bar mounted never settles (measured: tens of thousands of commits in a minute, no new request). The right-click case waits for the verdict before it reads the menu. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .../__tests__/AppHeader.appBarLeftGroup-12461.test.tsx | 6 +++++- .../layout/__tests__/AppHeader.appIdentity-12201.test.tsx | 6 +++++- .../__tests__/AppHeader.centeredSearch-12472.test.tsx | 6 +++++- .../AppHeader.designInStudioNeedsAuthoring-10899.test.tsx | 6 +++++- .../AppHeader.editAppFollowsPackagePosture-12615.test.tsx | 6 +++++- .../AppHeader.pageRouteNoAuthoringHandles-12198.test.tsx | 6 +++++- .../SidebarItemMenu.designOpensStudio-12520.test.tsx | 6 +++++- .../__tests__/UnifiedSidebar.favoritesMenu-12427.test.tsx | 1 + 8 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.appBarLeftGroup-12461.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.appBarLeftGroup-12461.test.tsx index 3f09e27242..4bdb00c793 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.appBarLeftGroup-12461.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.appBarLeftGroup-12461.test.tsx @@ -138,8 +138,12 @@ vi.mock('../../providers/MetadataProvider', () => ({ }), })); +// One adapter object for the file: the shared feeds key on the adapter's +// identity, so a fresh object per render re-attaches them on every commit and +// a case that awaits with the bar mounted never settles (objectui#12615). +const ADAPTER = vi.hoisted(() => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined })); vi.mock('../../providers/AdapterProvider', () => ({ - useAdapter: () => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined }), + useAdapter: () => ADAPTER, })); import { AppHeader } from '../AppHeader'; diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.appIdentity-12201.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.appIdentity-12201.test.tsx index a75beee6a9..276256157c 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.appIdentity-12201.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.appIdentity-12201.test.tsx @@ -162,8 +162,12 @@ vi.mock('../../providers/MetadataProvider', () => ({ }), })); +// One adapter object for the file: the shared feeds key on the adapter's +// identity, so a fresh object per render re-attaches them on every commit and +// a case that awaits with the bar mounted never settles (objectui#12615). +const ADAPTER = vi.hoisted(() => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined })); vi.mock('../../providers/AdapterProvider', () => ({ - useAdapter: () => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined }), + useAdapter: () => ADAPTER, })); import { AppHeader } from '../AppHeader'; diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.centeredSearch-12472.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.centeredSearch-12472.test.tsx index 0b32a98f00..5a813e4e10 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.centeredSearch-12472.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.centeredSearch-12472.test.tsx @@ -129,8 +129,12 @@ vi.mock('../../providers/MetadataProvider', () => ({ }), })); +// One adapter object for the file: the shared feeds key on the adapter's +// identity, so a fresh object per render re-attaches them on every commit and +// a case that awaits with the bar mounted never settles (objectui#12615). +const ADAPTER = vi.hoisted(() => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined })); vi.mock('../../providers/AdapterProvider', () => ({ - useAdapter: () => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined }), + useAdapter: () => ADAPTER, })); import { AppHeader, type AppHeaderVariant } from '../AppHeader'; diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.designInStudioNeedsAuthoring-10899.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.designInStudioNeedsAuthoring-10899.test.tsx index 156d75de2a..041331d094 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.designInStudioNeedsAuthoring-10899.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.designInStudioNeedsAuthoring-10899.test.tsx @@ -139,8 +139,12 @@ vi.mock('../../providers/MetadataProvider', () => ({ }), })); +// One adapter object for the file: the shared feeds key on the adapter's +// identity, so a fresh object per render re-attaches them on every commit and +// a case that awaits with the bar mounted never settles (objectui#12615). +const ADAPTER = vi.hoisted(() => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined })); vi.mock('../../providers/AdapterProvider', () => ({ - useAdapter: () => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined }), + useAdapter: () => ADAPTER, })); import { AppHeader } from '../AppHeader'; diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.editAppFollowsPackagePosture-12615.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.editAppFollowsPackagePosture-12615.test.tsx index b58797f58e..e99538e883 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.editAppFollowsPackagePosture-12615.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.editAppFollowsPackagePosture-12615.test.tsx @@ -151,8 +151,12 @@ vi.mock('../../providers/MetadataProvider', () => ({ }), })); +// One adapter object for the file: the shared feeds key on the adapter's +// identity, so a fresh object per render re-attaches them on every commit and +// a case that awaits with the bar mounted never settles (objectui#12615). +const ADAPTER = vi.hoisted(() => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined })); vi.mock('../../providers/AdapterProvider', () => ({ - useAdapter: () => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined }), + useAdapter: () => ADAPTER, })); import { AppHeader } from '../AppHeader'; diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.pageRouteNoAuthoringHandles-12198.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.pageRouteNoAuthoringHandles-12198.test.tsx index ca81794350..594332a8f1 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.pageRouteNoAuthoringHandles-12198.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.pageRouteNoAuthoringHandles-12198.test.tsx @@ -185,8 +185,12 @@ vi.mock('../../providers/MetadataProvider', () => ({ }), })); +// One adapter object for the file: the shared feeds key on the adapter's +// identity, so a fresh object per render re-attaches them on every commit and +// a case that awaits with the bar mounted never settles (objectui#12615). +const ADAPTER = vi.hoisted(() => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined })); vi.mock('../../providers/AdapterProvider', () => ({ - useAdapter: () => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined }), + useAdapter: () => ADAPTER, })); import { AppHeader } from '../AppHeader'; diff --git a/packages/app-shell/src/layout/__tests__/SidebarItemMenu.designOpensStudio-12520.test.tsx b/packages/app-shell/src/layout/__tests__/SidebarItemMenu.designOpensStudio-12520.test.tsx index 4d0e952ff6..9ba2be7b52 100644 --- a/packages/app-shell/src/layout/__tests__/SidebarItemMenu.designOpensStudio-12520.test.tsx +++ b/packages/app-shell/src/layout/__tests__/SidebarItemMenu.designOpensStudio-12520.test.tsx @@ -147,8 +147,12 @@ vi.mock('../../providers/MetadataProvider', () => ({ }), })); +// One adapter object for the file: the shared feeds key on the adapter's +// identity, so a fresh object per render re-attaches them on every commit and +// a case that awaits with the bar mounted never settles (objectui#12615). +const ADAPTER = vi.hoisted(() => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined })); vi.mock('../../providers/AdapterProvider', () => ({ - useAdapter: () => ({ find: () => Promise.resolve({ data: [] }), getClient: () => undefined }), + useAdapter: () => ADAPTER, })); import { resolveHref } from '@object-ui/layout'; diff --git a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.favoritesMenu-12427.test.tsx b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.favoritesMenu-12427.test.tsx index ad3910180f..d4690fb575 100644 --- a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.favoritesMenu-12427.test.tsx +++ b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.favoritesMenu-12427.test.tsx @@ -258,6 +258,7 @@ describe('UnifiedSidebar: one ⋯ trigger per entry, no pin icon (objectui#12427 it('a right-click on the row opens the same menu', async () => { renderSidebar(); + await tick(); fireEvent.contextMenu(screen.getByRole('link', { name: 'Leads' })); const items = await screen.findAllByRole('menuitem'); expect(items.map((el) => el.textContent)).toEqual(['Add to favorites', 'Open in new tab', 'Copy link', 'Design']); From f599de64722a1ce488145860f82109fa3f4e1bfd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:31:13 +0000 Subject: [PATCH 3/4] docs(app-shell): rewrap the two AppHeader comments the Edit app gate touched Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- packages/app-shell/src/layout/AppHeader.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index 52dc8554b6..19d7e92d50 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -355,7 +355,8 @@ export function AppHeader({ // App → Studio reverse bridge (ADR-0080): admins jump from the running app // to its owning package's design surface. Null when there is nothing to open - // (no owning package, or a principal or package the gate below refuses). When the current route names a specific + // (no owning package, or a principal or package the gate below refuses). + // When the current route names a specific // interface (a dashboard, page, or report), deep-link straight to THAT surface // in the Interfaces pillar instead of the package's generic Data tab — the // surface's design page replaces the retired in-page inline editor. The route @@ -771,8 +772,8 @@ export function AppHeader({ (/studio/:packageId/interfaces?surface=dashboard:NAME), otherwise the package's Data tab. Hidden for everyone else, for apps with no owning package and for a package that is not writable - (objectui#12615): not rendered, never disabled. An icon button at every width, like the - bell beside it (objectui#12461): the pencil alone, named + (objectui#12615): not rendered, never disabled. An icon button + at every width, like the bell beside it (objectui#12461): the pencil alone, named 「Edit app」 by its aria-label and its tooltip. */} {studioDesignPath && (