Skip to content

Commit 3482f3d

Browse files
fix(console): open the record page scoped to the current app (PageSchema.apps) (#29631)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbS1gR5cGSSfRihXeXjmJC3p)_ Fixes #29592 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:控制台打开记录页时,先看当前应用:某张记录页在 `apps` 里列了这个应用,就在该应用里打开它;其它地方(含 Setup)照旧打开对象唯一的默认记录页。记录抽屉同样生效。`page.apps` 的活性登记从 planned 改为 live,描述去掉 PLANNED 标记。 - **为什么改**:#28979 裁决 A 的 ui 半。规格半(PR #29437)已合入,但控制台没读 `apps`,所以 Duly 这类应用在自己的路由里打开用户记录,仍然看到平台的 `sys_user_detail`。 - **风险与回滚**:没有任何页面写 `apps` 时行为不变(测试有对照组,现有 291 个相关测试文件全绿)。路由段先解析成应用再比对应用名,不拿包 id 去比。回滚即 revert 本 PR。 - **先看**:`ui/packages/react/src/hooks/usePageAssignment.ts`(选页顺序:应用页、默认页、声明顺序)、`ui/packages/app-shell/src/views/RecordDetailView.tsx`(路由段解析为应用名)、`ui/packages/app-shell/src/console/appObjectScope.ts`(`declaredRecordPage` 与渲染选同一张页)。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/RecordDetailView.appScopedPage-29592.test.tsx`,应见 6 个用例全绿:Duly 应用里打开 `duly_member`,Setup 里仍是 `sys_user_detail`。 - **需要你做的**:无,席位落地。 ## What changes Ruling A on #28979 lets a record page name the apps whose routes open it (`PageSchema.apps`); the spec half landed in #29437. This is the console half. - **`usePageAssignment` (`@object-ui/react`)** now reads its existing `app` option, the owning app's NAME. Order: the first record page of the object for which `defaultRecordPageObject(page, app)` answers the object, then the `isDefault` page, then declaration order. The option's type is unchanged; it was declared "reserved for future use" and read by nothing, and its only caller never passed it. No new parameter, so `Clause-②: no` (seat ruling). - **`RecordDetailView`** resolves the route's `:appName` segment with `matchAppBySegment` over the full metadata app list and passes `app.name`, never the raw segment (contract-review flag ③.2 on #29437: the segment is `app._packageId ?? app.name`). The record drawer, mounted under the host list route, reads the same segment, so a record opened in the drawer inside an app gets that app's page too. A package that ships two apps resolves its package-id segment to the first app, so the second app's scoped page opens under its name segment only, as the `appAddressSegment` docblock states. - **`appObjectScope.ts` `declaredRecordPage`** (the record route's object scope, objectui#13046) asks the same order with the app `AppContent` already resolved, so the route reads the objects of the page it renders. Only this picker was touched; the packaged-lookup and package-less arms are untouched. - **Spec (the one edit the card owes, as amended):** `page.apps` liveness row `planned` → `live`, citing the three console read points; the describe drops `[PLANNED — not yet rendered]` and the closing "Until the console reads it" sentence, and the TSDoc names the renderer. Forced by that: the describe pin in `stack-app-record-page.test.ts` now pins the marker's absence; `liveness/state-counts/page.md` and `content/docs/references/ui/page.mdx` are regenerated. The `page.apps` omit row in `metadata-form-zod-reconciliation.test.ts` stays, with a rewritten reason: live, but not offered in the Studio page form because no registered widget spells a list of app names (seat ruling; whether the form offer becomes a follow-up card is the seat's call at ACCEPT). With no `apps` on any page, every branch falls through to the previous line. ## Tests - `ui/packages/react/src/hooks/__tests__/usePageAssignment.appScope-29592.test.tsx` (10): the scoped page opens in its app whatever the served order; Setup and no-app open the default; a page listing several apps; declaration-order fallthrough; another object's scoped page ignored; scoped slotted page hands back slots; `pageName` still wins; CONTROL with no `apps`. - `ui/packages/app-shell/src/views/RecordDetailView.appScopedPage-29592.test.tsx` (6): the ruling's pin (Duly under `com.duly` and under `duly` opens `duly_member`; `/apps/com.objectstack.setup/...` opens `sys_user_detail`), the two-apps-one-package corner, the drawer, and the CONTROL over five segments. - `ui/packages/app-shell/src/console/__tests__/appObjectScope.appScopedRecordPage-29592.test.tsx` (3): the scope reads the scoped page's objects in Duly and the default's in Setup; the scope picker and `usePageAssignment` agree across apps and orders; CONTROL. - Existing assignment tests unchanged and green: `usePageAssignment.isDefault-12195`, `.typeDiscriminator-9674`, `.declarationOrder-7298`, `.isRecordPageOf-12715`. <details><summary>Ablations (each mutated through scripts/ablation-replace.mjs, restored to HEAD and verified)</summary> - A, compare the raw segment (`matchAppBySegment(metadataApps, appName)?.name` → the segment): record-view suite 3 failed / 3 passed (package-id segment, two-apps corner, drawer red; the name segment stays green, as expected). - B, the hook ignores the app (`if (opts.app && false)`): 8 failed / 11 passed across the three new files. - C, the scope picker ignores the app: 2 failed / 1 passed in the agreement file. </details> <details><summary>Sweep</summary> Every ui test file naming `RecordDetailView`, `appObjectScope`, `usePageAssignment` or `readAppObjectScope` (291 files, `git grep -l` at 8fead84), three locked batches at 2 workers: 98/98 files 788 tests, 98/98 files 654 tests, 95/95 files 847 tests. Spec: `src/stack-app-record-page.test.ts` + `src/system/metadata-form-zod-reconciliation.test.ts` 103/103. `check:liveness` and `check:generated` green after regeneration. </details> ## Acceptance notes - Live measurement (app-crm `--fresh` backend, Vite console on this branch's source, 1440×900): a record page `crm_member` for `sys_user` with `apps: ['crm_app']` was saved through `PUT /api/v1/meta/page/crm_member` (200). Under `/apps/com.example.crm/sys_user/record/ID` it opens `crm_member`; `/apps/crm_app/...` canonicalises to the package-id segment and opens it too; `/apps/com.objectstack.setup/sys_user/record/ID` opens `sys_user_detail`. Before leg (the three ui files restored from `origin/main` in the same running console): the CRM route opened `sys_user_detail`. Screenshots are in the project files under `issue-29592/` (`before-crm-app-user-record.png`, `after-crm-app-user-record.png`, `after-setup-users-record.png`); the seat links them. - The record drawer is included by seat ruling; its host is `RecordDetailView` mounted with `embedded` under the list route (`/apps/:appName/:objectName`). - The Studio page form does not offer `apps` (no app-name widget); see the reconciliation ledger row. ## Verification Local, `dispatch-gates --commands --repo objectstack-ai/objectstack` derived 107 families on this diff: all 107 run at `0275f773`, every one exit 0, `--ran` reconciliation `107 run, 0 NOT-MEASURED, 0 UNRUN` (the ui `vitest run scripts/` suite and `check:type-check-debt` under the verify lock: 170 files / 4972 tests green; debt exit 0). `dispatch-gates --tier`: no path-derived mandate (`page.zod.ts` is a Clause-② suspect hint only; the change there is describe text). After merging `main` (`5b257756`, which brought #29552's `appObjectScope.ts` arm in cleanly): spec rebuilt, `check:generated` and `check:liveness` exit 0; the 16 ui test files naming `appObjectScope` / `readAppObjectScope` / `usePageAssignment` plus the record-view file and #29552's new test, 18 files / 113 tests green; the two spec files 103/103; ratchets rerun on the new head: `check:type-check-debt`, `check-changeset-no-major`, `check-empty-changeset`, `check:nul-bytes` exit 0. Type-check (`@object-ui/react`, `@object-ui/app-shell`, `@objectstack/spec`) exit 0 and eslint 0 errors on the touched ui files, both at `0275f773`. CI check-runs: see this PR. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTtndxBXWfjH1SiQ6w)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d280265 commit 3482f3d

13 files changed

Lines changed: 517 additions & 34 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/react': patch
3+
'@object-ui/app-shell': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
fix(console): a record opens the record page scoped to the app it is opened in (`PageSchema.apps`)
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
A record page that names apps in `apps` now opens for its object inside those apps: in the Duly app a user record opens Duly's own `sys_user` page, while Setup → Users still opens the platform's `sys_user_detail`. Everywhere else, and wherever no page names `apps`, the object's `isDefault` record page opens exactly as before.
13+
14+
- `usePageAssignment` (`@object-ui/react`) now reads its existing `app` option, which names the owning app by its name (`AppSchema.name`): the record page whose `apps` lists that app opens before the object's default record page. The option's type is unchanged; it was reserved and read by nothing until now. Pass the app's name, never a route segment.
15+
- The console's record view resolves the route's app segment to its app (package id first, then name) and passes the app's name, on the record route and in the record drawer. Where a package ships two apps, its package-id segment opens the first app, so the second app's scoped page opens under that app's name segment.
16+
- The record route's object scope picks the same page, so the route reads the objects of the page it renders.
17+
- `@objectstack/spec`: the `PageSchema.apps` description drops its "planned, not yet rendered" marker, and the key's liveness verdict is now live, citing the console reader.

‎content/docs/references/ui/page.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules ma
227227
| **template** | `Enum<'default' \| 'header-sidebar-main' \| 'three-column' \| 'dashboard'>` | optional (default: `"default"`) | Layout template — 'default', 'header-sidebar-main', 'three-column' or 'dashboard'. Each places its own regions (PAGE_TEMPLATE_SLOTS); a region the template does not place, or a second region filling the same slot, is refused at the region's name. |
228228
| **regions** | `{ name: string; width?: Enum<'small' \| 'medium' \| 'large' \| 'full'>; components: object[] }[]` | optional | Layout regions (header, main, sidebar, footer) with their components. Optional — list pages use interfaceConfig, slotted pages use slots, and an empty full page falls back to the synthesized default layout. |
229229
| **isDefault** | `boolean` | optional (default: `false`) | Whether this record page is the default record page of its `object` — the page the record view opens. At most one `isDefault: true` record page may exist for an object in a deployment: a second is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Saving a page under the existing default's own name is that page, not a second default. Refused on any other page — a page that is not a record page bound to an object: nothing reads it there. An app opens on its first navigation item, and the root landing follows the app's own isDefault. |
230-
| **apps** | `string[]` | optional | [PLANNED — not yet rendered] The apps whose routes open this record page for its `object`: inside an app named here, the object's record view (`/apps/<app>/<object>/record/<id>`) opens this page; everywhere else, Setup included, the object's one `isDefault` record page still opens, so the default and an app's page coexist. App names, non-empty and unique. At most one record page per object per app: a second page naming the same app for the same object is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Refused on any page that is not a record page bound to an object. Until the console reads it, every app opens the default record page. |
230+
| **apps** | `string[]` | optional | The apps whose routes open this record page for its `object`: inside an app named here, the object's record view (`/apps/<app>/<object>/record/<id>`) opens this page; everywhere else, Setup included, the object's one `isDefault` record page still opens, so the default and an app's page coexist. App names, non-empty and unique. At most one record page per object per app: a second page naming the same app for the same object is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Refused on any page that is not a record page bound to an object. |
231231
| **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (named in a position's `permissionSets`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
232232
| **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: (object \| object)[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) |
233233
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |

‎packages/spec/liveness/page.json‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,11 @@
132132
"note": "the default record page of the object a record page binds — objectui page resolution (the one reading every door judges by is defaultRecordPageObject, default-record-page.ts). On any other page it is REFUSED (#23610, ruling 6190536588 Q1 A; checkPageDefaultPlacement and the registry door, one sentence misplacedDefaultPageRefusal): nothing read it there — an app opens on its first navigation item and the root landing follows the app's own isDefault. LOAD PATH (P0 after #23610): an installed or stored page still carrying the flag replays the retired D2 entry page-is-default-off-record-page-removed on read — stored rows, the artifact doors, and the kernel's package registration door for a body that declares its protocol range (ObjectQL.convertPackageBodyForward) — so the kernel builds with an OS_METADATA_CONVERTED notice; the authoring, save and publish doors keep refusing."
133133
},
134134
"apps": {
135-
"status": "planned",
135+
"status": "live",
136136
"verifiedAt": "2026-11-02",
137-
"evidence": "packages/spec/src/ui/default-record-page.ts#defaultRecordPageObject (with the app passed: the object a record page is the page of inside that app — the one reading defineStack, the registry's package door and the metadata save door ask); packages/spec/src/ui/page.zod.ts#checkPageDefaultPlacement (refuses `apps` on a page that is not a record page bound to an object)",
138-
"note": "PLANNED, the renderer half pending — ruling A on #28979 (record 6573557633): inside an app `apps` names, the object's record view opens this page; elsewhere, Setup included, the object's one isDefault page still opens. The refusal half is enforced now: two record pages for one object naming the same app are refused by defineStack / composeStacks, the objectql registry's package door and item seam, and the metadata save door (DEFAULT_RECORD_PAGE_CONFLICT, naming both pages). Measured on objectstack main @2f7977cd: the console's page assignment (ui usePageAssignment / appObjectScope.declaredRecordPage) reads no app, so every app opens the default record page, as before the key existed. Flip to `live` when the ui follow-up reads the route's app first, citing the read point."
137+
"evidenceScope": "in-repo",
138+
"evidence": "ui/packages/react/src/hooks/usePageAssignment.ts#usePageAssignment (the `app` option, the app's name: the first record page of the object for which defaultRecordPageObject(page, app) answers it opens before the isDefault page); ui/packages/app-shell/src/views/RecordDetailView.tsx#RecordDetailView (resolves the route's `:appName` segment with matchAppBySegment and passes the app's name, on the record route and in the record drawer); ui/packages/app-shell/src/console/appObjectScope.ts#declaredRecordPage (the record route's object scope picks the same page); refusal half: packages/spec/src/ui/default-record-page.ts#defaultRecordPageObject and packages/spec/src/ui/page.zod.ts#checkPageDefaultPlacement",
139+
"note": "LIVE — ruling A on #28979 (record 6573557633): inside an app `apps` names, the object's record view opens this page; elsewhere, Setup included, the object's one isDefault page still opens. The console resolves the route segment to its app first (the segment carries the package id when the app has one) and compares the app's NAME, never the segment; where a package ships two apps, its package-id segment resolves to the first, so the second app's scoped page opens under its name segment only. Enforced on write: two record pages for one object naming the same app are refused by defineStack / composeStacks, the objectql registry's package door and item seam, and the metadata save door (DEFAULT_RECORD_PAGE_CONFLICT, naming both pages). Not offered in the Studio page form: see the page.apps omit row in packages/spec/src/system/metadata-form-zod-reconciliation.test.ts."
139140
},
140141
"interfaceConfig": {
141142
"status": "live",

‎packages/spec/liveness/state-counts/page.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.
1212

1313
| Type | live | exp | elsewhere | dead | planned | classified |
1414
|---|---|---|---|---|---|---|
15-
| `page` | 34 | 0 | 0 | 1 | 10 | 45 |
15+
| `page` | 35 | 0 | 0 | 1 | 9 | 45 |

‎packages/spec/src/stack-app-record-page.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,10 @@ describe('PageSchema.apps — the key', () => {
9898
expect(issues.map((i) => i.path)).toEqual(['isDefault', 'apps']);
9999
});
100100

101-
it('the describe carries the planned marker and no tracker number', () => {
101+
it('the describe carries no planned marker (the console reads the key) and no tracker number', () => {
102102
const description = PageSchema.shape.apps.description ?? '';
103-
expect(description).toMatch(/^\[PLANNED — not yet rendered\] /);
103+
expect(description).toMatch(/^The apps whose routes open this record page /);
104+
expect(description).not.toMatch(/PLANNED|not yet rendered|until the console reads it/i);
104105
expect(description).not.toMatch(/#\d{3,}/);
105106
});
106107
});

‎packages/spec/src/system/metadata-form-zod-reconciliation.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -354,15 +354,16 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
354354
key: 'record',
355355
why: "planned, not yet rendered — the binding's one-row query is validated at parse and refused on every page that is not `type: 'app'`, but no renderer mounts it as the page's `record` until the console's page-root binding lands. A form control offered today would let an author bind a row no predicate can read yet",
356356
},
357-
// #28979 — a record page's app scope lands spec-first the same way:
358-
// `planned` in the liveness ledger, read by no console until the ui card
359-
// that makes the page assignment read the route's app.
357+
// #28979 — a record page's app scope landed spec-first the same way and is
358+
// `live` since the console's page assignment reads the route's app. The
359+
// form offer was decided with the enforcement: not offered yet, for the
360+
// reason below.
360361
{
361362
kind: 'omit',
362363
type: 'page',
363364
path: ROOT_PATH,
364365
key: 'apps',
365-
why: "planned, not yet rendered — the app scope is validated at parse, refused off a record page bound to an object, and a second page for one object in one app is refused at every door, but no console opens the scoped page in its app until the page assignment reads the route's app. A form control offered today would let an author scope a page no route opens yet",
366+
why: "live, not offered in this form — the console opens the scoped page inside each app the list names (the page assignment reads the route's app), but no registered form widget spells a list of app names: there is no app picker, and a free-text list would let an author type a package id or a label where an app name is required, which the parse refuses only after the fact. Authored in source or the page JSON until an app-name picker exists",
366367
},
367368
// #28022 — an app's per-audience landing list lands spec-first the same way:
368369
// `planned` in the liveness ledger, read by no console until the landing

‎packages/spec/src/ui/page.zod.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2072,13 +2072,13 @@ export const PageSchema = lazySchema(() => strictObject({
20722072
* bound to an object ({@link checkPageDefaultPlacement}). Entries are app
20732073
* names (`AppSchema.name`), non-empty and unique.
20742074
*
2075-
* Declared ahead of its renderer (ADR-0049): the console's page assignment
2076-
* reads the route's app in the ui follow-up; until it does, the console
2077-
* opens the default record page in every app, as before the key existed.
2075+
* Renderer: the console's page assignment (`usePageAssignment` in
2076+
* `@object-ui/react`, and the record route's object scope, which picks the
2077+
* same page) resolves the route's app segment to its app and reads that
2078+
* app's NAME here, never the segment itself.
20782079
*/
2079-
// Renderer half: the console's usePageAssignment (ui lane follow-up of #28979).
20802080
apps: z.array(SnakeCaseIdentifierSchema).min(1).superRefine(checkPageAppsUnique).optional()
2081-
.describe("[PLANNED — not yet rendered] The apps whose routes open this record page for its `object`: inside an app named here, the object's record view (`/apps/<app>/<object>/record/<id>`) opens this page; everywhere else, Setup included, the object's one `isDefault` record page still opens, so the default and an app's page coexist. App names, non-empty and unique. At most one record page per object per app: a second page naming the same app for the same object is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Refused on any page that is not a record page bound to an object. Until the console reads it, every app opens the default record page."),
2081+
.describe("The apps whose routes open this record page for its `object`: inside an app named here, the object's record view (`/apps/<app>/<object>/record/<id>`) opens this page; everywhere else, Setup included, the object's one `isDefault` record page still opens, so the default and an app's page coexist. App names, non-empty and unique. At most one record page per object per app: a second page naming the same app for the same object is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Refused on any page that is not a record page bound to an object."),
20822082
// `assignedProfiles` REMOVED (ADR-0090 D2 / ADR-0049) — it named the concept
20832083
// D2 deleted and nothing anywhere enforced it, so a page that "assigned
20842084
// profiles" was open to everyone who could reach it. Page audience is the
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* A record route reads the objects of the record page it RENDERS
3+
* (objectui#13046), so the route's object scope must pick the page the way
4+
* `RecordDetailView` does through `usePageAssignment`. With app-scoped record
5+
* pages (`PageSchema.apps`, maintainer ruling A on objectstack#28979) that
6+
* pick depends on the route's app: inside an app a page names, that page;
7+
* elsewhere the object's default record page.
8+
*
9+
* Harness: `readAppObjectScope` with the app `AppContent` resolved from the
10+
* route segment, a settled page list, and a scope reader that records the
11+
* names it is asked. Each page binds one marker object, so the names asked
12+
* say which page was picked. The same matrix is asked of `usePageAssignment`
13+
* with the same app's name, and the two answers must agree.
14+
*/
15+
16+
import React from 'react';
17+
import { describe, it, expect } from 'vitest';
18+
import { renderHook, waitFor } from '@testing-library/react';
19+
import { MetadataCtx, usePageAssignment, type MetadataContextValue } from '@object-ui/react';
20+
import { readAppObjectScope } from '../appObjectScope';
21+
22+
type Page = Record<string, unknown>;
23+
24+
const OBJECT = 'sys_user';
25+
26+
/** A full record page of the user object binding one marker object. */
27+
const userPage = (name: string, marker: string, extra: Page = {}): Page => ({
28+
name,
29+
label: name,
30+
type: 'record',
31+
object: OBJECT,
32+
...extra,
33+
regions: [{ name: 'main', components: [{ type: 'object-grid', objectName: marker }] }],
34+
});
35+
36+
const SYS_USER_DETAIL = userPage('sys_user_detail', 'marker_default', { isDefault: true });
37+
const DULY_MEMBER = userPage('duly_member', 'marker_duly', { apps: ['duly'] });
38+
const MARKER_OF: Record<string, string> = { sys_user_detail: 'marker_default', duly_member: 'marker_duly' };
39+
40+
const SETUP_APP = { name: 'setup', _packageId: 'com.objectstack.setup', navigation: [] };
41+
const DULY_APP = { name: 'duly', _packageId: 'com.duly', navigation: [] };
42+
43+
/** The page `readAppObjectScope` picked, read off the marker names it asked for. */
44+
function scopePick(app: Record<string, unknown>, pages: Page[]): string | undefined {
45+
const asked = new Set<string>();
46+
const readObjectScope = ((q: { names?: readonly string[] }) => {
47+
for (const n of q.names ?? []) asked.add(n);
48+
return { objects: [], served: [], missing: [], settled: true };
49+
}) as never;
50+
readAppObjectScope(readObjectScope, app, `${OBJECT}/record/r1`, '', [], undefined, () => ({ pages, settled: true }));
51+
const picked = Object.entries(MARKER_OF).filter(([, marker]) => asked.has(marker)).map(([name]) => name);
52+
expect(picked.length, `exactly one page's objects are read, got ${picked.join(', ') || 'none'}`).toBe(1);
53+
return picked[0];
54+
}
55+
56+
/** The page `usePageAssignment` picks for the same app's name. */
57+
async function assignmentPick(appName: string, pages: Page[]): Promise<string | undefined> {
58+
const ctx = {
59+
apps: [], objects: [], dashboards: [], reports: [], pages, loading: false, error: null,
60+
refresh: async () => {}, invalidate: () => {}, ensureType: async () => pages,
61+
getItem: async () => null, getItemsByType: () => [], getTypeStatus: () => 'ready' as const,
62+
} as unknown as MetadataContextValue;
63+
const wrapper = ({ children }: { children: React.ReactNode }) => (
64+
<MetadataCtx.Provider value={ctx}>{children}</MetadataCtx.Provider>
65+
);
66+
const { result } = renderHook(() => usePageAssignment(OBJECT, { app: appName }), { wrapper });
67+
await waitFor(() => expect(result.current.loading).toBe(false));
68+
return result.current.page?.name;
69+
}
70+
71+
describe('appObjectScope — a record route reads the objects of the app-scoped page it renders (ruling A on objectstack#28979)', () => {
72+
it('inside the Duly app the route reads duly_member\'s objects; inside Setup, sys_user_detail\'s', () => {
73+
expect(scopePick(DULY_APP, [SYS_USER_DETAIL, DULY_MEMBER])).toBe('duly_member');
74+
expect(scopePick(SETUP_APP, [DULY_MEMBER, SYS_USER_DETAIL])).toBe('sys_user_detail');
75+
});
76+
77+
it('the scope picker and usePageAssignment agree on every app and order', async () => {
78+
for (const app of [DULY_APP, SETUP_APP]) {
79+
for (const pages of [[SYS_USER_DETAIL, DULY_MEMBER], [DULY_MEMBER, SYS_USER_DETAIL]]) {
80+
expect(scopePick(app, pages)).toBe(await assignmentPick(app.name, pages));
81+
}
82+
}
83+
});
84+
85+
it('CONTROL: with no `apps` on any page, every app reads the default page\'s objects', () => {
86+
const plain = [{ ...DULY_MEMBER, apps: undefined }, SYS_USER_DETAIL];
87+
expect(scopePick(DULY_APP, plain)).toBe('sys_user_detail');
88+
expect(scopePick(SETUP_APP, plain)).toBe('sys_user_detail');
89+
});
90+
});

0 commit comments

Comments
 (0)