diff --git a/.changeset/12615-edit-app-pencil-posture.md b/.changeset/12615-edit-app-pencil-posture.md new file mode 100644 index 0000000000..1f26af1a48 --- /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 and a page's Edit in studio pencil follow 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..4082098a90 --- /dev/null +++ b/packages/app-shell/src/hooks/useStudioDesignGate.ts @@ -0,0 +1,116 @@ +// 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), the sidebar + * ⋯ menu's Design entry and a page's Edit in studio pencil (`PageView`). + * 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, the + * sidebar's rows and a page's pencil) 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 b58f8db152..26aedafb9c 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -85,7 +85,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'; @@ -95,7 +95,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'; @@ -288,12 +288,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(); @@ -446,7 +440,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 - // (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 @@ -454,16 +449,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. @@ -862,9 +858,10 @@ 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 - bell beside it (objectui#12461): the pencil alone, named + 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 && ( , + 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: [], + }), +})); + +// 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: () => ADAPTER, +})); + +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..623545ab83 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'; @@ -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'; @@ -239,6 +243,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(); @@ -253,8 +260,10 @@ async function studioEntriesOnPageRoute(perms: MePermissionsResponse): Promise , ); - // The page body rendered — so an absent pencil is a reading. + // The page body rendered — so an absent pencil is a reading — and the + // package list's verdict (objectui#12615) settled. expect(await screen.findByTestId('page-body')).toBeInTheDocument(); + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); const pencilEl = screen.queryByTestId('page-edit-in-studio-button'); navigateSpy.mockClear(); if (pencilEl) fireEvent.click(pencilEl); @@ -285,7 +294,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..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 @@ -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. */ @@ -145,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'; @@ -173,7 +179,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 +188,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 +206,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 +227,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 +249,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..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 @@ -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); }); @@ -240,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']); @@ -322,7 +341,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'), ); }); diff --git a/packages/app-shell/src/views/PageView.tsx b/packages/app-shell/src/views/PageView.tsx index 2c4284106a..d7ebfcfdaf 100644 --- a/packages/app-shell/src/views/PageView.tsx +++ b/packages/app-shell/src/views/PageView.tsx @@ -23,14 +23,13 @@ import { import { Empty, EmptyTitle, EmptyDescription, Spinner } from '@object-ui/components'; import { FileText, Pencil } from 'lucide-react'; import { useObjectTranslation } from '@object-ui/i18n'; -import { useWorkspaceAdminStatus } from '@object-ui/auth'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { useMetadata } from '../providers/MetadataProvider.js'; import { useExpressionContext } from '../providers/ExpressionProvider.js'; import { preferLocal } from '../utils/preferLocal.js'; import { appStudioRoutePath } from '../utils/appRoute.js'; import { ConsoleActionRuntimeProvider } from '../hooks/useConsoleActionRuntime.js'; -import { useCanAuthorMetadata } from '../hooks/useCanAuthorMetadata.js'; +import { useStudioDesignGate } from '../hooks/useStudioDesignGate.js'; import { pageRecordActionsValue } from './pageRecordActions.js'; import { pageKindNode } from './pageKindNode.js'; @@ -75,17 +74,6 @@ export function PageView() { // publishes document hrefs from it (objectui#12233). const routerRootHref = useHref('/'); const location = useLocation(); - // Editing a page mutates the shared metadata definition, so the entry point - // is admin-only (mirrors the view/report/dashboard runtime editors) — AND - // requires the metadata-authoring capability the SERVER reports - // (`manage_metadata`, ADR-0066). The role alone is not that answer: an - // organization owner is a workspace admin while `organization_admin` - // deliberately withholds `manage_metadata`, so on the cloud control plane a - // signed-up customer was offered the platform's own page editor - // (objectui#10899). Same doctrine as HomePage's builder CTAs. - const { isAdmin } = useWorkspaceAdminStatus(); - const canAuthorMetadata = useCanAuthorMetadata(); - const { pages, objects, getTypeStatus } = useMetadata(); // ADR-0048 Phase 2 — prefer the page owned by the current app's package so // two packages shipping `page/` each resolve within their own @@ -94,6 +82,24 @@ export function PageView() { const dataSource = useAdapter(); const page = preferLocal(pages as any[], pageName, (activeApp as any)?._packageId); + // Where "Edit in studio" goes: the page's surface in Studio's Interfaces + // pillar, through the one derivation the app bar's pencil, the app + // switcher's Studio row and the sidebar's Design share (objectui#12540, the + // rule objectui#12520 applied to the sidebar), behind the one gate they read + // (`useStudioDesignGate`, objectui#12615). `null` answers "does the app have + // an owning package Studio can open", "may this caller author metadata" + // (a workspace admin holding the server-reported `manage_metadata`: an + // organization owner is a workspace admin while `organization_admin` + // withholds it, objectui#10899) and "is that package writable" (a read-only + // package, or the console's own system apps, would open in Studio + // browse-only). With any answer no, or no answer yet, the button is not + // drawn. Asked before the not-found return below, as a hook must be; a page + // that does not resolve asks nothing. + // ⛔ No fallback to the metadata-admin page: one intent, one destination. + const studioPath = useStudioDesignGate( + page ? appStudioRoutePath(activeApp, true, { type: 'page', name: pageName }) : null, + ); + if (!page) { // `page` metadata is lazy-loaded: on the very first access `pages` is an // empty array while the fetch is in flight, which would flash a false @@ -129,17 +135,6 @@ export function PageView() { // Resolve the app slug from the path (`/apps/:app/page/:name`) so the record // navigator's links survive whatever Router basename the host mounts under. const appName = location.pathname.match(/\/apps\/([^/]+)/)?.[1]; - // Where "Edit in studio" goes: the page's surface in Studio's Interfaces - // pillar, through the one derivation the app bar's pencil, the app - // switcher's Studio row and the sidebar's Design share (objectui#12540, the - // rule objectui#12520 applied to the sidebar). `null` answers both "may this - // caller author metadata" (the gate above) and "does the app have an owning - // package Studio can open"; with either answer no, the button is not drawn. - // ⛔ No fallback to the metadata-admin page: one intent, one destination. - const studioPath = appStudioRoutePath(activeApp, isAdmin && canAuthorMetadata, { - type: 'page', - name: pageName, - }); const canEditInStudio = studioPath !== null; const openInStudio = () => { if (studioPath === null) return; diff --git a/packages/app-shell/src/views/__tests__/PageView.editInStudioFollowsPackagePosture-12615.test.tsx b/packages/app-shell/src/views/__tests__/PageView.editInStudioFollowsPackagePosture-12615.test.tsx new file mode 100644 index 0000000000..8604aa89d8 --- /dev/null +++ b/packages/app-shell/src/views/__tests__/PageView.editInStudioFollowsPackagePosture-12615.test.tsx @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A page's "Edit in studio" pencil follows the owning package's posture + * (objectui#12615), the rule the app bar's Edit app pencil follows + * (`AppHeader.editAppFollowsPackagePosture-12615`): it is drawn only when the + * page's app belongs to a package Studio lists as writable. A read-only + * package's page gets no pencil, and neither does a page of the console's own + * system apps: their packages are kernel packages the list leaves out. Absent, + * never disabled. + * + * The package list is served the way `GET /api/v1/packages` serves it, with the + * server's `writable` verdict on each row (ADR-0070 D2). Each absence is read + * once the page body has rendered and the list has answered, beside a writable + * CONTROL read the same way. + * + * Harness: the one `PageView.editInStudioOpensStudio-12540` uses. Every case is + * a caller who passes the authoring gate (`PageView.editInStudioNeedsAuthoring-10899`). + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, cleanup, render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; + +const navigateSpy = vi.hoisted(() => vi.fn()); +vi.mock('react-router-dom', () => ({ + useParams: () => ({ pageName: 'welcome' }), + useSearchParams: () => [new URLSearchParams(), vi.fn()], + useNavigate: () => navigateSpy, + useHref: () => '/', + useLocation: () => ({ pathname: '/apps/x/page/welcome', search: '' }), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ t: (k: string, o?: Record) => String(o?.defaultValue ?? k) }), +})); + +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ pages: [{ name: 'welcome', type: 'page', label: 'Welcome' }], objects: [] }), +})); + +vi.mock('../MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false }), +})); + +// The page body is not under test — only the chrome around it. +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ({}), + SchemaRenderer: () =>
, +})); + +import { PageView } from '../PageView'; +import { ExpressionProvider } from '../../providers/ExpressionProvider'; + +/** A metadata author: holds `manage_metadata`. */ +const AUTHOR: MePermissionsResponse = { + authenticated: true, + userId: 'u1', + tenantId: 'org_1', + roles: [], + permissionSets: [], + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + objects: {}, + fields: {}, +}; + +const EXPRESSION_USER = {}; +/** 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. */ +const UNLISTED = { name: 'gone', label: 'Gone', _packageId: 'com.example.unlisted' }; + +/** `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', type: 'app', scope: 'system' }, writable: false }, + { manifest: { id: 'app.objectstack.hotcrm', name: 'HotCRM', type: 'app', scope: 'project' }, writable: false }, + { manifest: { id: 'com.example.my_base', name: 'My Base' }, writable: true }, + ], + }, +}; + +const BASE_TARGET = '/studio/com.example.my_base/interfaces?surface=page:welcome'; + +function serve(answer: () => Promise<{ ok: boolean; status: number; body: unknown }>) { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: unknown) => { + if (!String(url).endsWith('/api/v1/packages')) return { ok: true, status: 200, json: async () => ({ data: [] }) }; + const { ok, status, body } = await answer(); + return { ok, status, json: async () => body }; + }), + ); +} + +/** One macrotask: every read the stub answers has settled. */ +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); + +/** The page route under `app`, read once the page body rendered and the list answered. */ +async function mountUnder(app: Record) { + render( + + + + + , + ); + expect(await screen.findByTestId('page-body')).toBeInTheDocument(); + await settle(); +} + +/** Absent, not disabled: no element carries the pencil's test id or its name. */ +function expectNoPencil() { + expect(screen.queryByTestId('page-edit-in-studio-button')).toBeNull(); + expect(screen.queryByLabelText('Edit in studio')).toBeNull(); +} + +beforeEach(() => { + navigateSpy.mockClear(); + serve(async () => ({ ok: true, status: 200, body: PACKAGES })); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('a page\'s Edit in studio follows the owning package\'s posture (objectui#12615)', () => { + it('no pencil on a page of the Setup app (`com.objectstack.setup`)', async () => { + await mountUnder(SETUP); + expectNoPencil(); + }); + + it('no pencil on a page of a package whose posture is read-only (HotCRM, `writable: false`)', async () => { + await mountUnder(HOTCRM); + expectNoPencil(); + }); + + it('no pencil on a page of a package the list does not name', async () => { + await mountUnder(UNLISTED); + expectNoPencil(); + }); + + it('CONTROL — the pencil on a page of a writable base, opening the page in Studio', async () => { + await mountUnder(BASE_APP); + fireEvent.click(screen.getByTestId('page-edit-in-studio-button')); + expect(navigateSpy).toHaveBeenCalledTimes(1); + expect(navigateSpy).toHaveBeenCalledWith(BASE_TARGET); + }); +}); + +describe('no verdict, no pencil (objectui#12615)', () => { + it('absent while the list is being read, drawn once it answers writable', async () => { + let answer: (value: { ok: boolean; status: number; body: unknown }) => void = () => undefined; + serve(() => new Promise((resolve) => { answer = resolve; })); + await mountUnder(BASE_APP); + expectNoPencil(); + await act(async () => { answer({ ok: true, status: 200, body: PACKAGES }); }); + expect(await screen.findByTestId('page-edit-in-studio-button')).toBeInTheDocument(); + }); + + it('a failed list read draws no pencil', async () => { + serve(async () => ({ ok: false, status: 403, body: { success: false, error: { code: 'FORBIDDEN', message: 'no' } } })); + await mountUnder(BASE_APP); + expectNoPencil(); + }); +}); diff --git a/packages/app-shell/src/views/__tests__/PageView.editInStudioNeedsAuthoring-10899.test.tsx b/packages/app-shell/src/views/__tests__/PageView.editInStudioNeedsAuthoring-10899.test.tsx index 5a3dd118d6..885e2febc0 100644 --- a/packages/app-shell/src/views/__tests__/PageView.editInStudioNeedsAuthoring-10899.test.tsx +++ b/packages/app-shell/src/views/__tests__/PageView.editInStudioNeedsAuthoring-10899.test.tsx @@ -22,8 +22,8 @@ */ import '@testing-library/jest-dom/vitest'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, cleanup, render, screen } from '@testing-library/react'; import React from 'react'; import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; @@ -71,6 +71,8 @@ import { ExpressionProvider } from '../../providers/ExpressionProvider'; * opens that package's Studio surface and is drawn only when there is one, so * the app is an input here too, held constant: this file pins the AUTHORING * gate, and `PageView.editInStudioOpensStudio-12540` pins the package half. + * The package list serves that package writable, so the posture half + * (`PageView.editInStudioFollowsPackagePosture-12615`) passes here. */ const APP = { name: 'cloud_control', label: 'Cloud', _packageId: 'com.objectstack.cloud' }; const EXPRESSION_USER = {}; @@ -94,42 +96,65 @@ const ORG_OWNER = mePermissions(['manage_org_users', 'setup.access', 'setup.writ /** A metadata author (platform admin / env owner): holds `manage_metadata`. */ const AUTHOR = mePermissions(['manage_metadata', 'studio.access', 'setup.access']); -function mount(perms: MePermissionsResponse) { - return render( +/** + * The page route for one persona, read once the page body has rendered and + * the package list's verdict (objectui#12615) has settled. + */ +async function mount(perms: MePermissionsResponse) { + render( , ); + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); } beforeEach(() => { viewer.isAdmin = true; + // objectui#12615 — the app's package is served writable, so the pencil'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: [] }, + })), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); }); describe('the page pencil follows the server-reported authoring capability (objectui#10899)', () => { it('a workspace admin WITHOUT `manage_metadata` (an org owner) gets no pencil', async () => { - mount(ORG_OWNER); + await mount(ORG_OWNER); expect(await screen.findByTestId('page-body')).toBeInTheDocument(); expect(screen.queryByTestId('page-edit-in-studio-button')).not.toBeInTheDocument(); }); it('POSITIVE CONTROL — a workspace admin WITH `manage_metadata` keeps the pencil', async () => { - mount(AUTHOR); + await mount(AUTHOR); expect(await screen.findByTestId('page-body')).toBeInTheDocument(); expect(screen.getByTestId('page-edit-in-studio-button')).toBeInTheDocument(); }); it('the role gate still stands — a non-admin holding `manage_metadata` gets no pencil', async () => { viewer.isAdmin = false; - mount(AUTHOR); + await mount(AUTHOR); expect(await screen.findByTestId('page-body')).toBeInTheDocument(); expect(screen.queryByTestId('page-edit-in-studio-button')).not.toBeInTheDocument(); }); it('a server that reports NO `systemPermissions` (pre-ADR-0066) keeps today\'s admin pencil — unknown fails open', async () => { - mount(mePermissions(undefined)); + await mount(mePermissions(undefined)); expect(await screen.findByTestId('page-body')).toBeInTheDocument(); expect(screen.getByTestId('page-edit-in-studio-button')).toBeInTheDocument(); }); diff --git a/packages/app-shell/src/views/__tests__/PageView.editInStudioOpensStudio-12540.test.tsx b/packages/app-shell/src/views/__tests__/PageView.editInStudioOpensStudio-12540.test.tsx index b7bb0e1bc6..44d133932a 100644 --- a/packages/app-shell/src/views/__tests__/PageView.editInStudioOpensStudio-12540.test.tsx +++ b/packages/app-shell/src/views/__tests__/PageView.editInStudioOpensStudio-12540.test.tsx @@ -19,13 +19,15 @@ * metadata-admin page. * * The authoring gate (`manage_metadata` and the workspace-admin role) is - * pinned in `PageView.editInStudioNeedsAuthoring-10899`; every case here is a - * caller who passes it. + * pinned in `PageView.editInStudioNeedsAuthoring-10899`, and the package + * posture in `PageView.editInStudioFollowsPackagePosture-12615`; every case + * here is a caller who passes the first, on a package the list serves + * writable. */ import '@testing-library/jest-dom/vitest'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, cleanup, render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; @@ -96,13 +98,33 @@ async function mountUnder(app: Record | undefined) { )} , ); - // The page body rendered — so an absent button is a reading. + // The page body rendered, and the package list's verdict (objectui#12615) + // settled — so an absent button is a reading. expect(await screen.findByTestId('page-body')).toBeInTheDocument(); + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); return view; } beforeEach(() => { navigateSpy.mockClear(); + // objectui#12615 — the app's package is served writable, so the pencil'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: [] }, + })), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); }); describe('Edit in studio opens the page in Studio, as the app bar pencil does (objectui#12540)', () => {