Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/12615-edit-app-pencil-posture.md
Original file line number Diff line number Diff line change
@@ -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.
116 changes: 116 additions & 0 deletions packages/app-shell/src/hooks/useStudioDesignGate.ts
Original file line number Diff line number Diff line change
@@ -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;
}
41 changes: 19 additions & 22 deletions packages/app-shell/src/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -446,24 +440,26 @@ 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
// type doubles as the surface type and `pathParts[3]` is the surface name
// (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.
Expand Down Expand Up @@ -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 && (
<Button
Expand Down
31 changes: 17 additions & 14 deletions packages/app-shell/src/layout/SidebarItemMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -356,10 +359,10 @@ export function SidebarItemMenu({
<span>{t('console.nav.copyLink', { defaultValue: 'Copy link' })}</span>
</DropdownMenuItem>
)}
{canDesign && (
{gatedDesignPath && (
<DropdownMenuItem
data-testid="sidebar-item-menu-design"
onSelect={() => { if (designPath) navigate(designPath); }}
onSelect={() => navigate(gatedDesignPath)}
>
<PencilRuler className="h-4 w-4" />
<span>{t('console.nav.design', { defaultValue: 'Design' })}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -199,18 +203,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/');
Expand Down Expand Up @@ -247,10 +263,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) =>
Expand Down
Loading
Loading