Skip to content

Commit 6aba7cb

Browse files
fix(studio): Interfaces lists a package's pages, dashboards, reports and views no nav entry names (#26982)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8XrYW43xwSRnPqPsLzFpypo)_ Fixes #26889 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Studio › Interfaces 的左栏在导航树下新增「不在导航中」分区(页面/仪表板/报表/视图),列出包内没有导航项打开的条目;没有 app 的包也列出,点开进现有画布与检查器。 - 为什么改:原来左栏只画导航树,菜单外的列表视图、记录页、模块包的仪表板在 Studio 里打不开(HotCRM 18.2.0 实测 65 个视图只见 4 个)。 - 风险与回滚:只改 Interfaces 左栏的列表与无 app 空状态,不改导航的编辑与保存;回滚即 revert 本 PR。 - 先看:`StudioDesignSurface.tsx` 的 `navOpenedItems` / `OffNavSections` 与画布空状态条件;`packageSurfaces.ts` 的 `keep` 参数;新测试文件。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/InterfacesPillar.offNavItems-26889.test.tsx`,预期 7/7 通过。 - 需要你做的:无,席位落地。 ## What changes - **The Interfaces rail lists what no nav entry opens.** Under the navigation tree (or under the "no app yet" notice), a "Not in navigation" block holds one collapsible section per type, Pages, Dashboards, Reports, then Views, listing the package's items of that type that no nav entry opens. A row opens its item in the pillar's existing canvas and inspector, exactly as a tree row does (`pickSurface`, shared with the tree, keeps the held-widget confirmation). On a read-only package the canvas opens read-only, as it does for a tree row. Nothing is written by listing: no nav entry is added. - **One reader, reused.** The items come from `loadPackageSurfaces` (published ∪ draft, published label wins, a draft-only item by its `_drafts` label). It gains an optional `keep` filter over the published rows, fed each type's own `MetadataResourceConfig.listFilter`, so the rail lists what Setup › Metadata lists: the aggregated view container the server keeps beside its expanded view items is not an item. No third reader was added. - **No item is listed twice.** `navOpenedItems` walks the nav buffer with the tree's own binding (`resolveSurface`): a page / dashboard / report entry's target, and the view an `object` entry's `viewName` names, under the view item's name (`OBJECT.VIEWNAME`, via `listViewTargetOf`). A plain `object` entry opens its object, so that object's views stay listed. The sections are hidden while nav editing (the rail is the nav editor then) and while the app is still resolving. - **A package with no app** keeps its rail notice with the sections under it; its canvas keeps the empty state until a row opens an item (`appStatus === 'missing' && !error && !current`). - **Strings:** five `engine.studio.if.offNav.*` rows in the studio namespace (`en`, `zh`); none in `metadata-admin/i18n.ts`. Changeset: `@object-ui/i18n` patch. - **Test mocks:** `StudioDesignSurface.interfacesCreate-11823` and `interfacesCreatePage-11823` served another package's item from the pillar's own package-scoped list, which the server never does; their mock now keeps scope (`inScope`), the assertions are unchanged. ## Tests - `InterfacesPillar.offNavItems-26889.test.tsx` (new, 7 tests): the four sections list exactly the off-nav items (record page, draft-only page, dashboard, report, view); nav-opened items appear once (in the tree), the `viewName` view and the aggregated container never in the sections; a view, a record page and a dashboard each open in the canvas with the row active; a no-app package keeps its notice and opens its dashboard and page; read-only opens with no `onPatch` and no save, writable with one; a package whose every item an entry opens draws no sections (with a positive control). - Ablation 1 (drop the non-nav read: `loadPackageSurfaces(…).catch(` replaced by `Promise.resolve([] as PackageSurface[]).catch(`): 7/7 red; restored, blob equals HEAD. - Ablation 2 (drop the nav dedupe filter): the listing, the listed-once and the control tests red (3/7); restored, blob equals HEAD. ## Acceptance notes - A `?surface=` deep link to an off-nav item (for example the running app's *Edit app* on a record page route) still restores to the first nav leaf: the restore reads the nav tree only, in the app load, outside this card's declared region. Reported for the seat to file. - The sections are open by default; Views sits last because a package ships many. - A type whose read fails lists nothing, as the pillar's object read leaves its picker empty. ## Verification All readings at head `c11187f3` (the fix commits plus a merge of `origin/main`), app-shell dependency closure rebuilt after the merge. <details><summary>Tests (local, under the shared verification lock)</summary> - `pnpm --dir ui exec vitest run` on the new test, both edited create tests, and the nav-tree, no-app-label and read-only Interfaces tests: 6 files, 54 tests passed. - Before the merge, at `d9302876`: the whole `packages/app-shell/src/views/studio-design/` directory, 234 files; the two create tests failed on the mock described above, then passed after it was scoped. Outside importers of the changed modules (2 files) plus `packages/i18n/`: 91 files passed. App-shell term tests (`zh-base-term`, `studio-locale`, `metadata-form-i18n`): 19 tests passed. - `pnpm --filter @object-ui/app-shell run type-check`: exit 0. `pnpm --filter @object-ui/i18n run type-check`: exit 0. `eslint` on the changed files: 0 errors, no warning on a changed line. - `pnpm check:new-line-citations`: 0 new citations. </details> <details><summary>Derived gates (dispatch-gates --commands; reconciled with --ran: 34 derived, 34 run, 0 not measured)</summary> - `node scripts/check-adr-0087-registration.mjs --base origin/main` :: exit 0 - `node scripts/check-adr-0087-registration.mjs --self-test` :: exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-corpus.mjs` :: exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main` :: exit 0 - `node scripts/check-empty-changeset.mjs --self-test` :: exit 0 - `node scripts/check-sdui-manifest.mjs` :: exit 0 - `node scripts/check-sdui-manifest.mjs --self-test` :: exit 0 - `node scripts/pm/release-rehearsal-clone.mjs --self-test` :: exit 0 - `node scripts/release-pending-publish.mjs --self-test` :: exit 0 - `pnpm --dir ui exec vitest run scripts/` :: exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` :: exit 0 - `pnpm check:changeset-gate-self-tests` :: exit 0 - `pnpm check:closed-ceremony-text` :: exit 0 - `pnpm check:cross-package-test-inputs` :: exit 0 - `pnpm check:dispatcher-error-vocabulary` :: exit 0 - `pnpm check:driver-memory-census` :: exit 0 - `pnpm check:engine-double-contract` :: exit 0 - `pnpm check:gitlink-declared` :: exit 0 - `pnpm check:nul-bytes` :: exit 0 - `pnpm check:pm-changeset-deadline-census` :: exit 0 - `pnpm check:published-files` :: exit 0 - `pnpm check:query-options-erasure` :: exit 0 - `pnpm check:refd-timer-probe` :: exit 0 - `pnpm check:skill-identifier-liveness` :: exit 0 - `pnpm check:test-source-alias` :: exit 0 - `pnpm check:tier-file-adoption` :: exit 0 - `pnpm check:type-check-coverage` :: exit 0 - `pnpm check:type-check-debt` :: exit 0 - `pnpm check:undeclared-dep-imports` :: exit 0 - `pnpm check:watch-hint-literal` :: exit 0 - `pnpm check:where-matcher` :: exit 0 </details> Families CI runs beyond the derived list are read from this PR's check-runs, not re-run locally. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (changed files linted locally; the repo-wide run is CI's) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer brief above is the os-dev order's Chinese section). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbZJXVERQKCsD3gtpru)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 68748c3 commit 6aba7cb

8 files changed

Lines changed: 490 additions & 20 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/i18n': patch
3+
---
4+
5+
fix(studio): Interfaces lists a package's pages, dashboards, reports and views whether or not a navigation entry names them
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- Studio › Interfaces used to list only what the app's navigation names, so a list view off the menu, a record page, or any item of a package with no app could not be opened in Studio at all. Under the navigation tree the rail now has one section per type ("Not in navigation": Pages, Dashboards, Reports, Views) listing the package's items no entry opens, published and draft, and each row opens its item in the same canvas and inspector, read-only on an installed package. A package with no app keeps its "no app yet" notice and lists what it ships under it. An item a navigation entry opens keeps its row in the tree and is not listed again.
11+
- The `studio` namespace gains the five `engine.studio.if.offNav.*` strings, in `en` and `zh`. No export changes.
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26889 — the Interfaces pillar lists a package's views, pages,
5+
* dashboards and reports whether or not a navigation entry names them, and a
6+
* package with no app still lists what it ships.
7+
*
8+
* The rail used to be the app's navigation tree and nothing else, so an item
9+
* no entry opens (a list view off the menu, a record page, a module package's
10+
* dashboard) could not be opened in Studio at all, and a package with no app
11+
* showed only its "no app yet" notice. Under the tree there is now one section
12+
* per type with the items no entry opens; each row opens its item in the
13+
* pillar's canvas, read-only on a read-only package.
14+
*
15+
* - an item a nav entry opens keeps its tree row and is not listed again (a
16+
* page / dashboard entry's target, and the view an `object` entry's
17+
* `viewName` names);
18+
* - a draft-only item is listed, by the label its `_drafts` row carries;
19+
* - the aggregated view container the server keeps for dual-read is not an
20+
* item (Setup › Metadata leaves it out the same way);
21+
* - the "no app yet" notice stays, with the sections under it.
22+
*/
23+
import '@testing-library/jest-dom/vitest';
24+
import * as React from 'react';
25+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
26+
import { act, render, screen, fireEvent, cleanup, within } from '@testing-library/react';
27+
import { MemoryRouter } from 'react-router-dom';
28+
29+
const PKG = 'com.acme.crm';
30+
31+
const NAV = [
32+
{ id: 'nav_home', type: 'page', label: 'Home menu', pageName: 'home' },
33+
{ id: 'nav_ops', type: 'dashboard', label: 'Ops board', dashboardName: 'ops' },
34+
{ id: 'nav_my_leads', type: 'object', label: 'My leads', objectName: 'crm_lead', viewName: 'mine' },
35+
];
36+
37+
/** What the mocked server holds, per metadata type: published rows and `_drafts` headers. */
38+
const server = vi.hoisted(() => ({
39+
published: new Map<string, Array<Record<string, unknown>>>(),
40+
drafts: new Map<string, Array<Record<string, unknown>>>(),
41+
}));
42+
43+
const mockClient = vi.hoisted(() => ({
44+
list: vi.fn(async (type: string) => server.published.get(type) ?? []),
45+
listDrafts: vi.fn(async (opts?: { type?: string }) => server.drafts.get(opts?.type ?? '') ?? []),
46+
listTypes: vi.fn(async () => ({ entries: [] })),
47+
get: vi.fn(async () => null),
48+
references: vi.fn(async () => []),
49+
layered: vi.fn(async (type: string, name: string) => {
50+
const eff = (server.published.get(type) ?? []).find((row) => row.name === name) ?? null;
51+
return { code: null, overlay: eff, overlayScope: eff ? 'env' : null, effective: eff, editable: true, deletable: true, resettable: false, lock: 'none' };
52+
}),
53+
getDraft: vi.fn(async (type: string, name: string) => {
54+
throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 });
55+
}),
56+
save: vi.fn(async (type: string, name: string, item: unknown) => ({ type, name, item })),
57+
publish: vi.fn(async () => ({ success: true })),
58+
reset: vi.fn(async () => ({})),
59+
}));
60+
61+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
62+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
63+
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
64+
});
65+
66+
vi.mock('@object-ui/react', async (importOriginal) => {
67+
const mod = await importOriginal<typeof import('@object-ui/react')>();
68+
return { ...mod, useAdapter: () => ({}) };
69+
});
70+
71+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), dismiss: vi.fn() } }));
72+
73+
// The `view` resource's list filter (the aggregated container is no item) is
74+
// registered here, as the console registers it.
75+
import '../../services/builtinComponents';
76+
import { InterfacesPillar } from './StudioDesignSurface';
77+
import { registerMetadataPreview, type MetadataPreviewProps } from '../metadata-admin/preview-registry';
78+
import { STUDIO_RAIL_ROW_ACTIVE } from './studio-tokens';
79+
80+
vi.stubGlobal(
81+
'fetch',
82+
vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })),
83+
);
84+
85+
/** A canvas that says what it was opened on, and whether it may write. */
86+
function StubPreview({ type, name, onPatch }: MetadataPreviewProps) {
87+
return (
88+
<div data-testid="stub-canvas" data-writable={onPatch ? 'yes' : 'no'}>
89+
{`${type}:${name}`}
90+
</div>
91+
);
92+
}
93+
for (const type of ['page', 'dashboard', 'report', 'view']) registerMetadataPreview(type, StubPreview);
94+
95+
const page = (name: string, label: string, extra: Record<string, unknown> = {}) => ({ name, label, type: 'app', ...extra });
96+
97+
function seedWithApp() {
98+
server.published.set('app', [{ name: 'crm_app', label: 'CRM', navigation: NAV }]);
99+
server.published.set('page', [page('home', 'Home'), page('lead_detail', 'Lead Detail', { type: 'record', object: 'crm_lead' })]);
100+
server.published.set('dashboard', [
101+
{ name: 'ops', label: 'Ops' },
102+
{ name: 'sales_overview', label: 'Sales Overview' },
103+
]);
104+
server.published.set('report', [{ name: 'pipeline', label: 'Pipeline' }]);
105+
server.published.set('view', [
106+
{ name: 'crm_lead.mine', label: 'Mine', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } },
107+
{ name: 'crm_lead.hot', label: 'Hot Leads', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } },
108+
// The container the server keeps beside the expanded items: no item of its own.
109+
{ name: 'crm_lead', object: 'crm_lead', listViews: { hot: { columns: ['name'] } } },
110+
]);
111+
server.drafts.set('page', [{ name: 'welcome', label: 'Welcome', packageId: PKG }]);
112+
}
113+
114+
function seedNoApp() {
115+
server.published.set('dashboard', [{ name: 'service_overview', label: 'Service Overview' }]);
116+
server.published.set('page', [page('case_detail', 'Case Detail', { type: 'record', object: 'crm_case' })]);
117+
}
118+
119+
beforeEach(() => {
120+
server.published.clear();
121+
server.drafts.clear();
122+
for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear();
123+
});
124+
125+
afterEach(cleanup);
126+
127+
function renderPillar(props: { readOnly?: boolean } = {}) {
128+
render(
129+
<MemoryRouter initialEntries={[`/studio/${PKG}/interfaces`]}>
130+
<InterfacesPillar packageId={PKG} packageLabel="Acme CRM" {...props} />
131+
</MemoryRouter>,
132+
);
133+
}
134+
135+
/** The sections under the tree, once the package's items are read. */
136+
async function offNav(): Promise<HTMLElement> {
137+
return screen.findByTestId('if-off-nav', undefined, { timeout: 8000 });
138+
}
139+
140+
/** Every rail row's `type · name`, in rail order. */
141+
function railTitles(scope: HTMLElement): string[] {
142+
return within(scope)
143+
.getAllByRole('button')
144+
.map((b) => b.getAttribute('title') ?? '')
145+
.filter((title) => title.includes(' · '));
146+
}
147+
148+
describe('objectstack#26889 — Interfaces lists what no navigation entry names', () => {
149+
it('lists the views, pages, dashboards and reports no entry opens, one section per type', async () => {
150+
seedWithApp();
151+
renderPillar();
152+
const sections = await offNav();
153+
expect(within(sections).getByTestId('if-off-nav-page')).toBeInTheDocument();
154+
expect(railTitles(sections)).toEqual([
155+
'page · lead_detail',
156+
'page · welcome',
157+
'dashboard · sales_overview',
158+
'report · pipeline',
159+
'view · crm_lead.hot',
160+
]);
161+
// A draft-only page reads as the label its `_drafts` row carries.
162+
expect(within(sections).getByRole('button', { name: 'Welcome' })).toHaveAttribute('title', 'page · welcome');
163+
// Each type was read for this package, published and draft.
164+
for (const type of ['view', 'page', 'dashboard', 'report']) {
165+
expect(mockClient.list).toHaveBeenCalledWith(type, { packageId: PKG });
166+
expect(mockClient.listDrafts).toHaveBeenCalledWith({ packageId: PKG, type });
167+
}
168+
});
169+
170+
it('an item a nav entry opens is listed once, in the tree, and never again', async () => {
171+
seedWithApp();
172+
renderPillar();
173+
const sections = await offNav();
174+
const rail = sections.closest('nav') as HTMLElement;
175+
const titles = railTitles(rail);
176+
expect(titles.filter((t) => t === 'page · home')).toHaveLength(1);
177+
expect(titles.filter((t) => t === 'dashboard · ops')).toHaveLength(1);
178+
// The view the `My leads` entry names (`viewName: 'mine'`) is that entry's row.
179+
expect(titles).not.toContain('view · crm_lead.mine');
180+
expect(within(sections).queryByText('Home')).toBeNull();
181+
expect(within(sections).queryByText('Mine')).toBeNull();
182+
// The aggregated container is not listed as a view.
183+
expect(titles).not.toContain('view · crm_lead');
184+
});
185+
186+
it('a view, a record page and a dashboard no entry names each open in the canvas', async () => {
187+
seedWithApp();
188+
renderPillar();
189+
const sections = await offNav();
190+
for (const [label, opened] of [
191+
['Hot Leads', 'view:crm_lead.hot'],
192+
['Lead Detail', 'page:lead_detail'],
193+
['Sales Overview', 'dashboard:sales_overview'],
194+
] as const) {
195+
fireEvent.click(within(sections).getByRole('button', { name: label }));
196+
expect(await screen.findByText(opened, undefined, { timeout: 8000 })).toBeInTheDocument();
197+
expect(screen.getByTestId('if-breadcrumb')).toHaveTextContent(label);
198+
// The row it was opened from is the active one, and only it.
199+
const active = within(sections).getAllByRole('button').filter((b) => b.className.includes(STUDIO_RAIL_ROW_ACTIVE));
200+
expect(active.map((b) => b.textContent)).toEqual([label]);
201+
}
202+
});
203+
204+
it('a package with no app keeps its notice and lists its dashboard and page, which open', async () => {
205+
seedNoApp();
206+
renderPillar();
207+
const sections = await offNav();
208+
expect(screen.getByText('This package has no app yet.')).toBeInTheDocument();
209+
expect(screen.getByText('This package has no app yet')).toBeInTheDocument();
210+
expect(railTitles(sections)).toEqual(['page · case_detail', 'dashboard · service_overview']);
211+
212+
fireEvent.click(within(sections).getByRole('button', { name: 'Service Overview' }));
213+
expect(await screen.findByText('dashboard:service_overview', undefined, { timeout: 8000 })).toBeInTheDocument();
214+
// The canvas shows the item now, not the empty state; the rail keeps its notice.
215+
expect(screen.queryByText('This package has no app yet')).toBeNull();
216+
expect(screen.getByText('This package has no app yet.')).toBeInTheDocument();
217+
218+
fireEvent.click(within(sections).getByRole('button', { name: 'Case Detail' }));
219+
expect(await screen.findByText('page:case_detail', undefined, { timeout: 8000 })).toBeInTheDocument();
220+
});
221+
222+
it('a read-only package lists them too, and opens them read-only', async () => {
223+
seedNoApp();
224+
renderPillar({ readOnly: true });
225+
const sections = await offNav();
226+
fireEvent.click(within(sections).getByRole('button', { name: 'Service Overview' }));
227+
const canvas = await screen.findByTestId('stub-canvas', undefined, { timeout: 8000 });
228+
expect(canvas).toHaveTextContent('dashboard:service_overview');
229+
expect(canvas).toHaveAttribute('data-writable', 'no');
230+
expect(mockClient.save).not.toHaveBeenCalled();
231+
});
232+
233+
it('CONTROL: a writable package opens them writable', async () => {
234+
seedNoApp();
235+
renderPillar();
236+
const sections = await offNav();
237+
fireEvent.click(within(sections).getByRole('button', { name: 'Service Overview' }));
238+
expect(await screen.findByTestId('stub-canvas', undefined, { timeout: 8000 })).toHaveAttribute('data-writable', 'yes');
239+
});
240+
241+
it('CONTROL: a package whose every item an entry opens draws no sections', async () => {
242+
server.published.set('app', [{ name: 'crm_app', label: 'CRM', navigation: NAV }]);
243+
server.published.set('page', [page('home', 'Home')]);
244+
server.published.set('dashboard', [{ name: 'ops', label: 'Ops' }]);
245+
renderPillar();
246+
await screen.findByRole('button', { name: 'Home menu Page' }, { timeout: 8000 });
247+
await vi.waitFor(() => expect(mockClient.listDrafts).toHaveBeenCalledWith({ packageId: PKG, type: 'view' }));
248+
// Every read settled, and what it answered rendered.
249+
await act(async () => {
250+
await Promise.all(mockClient.listDrafts.mock.results.map((r) => r.value));
251+
await new Promise((r) => setTimeout(r, 50));
252+
});
253+
expect(screen.queryByTestId('if-off-nav')).toBeNull();
254+
// CONTROL of the control: the same package with one more dashboard lists it.
255+
cleanup();
256+
server.published.set('dashboard', [{ name: 'ops', label: 'Ops' }, { name: 'extra', label: 'Extra' }]);
257+
renderPillar();
258+
expect(railTitles(await offNav())).toEqual(['dashboard · extra']);
259+
});
260+
});

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesCreate-11823.test.tsx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ const DATASETS = [
5959
},
6060
];
6161

62+
/** A package other than the pillar's: its items answer the unscoped list only. */
63+
const OTHER_PKG = 'com.other.app';
64+
65+
/** A package-scoped list keeps the rows of that package, as the server's does (objectstack#26889). */
66+
function inScope(rows: Array<Record<string, unknown>>, opts: { packageId?: string }) {
67+
return opts.packageId ? rows.filter((r) => (r._packageId ?? opts.packageId) === opts.packageId) : rows;
68+
}
69+
6270
let apps: Array<Record<string, unknown>>;
6371
let published: Record<string, Array<Record<string, unknown>>>;
6472
let draftHeaders: Array<{ type: string; name: string; packageId: string }>;
@@ -71,10 +79,10 @@ const mockClient = {
7179
draftRows[`${type}:${name}`] = body;
7280
return { success: true, version: 'v1', state: 'draft' };
7381
}),
74-
list: vi.fn(async (type: string) => {
82+
list: vi.fn(async (type: string, opts: { packageId?: string } = {}) => {
7583
if (type === 'app') return apps;
7684
if (type === 'dataset') return DATASETS;
77-
return published[type] ?? [];
85+
return inScope(published[type] ?? [], opts);
7886
}),
7987
listDrafts: vi.fn(async (opts: { type?: string } = {}) =>
8088
draftHeaders.filter((h) => !opts.type || h.type === opts.type),
@@ -290,7 +298,7 @@ describe('the Interfaces pillar creates a dashboard and a report (objectui#11823
290298
});
291299

292300
it('an identifier a dashboard in another package holds is refused in the dialog, and nothing is written', async () => {
293-
published.dashboard = [HOME_DASH, { name: 'sales_overview', label: 'Their sales' }];
301+
published.dashboard = [HOME_DASH, { name: 'sales_overview', label: 'Their sales', _packageId: OTHER_PKG }];
294302
mountPillar();
295303
await ready();
296304
const dialog = await openCreate('New dashboard');

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesCreatePage-11823.test.tsx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ const LANDING = { name: 'landing', label: 'Landing', type: 'app', kind: 'html',
4343

4444
const NAV = [{ id: 'nav_landing', type: 'page', label: 'Landing', pageName: 'landing' }];
4545

46+
47+
/** A package other than the pillar's: its items answer the unscoped list only. */
48+
const OTHER_PKG = 'com.other.app';
49+
50+
/** A package-scoped list keeps the rows of that package, as the server's does (objectstack#26889). */
51+
function inScope(rows: Array<Record<string, unknown>>, opts: { packageId?: string }) {
52+
return opts.packageId ? rows.filter((r) => (r._packageId ?? opts.packageId) === opts.packageId) : rows;
53+
}
54+
4655
let apps: Array<Record<string, unknown>>;
4756
let published: Record<string, Array<Record<string, unknown>>>;
4857
let draftHeaders: Array<{ type: string; name: string; packageId: string }>;
@@ -53,7 +62,9 @@ const mockClient = {
5362
draftRows[`${type}:${name}`] = body;
5463
return { success: true, version: 'v1', state: 'draft' };
5564
}),
56-
list: vi.fn(async (type: string) => (type === 'app' ? apps : (published[type] ?? []))),
65+
list: vi.fn(async (type: string, opts: { packageId?: string } = {}) =>
66+
type === 'app' ? apps : inScope(published[type] ?? [], opts),
67+
),
5768
listDrafts: vi.fn(async (opts: { type?: string } = {}) =>
5869
draftHeaders.filter((h) => !opts.type || h.type === opts.type),
5970
),
@@ -268,7 +279,7 @@ describe('the Interfaces pillar creates a page and opens it on source plus live
268279
});
269280

270281
it('an identifier a page in another package holds is refused in the dialog, and nothing is written', async () => {
271-
published.page = [LANDING, { name: 'team_handbook', label: 'Their handbook', type: 'app' }];
282+
published.page = [LANDING, { name: 'team_handbook', label: 'Their handbook', type: 'app', _packageId: OTHER_PKG }];
272283
mountPillar();
273284
await ready();
274285
const dialog = await openNewPage();

0 commit comments

Comments
 (0)