Skip to content

Commit 50e35a2

Browse files
fix(app-shell): Studio's cross-package metadata list keeps its URL and lists every package (#29581)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbGL1oMxnpMEbXR6kM6npjVg)_ Fixes #29566 Clause-②: no Metadata-protocol: none The two lines are copied from the claim (comment 6586144352). ## 维护者速读 - 改了什么:Studio「全部元数据」的跨包列表(`/studio/~all/metadata/TYPE`)不再自己往地址里补 `?package=`,也不再被带去某一个包的页面;它列出读到的所有行(各个包的条目,加上不属于任何包的条目),每行点开时进入它所属的包。只有「新建」时才挑一个包(沿用 Setup 列表原来的挑法),并打开那个包的新建页。 - 为什么改:列表在区域没有指定包时,会把第一个项目包写进 `?package=`,区域随即把页面转到那个包。于是跨包列表根本打不开,带 `?package=all` 也一样。所有不带包的 Setup 元数据旧链接和「已打包自动化」书签都落在这里(验收项 platform-core.setup-metadata-redirects-to-studio 的 acceptance[0] 和 [3])。 - 风险与回滚:只改 `ResourceListPage.tsx` 里跨包分支的范围判断(宿主存在、packageId 为空、不在环境范围)。某个包的区域和 Setup 自己的列表行为不变,有对照用例钉住。跨包列表现在会显示平台包和无包条目,这是该分支的既定含义。回滚即撤销本 PR。 - 先看:`ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx`(`allLeg`、`pickedPackage` / `activePackage` 的拆分、修补 effect 里的 `if (host) return`、`handleCreate` 的跨包分支);新测试 `StudioMetadataArea.allList-29566.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioMetadataArea.allList-29566.test.tsx`,预期 6 个用例全过;把 `ResourceListPage.tsx` 换回 main 的版本时其中 4 个转红。 - 需要你做的:无,席位落地。 ## What changes - **`ResourceListPage.tsx`** (`DefaultMetadataList`): a new `allLeg` flag is true under the area's cross-package leg outside the environment scope (host present, `packageId` null, no `?scope=environment`). On that leg: - the `?package=` repair effect never runs (it now returns for any host, since a package's area was already exempt); - the list is scoped to no package: `activePackage` is `null`, and `scopedItems` keeps every row the read returns, as the environment scope already does. Each row still links with its owning package's `?package=`, so opening a row moves onto that package's leg (`areaLocationRedirect`); a package-less row opens by name on the cross-package leg; - the "No project packages installed" empty state and the packages-pending wait do not apply, because the leg needs no project package to list; - Create picks a package through the existing pick, now named `pickedPackage` (the current app's package, then the project package owning the most rows of the type, then the first project package), and navigates to `./new?package=PICK`, which the area opens as that package's create. With no project package it opens the existing create-a-package dialog. - A package's area (host with a `packageId`) and Setup's list (no host) take the same branches as before; `pickedPackage` is the old `activePackage` memo minus its host line. - **Comment-only:** `metadata-admin/metadataAreaHost.ts` (the `packageId` doc and the scope bullet) and `studio-design/metadataArea.ts` (the `~all` leg doc and `areaLocationRedirect`'s no-`?package=` bullet) now say that the cross-package leg lists a type across packages and repairs no package in. ## Tests - **New `studio-design/StudioMetadataArea.allList-29566.test.tsx`**, through the area's real routes (`/studio/~all/metadata/*`, `/studio/:packageId/metadata/*`) with the real list page. Only the metadata client, the types list, the locale, the author gate and the writable probe are doubled: - `/studio/~all/metadata/object` and `/studio/~all/metadata/object?package=all` keep their URL and list rows of two project packages, a platform package and a package-less row; each row's href names its owning package; - `/studio/~all/metadata/flow` (the Packaged automation landing) keeps its URL and lists both packages' flows; - Create on the cross-package list lands on `/studio/com.example.crm/metadata/object/new?package=com.example.crm`; - controls: a package's area keeps its leg and lists only its own rows; Setup's list (no area) still repairs `?package=` onto a project package. - **Red on main:** with `ResourceListPage.tsx` restored to the main blob (`fce5d4d2`, `allLeg` count 0 on disk), 4 of 6 go red: both `~all/object` cases, the `~all/flow` case and the create case. Both controls stay green. Restored with `git checkout HEAD --`; `git diff HEAD` empty and the blob hash equals HEAD's. - 35 test files that name `ResourceListPage`, `MetadataResourceListPage`, `metadataAreaHost`, `metadataArea` or `StudioMetadataArea`: 478/478 passed. - `turbo run type-check --filter=@object-ui/app-shell`: 65/65 tasks, exit 0. ESLint on the four changed files: 0 errors; the 23 warnings in `ResourceListPage.tsx` are the same 23 the main blob has. - `node scripts/check-sdui-manifest.mjs`: exit 0. ## Acceptance notes - **What the cross-package list shows.** Every row the read returns, including platform packages' items and rows of no package, as the leg is defined (`metadataArea.ts`: every package plus the environment's own items). The seat accepted this reading in the claim; a project-packages-only list would be a follow-up. - **The claim names `studio-design/metadataAreaHost.ts`;** the file is `metadata-admin/metadataAreaHost.ts`. It is the one `metadataAreaHost.ts` in the tree, and the edit there is comment-only, as claimed. - Not re-measured on a live console; the routing is pinned in jsdom through the real area and list. ## Verification All readings at head `28c07a54`. - `node scripts/pm/dispatch-gates.mjs --ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm --dir ui exec vitest run scripts/` (locked): 170 files passed, 2 skipped; 4972 tests passed. - `pnpm check:type-check-debt` (locked): 0 raw tsc errors, none above its recorded number. - Repo-wide lint and the full ui suites are CI's. <details><summary>The 34 derived gate families, each with its exit code</summary> ```text 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 --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:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 ``` </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jSSoutjNPjB5tGKhSw --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSSoutjNPjB5tGKhSw)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f1d795b commit 50e35a2

5 files changed

Lines changed: 235 additions & 22 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): Studio's cross-package metadata list (`/studio/~all/metadata/TYPE`) opens and stays open
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The metadata list repaired `?package=` whenever Studio's "All metadata" area named no package. On the cross-package leg that wrote the first project package into the URL, and the area then moved the page onto that package's leg, so the list of a type across packages could not be opened, with or without `?package=all`. Every package-less Setup metadata URL and the retired Packaged automation bookmark land there.
11+
12+
On the cross-package leg (outside the environment scope) the list now keeps no package and never repairs one. It lists every row the read returns, each row opening its owning package. Only Create picks a package, the same one Setup's list would pick, and opens that package's create. A package's area and Setup's list are unchanged.

‎ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx‎

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
191191
// package scope is the area's, never picked or repaired here, and a
192192
// read-only package offers no create.
193193
const host = useMetadataAreaHost();
194+
// objectstack#29566 — the area's cross-package leg (`/studio/~all/metadata`)
195+
// outside the environment scope: the list of a type across packages. It
196+
// names no package and never repairs one into the URL (`?package=X` would
197+
// move it onto X's leg, `areaLocationRedirect`); only create picks one.
198+
const allLeg = !!host && host.packageId === null && !envScope;
194199
// objectstack ADR-0146 D3 (#28568) — "On top, one surface": with a base open
195200
// in the area, its list holds the base's items and its paired
196201
// `<base>.custom` package's items together, each item of the pair marked
@@ -312,11 +317,11 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
312317
return () => { cancelled = true; };
313318
}, [client, appName]);
314319

315-
// Resolve the active package from the URL, validated against the project
316-
// package set. `null` while packages are still loading (fail closed).
320+
// Resolve the package Setup's list would scope to from the URL, validated
321+
// against the project package set. `null` while packages are still loading
322+
// (fail closed).
317323
const urlPackage = searchParams.get('package');
318-
const activePackage = React.useMemo(() => {
319-
if (host?.packageId) return host.packageId;
324+
const pickedPackage = React.useMemo(() => {
320325
if (!projectPackages) return null;
321326
if (urlPackage && projectPackages.some((p) => p.id === urlPackage)) return urlPackage;
322327
if (projectPackages.length === 0) return null;
@@ -339,16 +344,19 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
339344
if (n > bestN) { best = p.id; bestN = n; }
340345
}
341346
return best ?? projectPackages[0]?.id ?? null;
342-
}, [host, projectPackages, urlPackage, items, appPackage]);
347+
}, [projectPackages, urlPackage, items, appPackage]);
348+
// The package the list is scoped to: the area's own, none on the
349+
// cross-package leg, else the pick above.
350+
const activePackage = host?.packageId ? host.packageId : allLeg ? null : pickedPackage;
343351

344352
// Repair `?package=` so the sidebar selector, deep-links and create/edit
345353
// navigation all agree on the active scope. Runs once packages resolve
346354
// and the URL holds no valid project package.
347355
React.useEffect(() => {
348356
// The environment scope lists every package's items: no package to repair.
349357
if (envScope) return;
350-
// The area's package is its route's.
351-
if (host?.packageId) return;
358+
// The area's package is its route's; its cross-package leg has none.
359+
if (host) return;
352360
if (!projectPackages || projectPackages.length === 0) return;
353361
if (urlPackage && projectPackages.some((p) => p.id === urlPackage)) return;
354362
// If the current app's package is known we can repair immediately; otherwise
@@ -362,7 +370,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
362370
next.set('package', activePackage);
363371
setSearchParams(next, { replace: true });
364372
// eslint-disable-next-line react-hooks/exhaustive-deps
365-
}, [projectPackages, urlPackage, items, activePackage, appPackage]);
373+
}, [projectPackages, urlPackage, items, activePackage, appPackage, host]);
366374

367375
// Carry the active package into create/edit navigation as `?package=` so
368376
// the editor binds newly-saved rows to that software package.
@@ -388,6 +396,14 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
388396
return;
389397
}
390398
const bases = projectPackages ?? [];
399+
// The cross-package leg creates into the package Setup's list would pick;
400+
// the area then opens that package's create (`areaLocationRedirect`).
401+
if (allLeg) {
402+
const pick = pickedPackage ?? bases[0]?.id;
403+
if (pick) navigate(`./new?package=${encodeURIComponent(pick)}`);
404+
else if (projectPackages !== null) setShowCreateBase(true);
405+
return;
406+
}
391407
if (projectPackages !== null && bases.length === 0) {
392408
setShowCreateBase(true);
393409
return;
@@ -397,7 +413,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
397413
return;
398414
}
399415
navigate(`./new${pkgSuffix}`);
400-
}, [projectPackages, activePackage, pkgSuffix, navigate, envScope, host?.packageId]);
416+
}, [projectPackages, activePackage, pickedPackage, pkgSuffix, navigate, envScope, host?.packageId, allLeg]);
401417

402418
// objectui#7611 — the activation state the switch column shows. Re-read on
403419
// the list's own refresh, never on an unrelated render. The anchors arrive
@@ -543,8 +559,9 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
543559
// Per-type hide hook (e.g. `view` drops the bare aggregated
544560
// container the framework keeps for runtime dual-read).
545561
if (config.listFilter && !config.listFilter(row.item)) return false;
546-
// objectui#7611 — the environment scope is the whole registry list.
547-
if (envScope) return true;
562+
// objectui#7611 — the environment scope is the whole registry list,
563+
// and so is the area's cross-package leg (objectstack#29566).
564+
if (envScope || allLeg) return true;
548565
// Mandatory project-package scope: show nothing until a concrete
549566
// project package is active, then only rows tagged with it. The
550567
// 'sys_metadata' sentinel and untagged rows never match.
@@ -556,7 +573,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
556573
// Under the area a base's list also holds its pair's items (ADR-0146 D3).
557574
return pkg === activePackage || (pairId !== null && pkg === pairId);
558575
}),
559-
[items, activePackage, config, envScope, pairId],
576+
[items, activePackage, config, envScope, allLeg, pairId],
560577
);
561578

562579
// The customization mark of each row, from the base's served read only.
@@ -652,9 +669,9 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
652669
// write: a type with no runtime write channel is read-only for the operator
653670
// too, and its page says nothing about the wall.
654671
const walledDeciding = walled && typeWritable;
655-
// Packages load only for the package scope; the environment scope never
656-
// waits on them.
657-
const packagesPending = !envScope && !host?.packageId && projectPackages === null;
672+
// Packages load only for the package scope; the environment scope and the
673+
// area never wait on them.
674+
const packagesPending = !envScope && !host && projectPackages === null;
658675

659676
// Localise default column labels — registered columns keep their
660677
// hand-authored labels (consumers may want bespoke wording).
@@ -849,7 +866,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
849866
{error}
850867
</div>
851868
)}
852-
{!loading && !error && !envScope && !host?.packageId && projectPackages !== null && projectPackages.length === 0 && (
869+
{!loading && !error && !envScope && !host && projectPackages !== null && projectPackages.length === 0 && (
853870
<Empty>
854871
<EmptyTitle>No project packages installed</EmptyTitle>
855872
<EmptyDescription>
@@ -858,7 +875,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
858875
</EmptyDescription>
859876
</Empty>
860877
)}
861-
{!loading && !error && !packagesPending && (envScope || !!host?.packageId || (projectPackages?.length ?? 0) > 0) && filtered.length === 0 && (
878+
{!loading && !error && !packagesPending && (envScope || !!host || (projectPackages?.length ?? 0) > 0) && filtered.length === 0 && (
862879
<Empty>
863880
<EmptyTitle>
864881
{scopedItems.length === 0

‎ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
* {@link useMetadataAreaHost}:
1616
*
1717
* - which package is open. A page that would otherwise pick and repair its own
18-
* `?package=` scope (the directory, the list) stays on the area's package;
18+
* `?package=` scope (the directory, the list) stays on the area's package,
19+
* or on none in the cross-package leg;
1920
* - whether that package is read-only. Every write the pages offer is then
2021
* withheld: rollback, source save, reset, delete, the overlay save and the
2122
* create doors. The server stays the authority; this is the courtesy gate
@@ -36,7 +37,8 @@ import { SETUP_CATALOG_TYPES } from './catalog-scope.js';
3637
export interface MetadataAreaHost {
3738
/**
3839
* The package the area is open on, or `null` in the cross-package leg, which
39-
* serves the environment catalog (`?scope=environment`) with no package.
40+
* serves the environment catalog (`?scope=environment`) and a type's list
41+
* across every package (objectstack#29566), with no package.
4042
*/
4143
packageId: string | null;
4244
/**
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29566 — Studio's cross-package list (`/studio/~all/metadata/<type>`)
5+
* opens and stays open, through the area's real routes and the real list page.
6+
*
7+
* The list used to repair `?package=` whenever the area named no package, so
8+
* the cross-package leg became `?package=<first>` and the area then moved it
9+
* onto that package's leg (`areaLocationRedirect`). The list of a type across
10+
* packages could not be opened, with or without `?package=all`, which is where
11+
* every package-less Setup metadata URL and the Packaged automation bookmark
12+
* land (`setupMetadataRedirect.ts`).
13+
*
14+
* Controls: a package's area keeps its leg and lists that package's rows, and
15+
* Setup's list (no area) still repairs `?package=` to a project package.
16+
*/
17+
18+
import '@testing-library/jest-dom/vitest';
19+
import * as React from 'react';
20+
import { describe, it, expect, vi, afterEach } from 'vitest';
21+
import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react';
22+
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
23+
24+
const PROJECT = 'com.example.crm';
25+
const OTHER = 'com.example.hr';
26+
const PLATFORM = 'com.objectstack.plugin-security';
27+
28+
/** Rows of two project packages, a platform package, and one that belongs to no package. */
29+
const ROWS: Record<string, Array<Record<string, unknown>>> = {
30+
object: [
31+
{ name: 'crm_account', label: 'Account', _packageId: PROJECT },
32+
{ name: 'hr_employee', label: 'Employee', _packageId: OTHER },
33+
{ name: 'sys_user', label: 'User', _packageId: PLATFORM },
34+
{ name: 'env_note', label: 'Note' },
35+
],
36+
flow: [
37+
{ name: 'crm_followup', label: 'Follow up', _packageId: PROJECT },
38+
{ name: 'hr_onboard', label: 'Onboard', _packageId: OTHER },
39+
],
40+
};
41+
42+
const CLIENT = {
43+
list: async (type: string) =>
44+
type === 'package'
45+
? [
46+
{ manifest: { id: OTHER, scope: 'project', name: 'HR' } },
47+
{ manifest: { id: PROJECT, scope: 'project', name: 'CRM' } },
48+
{ manifest: { id: PLATFORM, scope: 'system', name: 'Security' } },
49+
]
50+
: (ROWS[type] ?? []),
51+
};
52+
53+
const entry = (type: string, domain: string) => ({
54+
type,
55+
label: type,
56+
domain,
57+
allowEnvironmentOverlay: false,
58+
allowRuntimeCreate: true,
59+
});
60+
61+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => ({
62+
...(await importOriginal<Record<string, unknown>>()),
63+
useMetadataClient: () => CLIENT,
64+
useMetadataTypes: () => ({ loading: false, error: null, entries: [entry('object', 'data'), entry('flow', 'automation')] }),
65+
}));
66+
vi.mock('../metadata-admin/i18n', async (importOriginal) => ({
67+
...(await importOriginal<Record<string, unknown>>()),
68+
useMetadataLocale: () => 'en-US',
69+
}));
70+
vi.mock('../../hooks/useCanAuthorMetadata', async (importOriginal) => ({
71+
...(await importOriginal<Record<string, unknown>>()),
72+
useCanAuthorMetadata: () => true,
73+
}));
74+
/** `GET /api/v1/packages` as the area's writable probe reads it. */
75+
vi.mock('./packages-io.js', async (importOriginal) => ({
76+
...(await importOriginal<Record<string, unknown>>()),
77+
fetchListedPackageManifests: vi.fn(
78+
async () =>
79+
new Map([
80+
[PROJECT, { id: PROJECT, name: 'CRM', writable: true, manifest: {} }],
81+
[OTHER, { id: OTHER, name: 'HR', writable: true, manifest: {} }],
82+
]),
83+
),
84+
}));
85+
86+
import { StudioMetadataArea } from './StudioMetadataArea';
87+
import { MetadataResourceListPage } from '../metadata-admin/ResourceListPage';
88+
import { t } from '../metadata-admin/i18n';
89+
90+
afterEach(cleanup);
91+
92+
// A package's list reads its customizations (`usePackageCustomizations`); this
93+
// file pins scope, not marks, so that read answers 404 (no mark is drawn).
94+
// One double at module scope, left up, so no late read reaches a socket.
95+
vi.stubGlobal(
96+
'fetch',
97+
vi.fn(async () => new Response(JSON.stringify({ success: false }), { status: 404, headers: { 'Content-Type': 'application/json' } })),
98+
);
99+
100+
function Where() {
101+
const { pathname, search } = useLocation();
102+
return <div data-testid="where">{`${pathname}${search}`}</div>;
103+
}
104+
105+
function PackageArea() {
106+
const { pathname } = useLocation();
107+
const packageId = decodeURIComponent(pathname.split('/')[2] ?? '');
108+
return <StudioMetadataArea key={packageId} packageId={packageId} />;
109+
}
110+
111+
/** The console's two area routes, plus Setup's list route with no area around it. */
112+
function mount(url: string) {
113+
return render(
114+
<MemoryRouter initialEntries={[url]}>
115+
<Where />
116+
<Routes>
117+
<Route path="/studio/~all/metadata/*" element={<StudioMetadataArea packageId={null} />} />
118+
<Route path="/studio/:packageId/metadata/*" element={<PackageArea />} />
119+
<Route path="/apps/:appName/metadata/:type" element={<MetadataResourceListPage />} />
120+
</Routes>
121+
</MemoryRouter>,
122+
);
123+
}
124+
125+
const where = () => screen.getByTestId('where').textContent ?? '';
126+
const row = (name: string) => screen.queryByTestId(`metadata-row-${name}`);
127+
const rowHref = (container: HTMLElement, name: string) =>
128+
container.querySelector<HTMLAnchorElement>(`a[href*="/${name}"]`)?.getAttribute('href') ?? null;
129+
const WAIT = { timeout: 10_000 };
130+
131+
describe('the cross-package list stays open (objectstack#29566)', () => {
132+
for (const query of ['', '?package=all']) {
133+
it(`/studio/~all/metadata/object${query} lists every package's rows and keeps its URL`, async () => {
134+
const { container } = mount(`/studio/~all/metadata/object${query}`);
135+
await waitFor(() => expect(row('crm_account')).not.toBeNull(), WAIT);
136+
for (const name of ['hr_employee', 'sys_user', 'env_note']) expect(row(name)).not.toBeNull();
137+
// Each row opens its owning package; a package-less row opens by name.
138+
expect(rowHref(container, 'crm_account')).toBe(`/studio/~all/metadata/object/crm_account?package=${PROJECT}`);
139+
expect(rowHref(container, 'hr_employee')).toBe(`/studio/~all/metadata/object/hr_employee?package=${OTHER}`);
140+
expect(rowHref(container, 'env_note')).toBe('/studio/~all/metadata/object/env_note');
141+
// Every load has settled (the package list, the rows); nothing rewrote the URL.
142+
await new Promise((r) => setTimeout(r, 50));
143+
expect(where()).toBe(`/studio/~all/metadata/object${query}`);
144+
});
145+
}
146+
147+
it('the Packaged automation landing, /studio/~all/metadata/flow, lists the flows of every package', async () => {
148+
mount('/studio/~all/metadata/flow');
149+
await waitFor(() => expect(row('crm_followup')).not.toBeNull(), WAIT);
150+
expect(row('hr_onboard')).not.toBeNull();
151+
await new Promise((r) => setTimeout(r, 50));
152+
expect(where()).toBe('/studio/~all/metadata/flow');
153+
});
154+
155+
it('create alone picks a package, and opens that package\'s create', async () => {
156+
mount('/studio/~all/metadata/object');
157+
await waitFor(() => expect(row('crm_account')).not.toBeNull(), WAIT);
158+
fireEvent.click(screen.getAllByRole('button', { name: new RegExp(t('engine.list.create', 'en-US')) })[0]);
159+
await waitFor(() => expect(where()).toBe(`/studio/${PROJECT}/metadata/object/new?package=${PROJECT}`), WAIT);
160+
});
161+
});
162+
163+
describe('controls: a package area and Setup keep their scope', () => {
164+
it('a package\'s area keeps its leg and lists its own rows', async () => {
165+
mount(`/studio/${PROJECT}/metadata/object?package=${PROJECT}`);
166+
await waitFor(() => expect(row('crm_account')).not.toBeNull(), WAIT);
167+
expect(row('hr_employee')).toBeNull();
168+
expect(row('sys_user')).toBeNull();
169+
expect(row('env_note')).toBeNull();
170+
expect(where()).toBe(`/studio/${PROJECT}/metadata/object?package=${PROJECT}`);
171+
});
172+
173+
it('Setup\'s list (no area) still repairs `?package=` onto a project package', async () => {
174+
mount('/apps/setup/metadata/object');
175+
await waitFor(() => expect(where()).toBe(`/apps/setup/metadata/object?package=${PROJECT}`), WAIT);
176+
await waitFor(() => expect(row('crm_account')).not.toBeNull(), WAIT);
177+
expect(row('hr_employee')).toBeNull();
178+
expect(row('sys_user')).toBeNull();
179+
});
180+
});

‎ui/packages/app-shell/src/views/studio-design/metadataArea.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
* (positions, capabilities, permission sets, sharing rules), whose items
1414
* have one name per deployment and may belong to no package (ADR-0131 D3),
1515
* served in the environment scope (`?scope=environment`), as Setup's catalog
16-
* serves it today.
16+
* serves it today. Outside that scope, a type's list here is the list
17+
* across every package, each row opening its owning package (#29566).
1718
*
1819
* ## Why `~all` and not `~org`
1920
*
@@ -148,8 +149,9 @@ const ALL_PACKAGES_VALUE = 'all';
148149
* - the per-package leg with no `?package=` gets its own, because the item
149150
* page binds its saves to `?package=` (`readActivePackageBinding`): without
150151
* it a save in the area would leave the package;
151-
* - the cross-package leg with no `?package=` stays as it is, the pages
152-
* keeping their Setup meaning for a URL that names no package.
152+
* - the cross-package leg with no `?package=` stays as it is: a URL that
153+
* names no package lists across packages, and no page repairs one in
154+
* (#29566).
153155
*
154156
* `rest` is the path below the area's root as the URL spells it (encoded),
155157
* `''` at the root; `search` is the URL's query, with or without its `?`.

0 commit comments

Comments
 (0)