diff --git a/.changeset/12718-studio-no-app-package-label.md b/.changeset/12718-studio-no-app-package-label.md new file mode 100644 index 0000000000..91ecc73f96 --- /dev/null +++ b/.changeset/12718-studio-no-app-package-label.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': patch +--- + +Studio Interfaces names a package that has no app yet by its display name, the one the top bar prints, instead of its id (objectui#12718). The rail heading read `com.qa.recheck · Navigation` and the pillar title `com.qa.recheck` beside a top bar reading `QA Recheck`; both now read `QA Recheck`. The name comes from the package list the Studio surface already reads for its read-only gate, so no request is added. A package whose manifest declares no name still reads as its id, the only name it has. + +A draft-only app (created, never published) is now named by the label its pending-draft row serves, as the shell header already names it, instead of its machine name. A published app keeps its own label. + +Clause-②: no +No export, type member, spec key or language-pack key is added or removed. `InterfacesPillar` gains an internal `packageLabel` prop; it is not exported from the package entry. diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.noAppPackageLabel-12718.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.noAppPackageLabel-12718.test.tsx new file mode 100644 index 0000000000..8017235848 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.noAppPackageLabel-12718.test.tsx @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12718 — the Interfaces pillar of a package that has no app yet + * names the package by its display name, the one the Studio top bar prints, + * and never by its id. + * + * `InterfacesPillar` seeded its app label with `packageId`, and the app + * resolve returned on the no-app branch before it ever set one, so the rail + * heading read `com.qa.recheck · Navigation` and the pillar title + * `com.qa.recheck` beside a top bar reading `QA Recheck`. The surface now + * hands the pillar the name from the package list it already reads for the + * writability gate (no new request), and the pillar holds no app label until + * an app is found. + * + * A draft-only app is named by the label its `_drafts` row serves + * (objectstack-ai/objectstack#22200), not by its machine name. The controls: + * a published app keeps its own label, and a package that declares no name + * still reads as its id, the only name it has. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const PACKAGE_ID = 'com.qa.recheck'; + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }, + Toaster: () => null, +})); + +/** What the mocked server answers for this package's app. */ +const state: { + published: Array>; + drafts: Array>; +} = { published: [], drafts: [] }; + +const mockClient = { + list: vi.fn(async (type: string) => (type === 'app' ? state.published : [])), + listDrafts: vi.fn(async (opts?: { type?: string }) => (opts?.type === 'app' ? state.drafts : [])), + layered: vi.fn(async (_t: string, name: string) => ({ effective: { name } })), + getDraft: vi.fn(async () => null), + get: vi.fn(async () => undefined), + save: vi.fn(async () => ({})), +}; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ loading: false, error: null, entries: [] }), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useAdapter: () => ({}) }; +}); + +vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null })); +vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); + +import { StudioDesignSurface } from './StudioDesignSurface'; + +const answer = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); + +/** The package list the top bar and the writability gate read. */ +function servePackage(manifest: Record) { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => + String(input) === '/api/v1/packages' + ? answer({ success: true, data: { packages: [{ manifest, writable: true }] } }) + : answer([]), + ), + ); +} + +function renderInterfaces() { + render( + + + } /> + + , + ); +} + +/** The pillar's own text: the rail heading and the item header title, not the top bar. */ +function pillarText(): string { + const header = screen.getByTestId('studio-shell-header'); + let text = ''; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (!header.contains(node)) text += `${node.textContent ?? ''}\n`; + } + return text; +} + +/** Waits for the top bar to have the package list in hand. */ +async function packageListLoaded() { + const header = screen.getByTestId('studio-shell-header'); + await waitFor(() => + expect(within(header).getByTestId('studio-pkg-chip')).toHaveAttribute('data-pkg-list-state', 'loaded'), + ); +} + +beforeEach(() => { + state.published = []; + state.drafts = []; + for (const fn of Object.values(mockClient)) fn.mockClear(); + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('Studio Interfaces names a package with no app by its display name (objectui#12718)', () => { + it('no app: the rail heading and the pillar title read the package name, never its id', async () => { + servePackage({ id: PACKAGE_ID, name: 'QA Recheck' }); + renderInterfaces(); + + expect(await screen.findByText('QA Recheck · Navigation', undefined, { timeout: 8000 })).toBeInTheDocument(); + await packageListLoaded(); + expect(mockClient.list).toHaveBeenCalledWith('app', { packageId: PACKAGE_ID }); + expect(mockClient.listDrafts).toHaveBeenCalledWith({ packageId: PACKAGE_ID, type: 'app' }); + const text = pillarText(); + // The title is the package name on a line of its own; the heading is the other line naming it. + expect(text.split('\n').filter((line) => line.trim() === 'QA Recheck')).not.toHaveLength(0); + expect(text).not.toContain(PACKAGE_ID); + // The package list is the one the surface already reads: no request is added for the name. + const reads = (fetch as unknown as ReturnType).mock.calls.filter( + ([input]) => String(input) === '/api/v1/packages', + ); + expect(reads.length).toBeLessThanOrEqual(1); + }); + + it('a draft-only app: its label from the `_drafts` row, not its machine name', async () => { + servePackage({ id: PACKAGE_ID, name: 'QA Recheck' }); + state.drafts = [{ type: 'app', name: 'recheck_app', label: 'Recheck Desk', packageId: PACKAGE_ID }]; + renderInterfaces(); + + expect(await screen.findByText('Recheck Desk · Navigation', undefined, { timeout: 8000 })).toBeInTheDocument(); + await packageListLoaded(); + expect(pillarText()).not.toContain('recheck_app ·'); + }); + + it('CONTROL: a published app keeps its own label over the package name', async () => { + servePackage({ id: PACKAGE_ID, name: 'QA Recheck' }); + state.published = [{ name: 'recheck_app', label: 'Recheck Desk' }]; + renderInterfaces(); + + expect(await screen.findByText('Recheck Desk · Navigation', undefined, { timeout: 8000 })).toBeInTheDocument(); + await packageListLoaded(); + expect(screen.queryByText('QA Recheck · Navigation')).toBeNull(); + }); + + it('CONTROL: a package that declares no name reads as its id, the only name it has', async () => { + servePackage({ id: PACKAGE_ID }); + renderInterfaces(); + + await packageListLoaded(); + expect(await screen.findByText(`${PACKAGE_ID} · Navigation`, undefined, { timeout: 8000 })).toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index a3db049181..9a25941fe3 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -1217,6 +1217,11 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React // pillar's `?sel=nav:` deep link tells them apart, so that it does not act // on a write state nobody has answered yet (objectui#11153). const [pkgWritable, setPkgWritable] = React.useState(undefined); + // objectui#12718 — the package's display name, from the same answer: the + // Interfaces pillar names a package that has no app yet by it. Held with + // the id it answered for, so a switch never hands the next package the + // previous one's name while its own read is in flight. + const [pkgEntry, setPkgEntry] = React.useState(null); React.useEffect(() => { // objectui#11553 — the package-less scope has no package to look up; its // writability is decided below, not fetched. @@ -1225,7 +1230,10 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React setPkgWritable(undefined); fetchPackages() .then((list) => { - if (!cancelled) setPkgWritable(list.find((p) => p.id === packageId)?.writable ?? null); + if (cancelled) return; + const entry = list.find((p) => p.id === packageId) ?? null; + setPkgWritable(entry?.writable ?? null); + setPkgEntry(entry); }) .catch((e: unknown) => { // Staying ungated is still right — the server gate is the authority @@ -1954,6 +1962,7 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React `${s?.type ?? ''}:${s?.name ?? export function InterfacesPillar({ packageId, + packageLabel, publishNonce = 0, draftNonce = 0, onDraftSaved, @@ -2946,6 +2956,10 @@ export function InterfacesPillar({ onHeldEditChange, }: { packageId: string; + /** objectui#12718 — the package's display name, as the Studio top bar + * reads it; `undefined` until the package list answers. The pillar names a + * package with no app by it, so the id shows only when neither exists. */ + packageLabel?: string; publishNonce?: number; /** Bumped when a draft is saved elsewhere (e.g. the header's create-app flow) — a * re-resolve signal so a just-created app appears without a reload. */ @@ -3000,7 +3014,10 @@ export function InterfacesPillar({ // in the designer locale, so the rail heading follows a designer-language // switch as the nav rows do. ⛔ Never `String()` it: a map reads // `[object Object]`. - const [appLabel, setAppLabel] = React.useState(packageId); + // objectui#12718 — `null` until an app is found. A package with no app yet + // is named by its display name, and by its id only when it declares none. + const [loadedAppLabel, setAppLabel] = React.useState(null); + const appLabel: I18nLabel = loadedAppLabel ?? packageLabel ?? packageId; const [appName, setAppName] = React.useState(null); const [appDraft, setAppDraft] = React.useState>({}); // objectui#11272 — the app `appDraft` was loaded for, `app:NAME`: written @@ -3369,7 +3386,11 @@ export function InterfacesPillar({ const d = drafts?.[0]; if (d?.name) { name = String(d.name); - label = String(d.name); + // objectui#12718 — the `_drafts` row serves the app's label + // (objectstack-ai/objectstack#22200), as the shell header's + // draft-only read already takes it. + const rowLabel = (d as { label?: I18nLabel | null }).label; + label = rowLabel ?? String(d.name); } } if (!name) {