Skip to content

Commit 1e6f542

Browse files
feat(studio): a contributed nav entry shows the label the live app shows, and a contributor-only package says where its entries go (#30371)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgBZMqtfRtfa9Hih8oCkjd71)_ domain:ui#3 · os-bill · session_01Utp6CbPN5UYMnBaNJPJWfG ## 维护者速读 - 改了什么:Studio「界面」里别的包加进来的导航项,现在显示线上应用里看到的名字(如 Orders),id 降为下面一行灰字;只往别人应用里加导航、自己没有应用的包,不再说「还没有应用」,而是说「它向 X 的应用添加了 N 个导航项」并给打开按钮。 - 为什么改:之前树里显示 nav_orders 这类 id,和线上应用对不上;贡献包的空状态说「没有应用」,实际它的导航出现在别的应用里,读者会误会。 - 风险与回滚:只改 Studio 显示,不改元数据、不改服务端;名字只在 id 唯一时才取,重复 id 仍显示 id;读失败、包停用、目标应用不存在或计数为 0 时保持原文。回滚即 revert 本 PR。 - 先看:InterfacesNavRail.tsx 的 contributedNavLabel(名字从哪来);StudioRailCustomizations.tsx 的 useContributedElsewhere(空状态发哪两次读);StudioDesignSurface.tsx 合并后的空状态分支。 - 两分钟验证:在 app-multi-package 上打开 Studio 的 Multi-Package Core「界面」,看到 Orders 下面一行 nav_orders;再打开 Multi-Package Orders「界面」,看到「It adds 1 navigation entry to Multi-Package Core’s app.」。 - 需要你做的:无,席位落地。 Fixes #29851 Clause-②: yes (widening) — five new `@object-ui/i18n` studio keys, in `en` and `zh` Metadata-protocol: none ## What changes - **The label (part 1, amendment 6598320473).** A navigation entry another package contributes to a base's app now leads with the label the live app shows, with its id as a muted second line and the package chip under it. The label is looked up by `(app, id)` in the app row the Interfaces pillar already reads (`GET /meta/app?package=`), which serves the folded navigation. `servedContributedNavOf` keeps only the ids the published layer does not hold and that occur exactly once in the served tree, so an id the base also holds, or one two contributions share, keeps the bare id and no label is guessed. A label-less entry reads what it inherits through the rail's own resolver (`navEntryLabelText` plus `useNavTargetLabel`). No request is added. This is a lookup by id in a served read, not the join on names ruling 6566915346 refuses. - **The contributor's empty state (part 2, option A).** A package with no app of its own whose listed manifest declares `navigationContributions` now says "This package has no app of its own. It adds N navigation entries to OWNER's app.", with an Open button, in the canvas and in the rail. The declared app is an address only: its owner comes from the served `GET /meta/app/:app` (`_packageId`), and the count from the owner's served `GET /packages/:owner/customizations` `contributions`, filtered by `kind: nav`, this `packageId` and that app. At most two reads per declared target app (one customization read per owner), sent only on that empty state. When the package is disabled, the target app is missing, either read fails, or the count is 0, the text stays "This package has no app yet." - The empty state reads the open package's declaration off the package list the Studio surface already fetched (`fetchListedPackageManifests`, passed down as `listedManifests`), so it sends no package-list request of its own. - **Strings.** `engine.studio.if.noOwnApp`, `.noOwnAppTitle`, `.contributesOne`, `.contributesOther`, `.openOwner`, in the studio `en` and `zh` packs. No metadata key is added. - **Main moved under this branch.** The Interfaces nav rail moved out of `StudioDesignSurface.tsx` into `InterfacesNavRail.tsx` (move-only split, #29988). The merge takes main's move and carries this card's rail hunks with it: `InterfacesServedContributedNavContext`, the `label` passed to `ContributedRailRow` in `ContributedNavRows`, and `contributedNavLabel`. `InterfacesNavRail.tsx` is outside the claim's listed file surface by name only; it holds the same code the claim named in `StudioDesignSurface.tsx`. ## Tests - `contributionRows.test.ts`: the served entries keyed by id at any depth and never the base's own; an id the base also holds keeps no label; an id two contributions share keeps no label; no served row or another app's gives nothing; the declared target apps are addresses, each once, in order; the count of the owner's served nav contributions for the package and app. - `StudioDesignSurface.oneSurface-28572.test.tsx`, a new top-level `describe` in the existing harness: a unique id shows its served label with the id as a second line and a shared id keeps the bare id; CONTROL, no served fold keeps the id alone; a contributor-only package says what it adds from two reads and opens the owner; FALLBACK disabled package; FALLBACK missing target app (the owner is never read); FALLBACK failed app read or failed owner read; FALLBACK 0 entries; CONTROL, a package with no app that declares no contribution reads nothing more. ## Acceptance notes - Overlap with in-flight studio-design claims, read after the merge: this diff touches `StudioDesignSurface.tsx` in the imports, the surface's package-list effect, the `InterfacesPillar` props and its no-app empty state; #30088 holds that file's actions branch, a different region. It touches none of `ObjectListViewInspector.tsx` (#30177, #30089), `ObjectActionsPanel.tsx` (#30088) or `StudioBundleItemEditor.tsx` (#30285). - The changeset carried `Clause-②: no` on the resume branch; it now says `yes (widening)`, as the five new published i18n keys make it. The level stays `patch` (kind `none`). ## Verification All readings on head `98ed7206f4` (main merged at `9afcd6a6b6`), repository objectstack-ai/objectstack. <details><summary>Type-check and tests</summary> - `pnpm exec turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/i18n --concurrency=2`: Tasks 69 successful, 69 total; lock VERDICT command-exit 0. - vitest (root `ui/`) over every test file that imports `contributionRows`, `StudioRailCustomizations`, `InterfacesNavRail` or `StudioDesignSurface` (193 files, by `git grep`), plus `packages/i18n/`: Test Files 288 passed (288); Tests 2728 passed, 13 skipped (2741); lock VERDICT command-exit 0. - `pnpm --dir ui exec vitest run scripts/`: Test Files 170 passed, 2 skipped (172); Tests 4985 passed, 2 skipped. - `pnpm check:new-line-citations` (ui): 0 new citations. </details> <details><summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35 families from 9 changed paths (661 changed lines, under the 3000 threshold). All 35 ran and exited 0, `node scripts/check-sdui-manifest.mjs` included. - `dispatch-gates --ran`: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. </details> <details><summary>Ablation (each leg restored, git diff HEAD empty after)</summary> - `servedContributedNavOf` forced to return nothing (`node scripts/ablation-replace.mjs`, anchor hit 1 to 0): 4 pins red, the three helper pins and "a unique id shows its served label with the id as a second line". Restored blob equals HEAD. - `if (count === 0) return null;` deleted from `useContributedElsewhere`: 2 pins red, "FALLBACK: a disabled package" and "FALLBACK: 0 entries for the declared app". Restored blob equals HEAD. </details> <details><summary>Live, en and zh, fresh boot of the merged tree</summary> Backend `examples/app-multi-package`, `objectstack dev --seed-admin --fresh` on its own port; the Console from `ui/apps/console` src through Vite pointed at it; Chromium through Playwright. - Multi-Package Core, Interfaces, Sales group, contributed row: en "Orders / nav_orders / from Multi-Package Orders"; zh "Orders / nav_orders / 来自 Multi-Package Orders". - Multi-Package Orders, Interfaces, canvas: en "This package has no app of its own / It adds 1 navigation entry to Multi-Package Core’s app. / Open Multi-Package Core"; zh "这个软件包没有自己的应用 / 它向 Multi-Package Core 的应用添加了 1 个导航项。 / 打开 Multi-Package Core". The rail says the same in one line each. - Shots: `ui-seat3/issue-29851/shots/` in the project folder (`en-base-contributed-row.png`, `en-contributor-empty-state.png`, `zh-base-contributed-row.png`, `zh-contributor-empty-state.png`). </details> <details><summary>Measured request counts</summary> - Reads on opening a package's Interfaces pillar, as the browser sent them. Multi-Package Core (has an app): two `GET /api/v1/packages`, no `/meta/app/:app` and no other package's customizations. Multi-Package Orders (contributor only): the same two `GET /api/v1/packages`, plus `GET /meta/app/multi_crm` and `GET /packages/com.example.multi.core/customizations`. The empty state adds two reads and no package-list read. - Platform packages on this boot (`GET /api/v1/packages`, 24 listed): 11 declare `navigationContributions` for `setup`. One of them is `com.objectstack.setup`, which owns the app and never reaches the empty state, so 10 contributor-only packages show the new sentence for `setup`. `com.objectstack.mcp.connect-agent-ui` also targets `account` (owner `com.objectstack.account`), so it sends four reads and reads "It adds 1 navigation entry to Setup’s app." and "It adds 1 navigation entry to Account’s app.". Across the 10 that is 11 targets, 22 reads in all, each sent only when that package's pillar is opened. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6Cb8MSvSrJz9nXGSj2F)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 26c2efd commit 1e6f542

9 files changed

Lines changed: 646 additions & 15 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(studio): a contributed nav entry shows the label the live app shows, and a package that only contributes navigation says where it goes
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **The label.** A navigation entry another package contributes to a base's app now shows the label the live app shows, with its id as a muted second line under it. Before, the row showed only the id. The label is looked up by `(app, id)` in the app row the Interfaces pillar already reads (`GET /meta/app?package=`), which serves the folded navigation: the served entries the app's published layer does not hold. No request is added and nothing is read from the contributor's manifest. An id the base also holds, or one two contributions share, keeps the bare id, so no label is guessed. A label-less entry reads what it inherits, through the rail's own resolver. A never-published app has no served row, so its contributed rows keep the id.
12+
- **The contributor's empty state.** A package with no app of its own whose listed manifest declares `navigationContributions` now says what it adds: "This package has no app of its own. It adds 2 navigation entries to HotCRM's app.", with a button that opens that package. The declared app is an address only. Its owner comes from the served app read (`GET /meta/app/:app`), and the count comes from the owner's served customization read (`GET /packages/:owner/customizations`), counting the `nav` contributions stamped with this package for that app. That is at most two reads per declared target app, sent only on that empty state. When the package is disabled, the target app is missing, either read fails, or the count is 0, the text stays "This package has no app yet."
13+
- **New strings.** `@object-ui/i18n`'s studio namespace gains five keys in `en` and `zh` (`engine.studio.if.noOwnApp`, `.noOwnAppTitle`, `.contributesOne`, `.contributesOther`, `.openOwner`).

‎ui/packages/app-shell/src/views/studio-design/InterfacesNavRail.tsx‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,13 @@ import {
4444
type RailCustomizations,
4545
} from './StudioRailCustomizations.js';
4646
import { CustomizationMark } from './CustomizationMark.js';
47-
import type { NavContributionPlacement, TopLevelContributedNav } from './contributionRows.js';
47+
import {
48+
NO_SERVED_CONTRIBUTED_NAV,
49+
servedEntryOf,
50+
type NavContributionPlacement,
51+
type ServedContributedNav,
52+
type TopLevelContributedNav,
53+
} from './contributionRows.js';
4854

4955
const KIND_ICON: Record<string, LucideIcon> = {
5056
group: Folder,
@@ -206,6 +212,13 @@ export const InterfacesRailMarksContext = React.createContext<RailCustomizations
206212
*/
207213
export const InterfacesNavContributionsContext = React.createContext<NavContributionPlacement>({ byGroup: new Map(), top: [] });
208214

215+
/**
216+
* objectstack#29851 — the open app's served entries its published layer does
217+
* not hold, by id (`servedContributedNavOf`): what a contributed row reads
218+
* its label from. Empty for a never-published app.
219+
*/
220+
export const InterfacesServedContributedNavContext = React.createContext<ServedContributedNav>(NO_SERVED_CONTRIBUTED_NAV);
221+
209222
/**
210223
* objectstack#29786 — contributed nav entries as rail rows: greyed, with the
211224
* contributing package, and opening that package's Interfaces pillar. Not
@@ -221,13 +234,17 @@ export function ContributedNavRows({
221234
}): React.ReactElement | null {
222235
const locale = useMetadataLocale();
223236
const openContributor = useOpenContributor();
237+
const served = React.useContext(InterfacesServedContributedNavContext);
238+
// objectstack#29851 — the label-less entry inherits as the live sidebar's does.
239+
const targetLabel = useNavTargetLabel();
224240
if (rows.length === 0) return null;
225241
return (
226242
<>
227243
{rows.map(({ entry, undeclaredGroup }) => (
228244
<ContributedRailRow
229245
key={`${entry.packageId}:${entry.name}`}
230246
item={entry}
247+
label={contributedNavLabel(servedEntryOf(served, entry), locale, targetLabel)}
231248
indent={indent}
232249
icon={<Puzzle className="h-4 w-4 shrink-0" />}
233250
title={contributedItemTitle(entry, locale, undeclaredGroup)}
@@ -239,6 +256,21 @@ export function ContributedNavRows({
239256
);
240257
}
241258

259+
/**
260+
* objectstack#29851 — the text the live app shows for a contributed entry: the
261+
* served entry read with the rail's own resolver, or `undefined` when no served
262+
* entry is known for it (the row then keeps its id).
263+
*/
264+
function contributedNavLabel(
265+
node: Record<string, unknown> | undefined,
266+
locale: string,
267+
targetLabel: NavTargetLabelResolver,
268+
): string | undefined {
269+
if (!node) return undefined;
270+
const text = navEntryLabelText(node as NavNode, locale, targetLabel).trim();
271+
return text || undefined;
272+
}
273+
242274
export function NavTree({
243275
nodes,
244276
active,

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx‎

Lines changed: 196 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ const srv = vi.hoisted(() => ({
5555
saves: [] as Array<{ path: string; search: string; body: Record<string, unknown> }>,
5656
/** objectstack#29786 — the read serves {@link CONTRIBUTIONS}, and the app and object hold what they target. */
5757
contributed: false,
58+
/** objectstack#29851 — the base's `/meta/app` row serves the folded navigation ({@link SERVED_APP}). */
59+
servedFold: false,
60+
/** objectstack#29851 — the add-on is a contributor with no app of its own (see {@link addonRow}). */
61+
addonContributes: false,
62+
/** objectstack#29851 — the add-on's listed row says it is disabled; the server then lists none of its entries. */
63+
addonDisabled: false,
64+
/** objectstack#29851 — how `GET /meta/app/crm_app` answers. */
65+
targetApp: 'served' as 'served' | 'missing' | 'fails',
66+
/** objectstack#29851 — the base's customization read fails. */
67+
ownerReadFails: false,
68+
/** objectstack#29851 — the add-on declares its entries for this app. */
69+
declaredApp: 'crm_app',
5870
}));
5971

6072
vi.mock('@object-ui/auth', async (importOriginal) => {
@@ -122,10 +134,25 @@ function packageRows() {
122134
...(srv.pairListed
123135
? [{ manifest: { id: PAIR, name: 'HotCRM customizations', namespace: 'hotcrm_custom', version: '0.1.0' }, writable: true }]
124136
: []),
125-
{ manifest: { id: ADDON, name: 'Acme add-on', namespace: 'acme', version: '1.0.0' }, writable: false },
137+
addonRow(),
126138
];
127139
}
128140

141+
/**
142+
* objectstack#29851 — the add-on's listed row. As a contributor it declares
143+
* navigation for the base's app: the declaration is an address only, so the
144+
* stub's served reads decide what Studio says.
145+
*/
146+
function addonRow() {
147+
const manifest: Record<string, unknown> = { id: ADDON, name: 'Acme add-on', namespace: 'acme', version: '1.0.0' };
148+
if (srv.addonContributes) {
149+
manifest.navigationContributions = [
150+
{ app: srv.declaredApp, group: 'service_group', items: [{ id: 'nav_cases', type: 'url', url: '/cases' }] },
151+
];
152+
}
153+
return { manifest, writable: false, ...(srv.addonDisabled ? { enabled: false } : {}) };
154+
}
155+
129156
/** The base's read: its `pipeline` dashboard overridden, and a field its pair adds to `crm_account`. */
130157
const READ = {
131158
packageId: BASE,
@@ -163,8 +190,28 @@ const ACCOUNT_SERVED = {
163190
acme_score: { type: 'number', label: 'Acme score' },
164191
},
165192
};
166-
const readWith = (read: typeof READ) =>
167-
srv.contributed ? { ...read, contributions: CONTRIBUTIONS, counts: { ...read.counts, contributions: CONTRIBUTIONS.length } } : read;
193+
const readWith = (read: typeof READ) => {
194+
if (!srv.contributed) return read;
195+
// A disabled package's contributions are not listed (the server's fold drops them).
196+
const served = srv.addonDisabled ? CONTRIBUTIONS.filter((c) => c.packageId !== ADDON) : CONTRIBUTIONS;
197+
return { ...read, contributions: served, counts: { ...read.counts, contributions: served.length } };
198+
};
199+
/**
200+
* objectstack#29851 — the base's app as `/meta/app?package=` serves it: the
201+
* fold has added the add-on's entries to it. `nav_cases` once, under the
202+
* group it names; `nav_knowledge` twice (two contributions share the id), so
203+
* no label can be told for it.
204+
*/
205+
const SERVED_APP = {
206+
name: 'crm_app',
207+
label: 'CRM',
208+
navigation: [
209+
...NAV,
210+
{ id: 'service_group', type: 'group', label: 'Service', children: [{ id: 'nav_cases', type: 'url', label: 'Cases', url: '/cases' }] },
211+
{ id: 'nav_knowledge', type: 'url', label: 'Knowledge', url: '/kb' },
212+
{ id: 'nav_knowledge', type: 'url', label: 'Knowledge base', url: '/kb2' },
213+
],
214+
};
168215
const EMPTY_READ = { packageId: BASE, overrides: [], extensions: [], switchedOff: [], contributions: [], counts: { overrides: 0, extensions: 0, switchedOff: 0, contributions: 0 } };
169216

170217
/** The base's own lists, by type. The override is served under the base's name (ADR-0146 D8 item 2). */
@@ -216,6 +263,12 @@ beforeEach(() => {
216263
srv.ensure.length = 0;
217264
srv.saves.length = 0;
218265
srv.contributed = false;
266+
srv.servedFold = false;
267+
srv.addonContributes = false;
268+
srv.addonDisabled = false;
269+
srv.targetApp = 'served';
270+
srv.ownerReadFails = false;
271+
srv.declaredApp = 'crm_app';
219272
vi.stubGlobal(
220273
'fetch',
221274
vi.fn(async (input: unknown, init?: RequestInit) => {
@@ -225,8 +278,17 @@ beforeEach(() => {
225278
if (method === 'GET') srv.reads.push(`${path}${url.search}`);
226279
if (path === '/api/v1/packages') return json({ success: true, data: packageRows() });
227280
if (path === `/api/v1/packages/${BASE}/customizations`) {
281+
if (srv.ownerReadFails) return json({ success: false, error: { code: 'INTERNAL_ERROR', message: 'down' } }, 500);
228282
return json({ success: true, data: readWith(srv.pairListed ? READ : EMPTY_READ) });
229283
}
284+
if (path === `/api/v1/packages/${ADDON}/customizations`) {
285+
return json({ success: true, data: { ...EMPTY_READ, packageId: ADDON } });
286+
}
287+
if (path === '/api/v1/meta/app/crm_app') {
288+
if (srv.targetApp === 'fails') return json({ success: false, error: { message: 'down' } }, 500);
289+
if (srv.targetApp === 'missing') return json({}, 404);
290+
return json({ type: 'app', name: 'crm_app', item: { ...SERVED_APP, _packageId: BASE } });
291+
}
230292
if (path === `/api/v1/packages/${BASE}/ensure-customization` && method === 'POST') {
231293
const created = !srv.pairListed;
232294
srv.pairListed = true;
@@ -240,7 +302,9 @@ beforeEach(() => {
240302
return json({ drafts: draftRows(url.searchParams.get('packageId'), url.searchParams.get('type')) });
241303
}
242304
if (path === '/api/v1/meta/app') {
243-
return json(url.searchParams.get('package') === PAIR ? [] : [{ name: 'crm_app', label: 'CRM' }]);
305+
const pkg = url.searchParams.get('package');
306+
if (pkg === PAIR || (pkg === ADDON && srv.addonContributes)) return json([]);
307+
return json([srv.servedFold ? SERVED_APP : { name: 'crm_app', label: 'CRM' }]);
244308
}
245309
if (path === '/api/v1/meta/app/crm_app/layers') {
246310
return json({ code: null, overlay: null, overlayScope: null, effective: srv.contributed ? APP_WITH_GROUP : APP });
@@ -254,6 +318,7 @@ beforeEach(() => {
254318
const pkg = url.searchParams.get('package');
255319
if (pkg === PAIR) return json(srv.pairListed ? pairList(type) : []);
256320
if (pkg === BASE) return json(BASE_LISTS[type] ?? []);
321+
if (pkg === ADDON && srv.addonContributes) return json([]);
257322
return json([...(BASE_LISTS[type] ?? []), ...pairList(type)]);
258323
}
259324
const one = /^\/api\/v1\/meta\/([^/]+)\/([^/]+)$/.exec(path);
@@ -651,3 +716,130 @@ describe('items another package contributes are drawn greyed, with the package (
651716
expect(srv.reads.filter((r) => r.startsWith(`/api/v1/packages/${BASE}/customizations`)).length).toBeGreaterThan(0);
652717
});
653718
});
719+
720+
/**
721+
* objectstack#29851 — a contributed nav row reads the label the live app
722+
* shows, looked up by `(app, id)` in the served app row the pillar already
723+
* reads; a package that only contributes says what it adds, as the owner's
724+
* served read lists it.
725+
*/
726+
describe('contributed nav entries show their served label, and a contributor says what it adds (objectstack#29851)', () => {
727+
const contributed = (root: HTMLElement, name: string) =>
728+
root.querySelector<HTMLElement>(`[data-contributed="nav"][data-testid="contributed-nav-${name}"]`);
729+
730+
it('a unique id shows its served label with the id as a second line; a shared id keeps the bare id', async () => {
731+
srv.contributed = true;
732+
srv.servedFold = true;
733+
renderAt(`/studio/${BASE}/interfaces`);
734+
await interfacesReady();
735+
await waitFor(() => expect(contributed(ifRail(), 'nav_cases')).not.toBeNull(), { timeout: 8000 });
736+
const cases = contributed(ifRail(), 'nav_cases')!;
737+
await waitFor(() => expect(within(cases).getByTestId('contributed-label')).toHaveTextContent('Cases'));
738+
expect(within(cases).getByTestId('contributed-id')).toHaveTextContent('nav_cases');
739+
// Two served entries share `nav_knowledge`: no label is guessed.
740+
const knowledge = contributed(ifRail(), 'nav_knowledge')!;
741+
expect(within(knowledge).getByTestId('contributed-label')).toHaveTextContent('nav_knowledge');
742+
expect(within(knowledge).queryByTestId('contributed-id')).toBeNull();
743+
// No request of its own: the app item is never read for a package that has an app.
744+
expect(srv.reads.filter((r) => r.startsWith('/api/v1/meta/app/crm_app') && !r.includes('/layers'))).toEqual([]);
745+
});
746+
747+
it('CONTROL: with no served fold, the row keeps the id alone', async () => {
748+
srv.contributed = true;
749+
renderAt(`/studio/${BASE}/interfaces`);
750+
await interfacesReady();
751+
await waitFor(() => expect(contributed(ifRail(), 'nav_cases')).not.toBeNull(), { timeout: 8000 });
752+
const cases = contributed(ifRail(), 'nav_cases')!;
753+
expect(within(cases).getByTestId('contributed-label')).toHaveTextContent('nav_cases');
754+
expect(within(cases).queryByTestId('contributed-id')).toBeNull();
755+
});
756+
757+
/** The package-list reads this harness's surface sends for an app-less package, with or without the empty state's reads. */
758+
const SURFACE_PACKAGE_LIST_READS = 2;
759+
const NO_APP = () => t('engine.studio.if.noApp', 'en');
760+
const ADDS_TWO = () => tFormat('engine.studio.if.contributesOther', 'en', { count: 2, package: 'HotCRM' });
761+
762+
async function contributorRailSays(): Promise<HTMLElement> {
763+
renderAt(`/studio/${ADDON}/interfaces`);
764+
const rail = await screen.findByTestId('if-no-app-rail', undefined, { timeout: 8000 });
765+
return rail;
766+
}
767+
768+
it('a contributor-only package says what it adds to the owner’s app, from two reads, and opens the owner', async () => {
769+
srv.contributed = true;
770+
srv.addonContributes = true;
771+
const rail = await contributorRailSays();
772+
await waitFor(() => expect(rail).toHaveTextContent(ADDS_TWO()), { timeout: 8000 });
773+
expect(rail).toHaveTextContent(t('engine.studio.if.noOwnApp', 'en'));
774+
expect(rail).not.toHaveTextContent(NO_APP());
775+
const pane = screen.getByTestId('if-contributes-elsewhere');
776+
expect(pane).toHaveTextContent(t('engine.studio.if.noOwnAppTitle', 'en'));
777+
expect(pane).toHaveTextContent(ADDS_TWO());
778+
// The request budget: one owner lookup and one customization read for the one target,
779+
// and no package-list request of its own: the surface's reads are reused, as many as
780+
// the CONTROL below (a package declaring nothing) sends.
781+
expect(srv.reads.filter((r) => r === '/api/v1/packages')).toHaveLength(SURFACE_PACKAGE_LIST_READS);
782+
expect(srv.reads.filter((r) => r === '/api/v1/meta/app/crm_app')).toHaveLength(1);
783+
expect(srv.reads.filter((r) => r.startsWith(`/api/v1/packages/${BASE}/customizations`))).toHaveLength(1);
784+
fireEvent.click(within(pane).getByTestId('if-open-owner'));
785+
await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent(`/studio/${encodeURIComponent(BASE)}/interfaces`));
786+
});
787+
788+
async function keepsTodaysText(): Promise<void> {
789+
const rail = await contributorRailSays();
790+
await waitFor(() => expect(rail).toHaveTextContent(NO_APP()), { timeout: 8000 });
791+
// Let the reads settle: the text still says what it said.
792+
await new Promise((r) => setTimeout(r, 300));
793+
expect(rail).toHaveTextContent(NO_APP());
794+
expect(screen.queryByTestId('if-contributes-elsewhere')).toBeNull();
795+
expect(screen.getByText(t('engine.studio.if.noAppTitle', 'en'))).toBeInTheDocument();
796+
}
797+
798+
it('FALLBACK: a disabled package (the owner lists none of its entries) keeps today’s text', async () => {
799+
srv.contributed = true;
800+
srv.addonContributes = true;
801+
srv.addonDisabled = true;
802+
await keepsTodaysText();
803+
});
804+
805+
it('FALLBACK: a missing target app keeps today’s text, and the owner is never read', async () => {
806+
srv.contributed = true;
807+
srv.addonContributes = true;
808+
srv.targetApp = 'missing';
809+
await keepsTodaysText();
810+
expect(srv.reads.filter((r) => r.startsWith(`/api/v1/packages/${BASE}/customizations`))).toEqual([]);
811+
});
812+
813+
it('FALLBACK: a failed app read or a failed owner read keeps today’s text', async () => {
814+
srv.contributed = true;
815+
srv.addonContributes = true;
816+
srv.targetApp = 'fails';
817+
await keepsTodaysText();
818+
cleanup();
819+
srv.targetApp = 'served';
820+
srv.ownerReadFails = true;
821+
await keepsTodaysText();
822+
});
823+
824+
it('FALLBACK: 0 entries for the declared app keeps today’s text', async () => {
825+
srv.contributed = true;
826+
srv.addonContributes = true;
827+
// The owner serves the add-on's field, flow and `other_app` entry, but no entry on `crm_app`.
828+
const answer = CONTRIBUTIONS.splice(0, CONTRIBUTIONS.length);
829+
CONTRIBUTIONS.push(...answer.filter((c) => !(c.kind === 'nav' && 'app' in c && c.app === 'crm_app')));
830+
try {
831+
await keepsTodaysText();
832+
expect(srv.reads.filter((r) => r.startsWith(`/api/v1/packages/${BASE}/customizations`))).toHaveLength(1);
833+
} finally {
834+
CONTRIBUTIONS.splice(0, CONTRIBUTIONS.length, ...answer);
835+
}
836+
});
837+
838+
it('CONTROL: a package with no app that declares no contribution reads nothing more', async () => {
839+
srv.addonContributes = true;
840+
srv.declaredApp = '';
841+
await keepsTodaysText();
842+
expect(srv.reads.filter((r) => r.startsWith('/api/v1/meta/app/'))).toEqual([]);
843+
expect(srv.reads.filter((r) => r === '/api/v1/packages')).toHaveLength(SURFACE_PACKAGE_LIST_READS);
844+
});
845+
});

0 commit comments

Comments
 (0)