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
10 changes: 10 additions & 0 deletions .changeset/12718-studio-no-app-package-label.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>;
drafts: Array<Record<string, unknown>>;
} = { 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<Record<string, unknown>>();
return {
...actual,
useMetadataClient: () => mockClient,
useMetadataTypes: () => ({ loading: false, error: null, entries: [] }),
};
});

vi.mock('@object-ui/react', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
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<string, unknown>) {
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(
<MemoryRouter initialEntries={[`/studio/${PACKAGE_ID}/interfaces`]}>
<Routes>
<Route path="/studio/:packageId/:tab" element={<StudioDesignSurface />} />
</Routes>
</MemoryRouter>,
);
}

/** 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<typeof vi.fn>).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();
});
});
27 changes: 24 additions & 3 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean | null | undefined>(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<PkgEntry | null>(null);
React.useEffect(() => {
// objectui#11553 — the package-less scope has no package to look up; its
// writability is decided below, not fetched.
Expand All @@ -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
Expand Down Expand Up @@ -1954,6 +1962,7 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
<InterfacesPillar
key={packageId}
packageId={packageId}
packageLabel={pkgEntry?.id === packageId ? pkgEntry.name : undefined}
publishNonce={publishNonce}
draftNonce={draftNonce}
onDraftSaved={onDraftSaved}
Expand Down Expand Up @@ -2934,6 +2943,7 @@ const leafKeyOf = (s: Surface | null): string => `${s?.type ?? ''}:${s?.name ??

export function InterfacesPillar({
packageId,
packageLabel,
publishNonce = 0,
draftNonce = 0,
onDraftSaved,
Expand All @@ -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. */
Expand Down Expand Up @@ -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<I18nLabel>(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<I18nLabel | null>(null);
const appLabel: I18nLabel = loadedAppLabel ?? packageLabel ?? packageId;
const [appName, setAppName] = React.useState<string | null>(null);
const [appDraft, setAppDraft] = React.useState<Record<string, unknown>>({});
// objectui#11272 — the app `appDraft` was loaded for, `app:NAME`: written
Expand Down Expand Up @@ -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) {
Expand Down
Loading