diff --git a/.changeset/29592-app-scoped-record-page.md b/.changeset/29592-app-scoped-record-page.md new file mode 100644 index 00000000000..869bd0d68b5 --- /dev/null +++ b/.changeset/29592-app-scoped-record-page.md @@ -0,0 +1,17 @@ +--- +'@object-ui/react': patch +'@object-ui/app-shell': patch +'@objectstack/spec': patch +--- + +fix(console): a record opens the record page scoped to the app it is opened in (`PageSchema.apps`) + +Clause-②: no +Metadata-protocol: none + +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. + +- `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. +- 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. +- The record route's object scope picks the same page, so the route reads the objects of the page it renders. +- `@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. diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 643811a5280..ff880edc9ef 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -227,7 +227,7 @@ An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules ma | **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. | | **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. | | **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. | -| **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///record/`) 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. | +| **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///record/`) 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. | | **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. | | **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: (object \| object)[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index fa49b4eaf9e..4428a75d6da 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -132,10 +132,11 @@ "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." }, "apps": { - "status": "planned", + "status": "live", "verifiedAt": "2026-11-02", - "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)", - "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." + "evidenceScope": "in-repo", + "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", + "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." }, "interfaceConfig": { "status": "live", diff --git a/packages/spec/liveness/state-counts/page.md b/packages/spec/liveness/state-counts/page.md index dbda954e61d..a28deda9bec 100644 --- a/packages/spec/liveness/state-counts/page.md +++ b/packages/spec/liveness/state-counts/page.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `page` | 34 | 0 | 0 | 1 | 10 | 45 | +| `page` | 35 | 0 | 0 | 1 | 9 | 45 | diff --git a/packages/spec/src/stack-app-record-page.test.ts b/packages/spec/src/stack-app-record-page.test.ts index 11497d24470..bfd417c2368 100644 --- a/packages/spec/src/stack-app-record-page.test.ts +++ b/packages/spec/src/stack-app-record-page.test.ts @@ -98,9 +98,10 @@ describe('PageSchema.apps — the key', () => { expect(issues.map((i) => i.path)).toEqual(['isDefault', 'apps']); }); - it('the describe carries the planned marker and no tracker number', () => { + it('the describe carries no planned marker (the console reads the key) and no tracker number', () => { const description = PageSchema.shape.apps.description ?? ''; - expect(description).toMatch(/^\[PLANNED — not yet rendered\] /); + expect(description).toMatch(/^The apps whose routes open this record page /); + expect(description).not.toMatch(/PLANNED|not yet rendered|until the console reads it/i); expect(description).not.toMatch(/#\d{3,}/); }); }); diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 54beb49eac2..6ef26282873 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -354,15 +354,16 @@ const LEDGER: ReadonlyArray = [ key: 'record', 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", }, - // #28979 — a record page's app scope lands spec-first the same way: - // `planned` in the liveness ledger, read by no console until the ui card - // that makes the page assignment read the route's app. + // #28979 — a record page's app scope landed spec-first the same way and is + // `live` since the console's page assignment reads the route's app. The + // form offer was decided with the enforcement: not offered yet, for the + // reason below. { kind: 'omit', type: 'page', path: ROOT_PATH, key: 'apps', - 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", + 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", }, // #28022 — an app's per-audience landing list lands spec-first the same way: // `planned` in the liveness ledger, read by no console until the landing diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index fb97d8ed68e..b10331396ff 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -2072,13 +2072,13 @@ export const PageSchema = lazySchema(() => strictObject({ * bound to an object ({@link checkPageDefaultPlacement}). Entries are app * names (`AppSchema.name`), non-empty and unique. * - * Declared ahead of its renderer (ADR-0049): the console's page assignment - * reads the route's app in the ui follow-up; until it does, the console - * opens the default record page in every app, as before the key existed. + * Renderer: the console's page assignment (`usePageAssignment` in + * `@object-ui/react`, and the record route's object scope, which picks the + * same page) resolves the route's app segment to its app and reads that + * app's NAME here, never the segment itself. */ - // Renderer half: the console's usePageAssignment (ui lane follow-up of #28979). apps: z.array(SnakeCaseIdentifierSchema).min(1).superRefine(checkPageAppsUnique).optional() - .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///record/`) 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."), + .describe("The apps whose routes open this record page for its `object`: inside an app named here, the object's record view (`/apps///record/`) 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."), // `assignedProfiles` REMOVED (ADR-0090 D2 / ADR-0049) — it named the concept // D2 deleted and nothing anywhere enforced it, so a page that "assigned // profiles" was open to everyone who could reach it. Page audience is the diff --git a/ui/packages/app-shell/src/console/__tests__/appObjectScope.appScopedRecordPage-29592.test.tsx b/ui/packages/app-shell/src/console/__tests__/appObjectScope.appScopedRecordPage-29592.test.tsx new file mode 100644 index 00000000000..d384a88a912 --- /dev/null +++ b/ui/packages/app-shell/src/console/__tests__/appObjectScope.appScopedRecordPage-29592.test.tsx @@ -0,0 +1,90 @@ +/** + * A record route reads the objects of the record page it RENDERS + * (objectui#13046), so the route's object scope must pick the page the way + * `RecordDetailView` does through `usePageAssignment`. With app-scoped record + * pages (`PageSchema.apps`, maintainer ruling A on objectstack#28979) that + * pick depends on the route's app: inside an app a page names, that page; + * elsewhere the object's default record page. + * + * Harness: `readAppObjectScope` with the app `AppContent` resolved from the + * route segment, a settled page list, and a scope reader that records the + * names it is asked. Each page binds one marker object, so the names asked + * say which page was picked. The same matrix is asked of `usePageAssignment` + * with the same app's name, and the two answers must agree. + */ + +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { MetadataCtx, usePageAssignment, type MetadataContextValue } from '@object-ui/react'; +import { readAppObjectScope } from '../appObjectScope'; + +type Page = Record; + +const OBJECT = 'sys_user'; + +/** A full record page of the user object binding one marker object. */ +const userPage = (name: string, marker: string, extra: Page = {}): Page => ({ + name, + label: name, + type: 'record', + object: OBJECT, + ...extra, + regions: [{ name: 'main', components: [{ type: 'object-grid', objectName: marker }] }], +}); + +const SYS_USER_DETAIL = userPage('sys_user_detail', 'marker_default', { isDefault: true }); +const DULY_MEMBER = userPage('duly_member', 'marker_duly', { apps: ['duly'] }); +const MARKER_OF: Record = { sys_user_detail: 'marker_default', duly_member: 'marker_duly' }; + +const SETUP_APP = { name: 'setup', _packageId: 'com.objectstack.setup', navigation: [] }; +const DULY_APP = { name: 'duly', _packageId: 'com.duly', navigation: [] }; + +/** The page `readAppObjectScope` picked, read off the marker names it asked for. */ +function scopePick(app: Record, pages: Page[]): string | undefined { + const asked = new Set(); + const readObjectScope = ((q: { names?: readonly string[] }) => { + for (const n of q.names ?? []) asked.add(n); + return { objects: [], served: [], missing: [], settled: true }; + }) as never; + readAppObjectScope(readObjectScope, app, `${OBJECT}/record/r1`, '', [], undefined, () => ({ pages, settled: true })); + const picked = Object.entries(MARKER_OF).filter(([, marker]) => asked.has(marker)).map(([name]) => name); + expect(picked.length, `exactly one page's objects are read, got ${picked.join(', ') || 'none'}`).toBe(1); + return picked[0]; +} + +/** The page `usePageAssignment` picks for the same app's name. */ +async function assignmentPick(appName: string, pages: Page[]): Promise { + const ctx = { + apps: [], objects: [], dashboards: [], reports: [], pages, loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, ensureType: async () => pages, + getItem: async () => null, getItemsByType: () => [], getTypeStatus: () => 'ready' as const, + } as unknown as MetadataContextValue; + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => usePageAssignment(OBJECT, { app: appName }), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + return result.current.page?.name; +} + +describe('appObjectScope — a record route reads the objects of the app-scoped page it renders (ruling A on objectstack#28979)', () => { + it('inside the Duly app the route reads duly_member\'s objects; inside Setup, sys_user_detail\'s', () => { + expect(scopePick(DULY_APP, [SYS_USER_DETAIL, DULY_MEMBER])).toBe('duly_member'); + expect(scopePick(SETUP_APP, [DULY_MEMBER, SYS_USER_DETAIL])).toBe('sys_user_detail'); + }); + + it('the scope picker and usePageAssignment agree on every app and order', async () => { + for (const app of [DULY_APP, SETUP_APP]) { + for (const pages of [[SYS_USER_DETAIL, DULY_MEMBER], [DULY_MEMBER, SYS_USER_DETAIL]]) { + expect(scopePick(app, pages)).toBe(await assignmentPick(app.name, pages)); + } + } + }); + + it('CONTROL: with no `apps` on any page, every app reads the default page\'s objects', () => { + const plain = [{ ...DULY_MEMBER, apps: undefined }, SYS_USER_DETAIL]; + expect(scopePick(DULY_APP, plain)).toBe('sys_user_detail'); + expect(scopePick(SETUP_APP, plain)).toBe('sys_user_detail'); + }); +}); diff --git a/ui/packages/app-shell/src/console/appObjectScope.ts b/ui/packages/app-shell/src/console/appObjectScope.ts index 56b30700eba..c0c5c912403 100644 --- a/ui/packages/app-shell/src/console/appObjectScope.ts +++ b/ui/packages/app-shell/src/console/appObjectScope.ts @@ -349,18 +349,25 @@ export interface PagesRead { /** * The record page a record route of `objectName` renders (objectui#13046), as * `RecordDetailView` picks it through `usePageAssignment`: the page the route - * names, when it is a record page of the object; otherwise the object's - * default record page (`defaultRecordPageObject`), else its first record page. - * Only a full page is returned. A slotted page, or none, returns `null`: the - * view synthesizes that page, and derives its related lists from every object - * that points at the record's object. + * names, when it is a record page of the object; otherwise the page scoped to + * the route's app (`PageSchema.apps`, read by the app's NAME, never the route + * segment), else the object's default record page (`defaultRecordPageObject`), + * else its first record page. Only a full page is returned. A slotted page, or + * none, returns `null`: the view synthesizes that page, and derives its related + * lists from every object that points at the record's object. */ -function declaredRecordPage(pages: readonly unknown[], objectName: string, pageName: string | undefined): unknown { +function declaredRecordPage( + pages: readonly unknown[], + objectName: string, + pageName: string | undefined, + appName: string | undefined, +): unknown { const page = pageName ? pages.find((p) => isRecord(p) && p.name === pageName && isRecordPageOf(p, objectName)) : (() => { const candidates = pages.filter((p) => isRecordPageOf(p, objectName)); - return candidates.find((p) => defaultRecordPageObject(p) === objectName) ?? candidates[0]; + const scoped = appName ? candidates.find((p) => defaultRecordPageObject(p, appName) === objectName) : undefined; + return scoped ?? candidates.find((p) => defaultRecordPageObject(p) === objectName) ?? candidates[0]; })(); return isRecord(page) && page.kind !== 'slotted' ? page : null; } @@ -491,7 +498,7 @@ export function readAppObjectScope( if (!app) return { candidate, scope: null, ready: false, ...NOT_READ }; const pagesRead = readPages?.(); if (!pagesRead?.settled) return { candidate, scope: PENDING_OBJECT_SCOPE, ready: false, ...NOT_READ }; - const page = declaredRecordPage(pagesRead.pages, route.objectName, route.pageName); + const page = declaredRecordPage(pagesRead.pages, route.objectName, route.pageName, typeof app.name === 'string' ? app.name : undefined); if (!page) return { candidate, scope: null, ready: false, ...NOT_READ }; const extra = [route.objectName, ...appRequiredObjectNames(app)]; if (new URLSearchParams(search).has(COMMAND_PALETTE_PARAM)) { diff --git a/ui/packages/app-shell/src/views/RecordDetailView.appScopedPage-29592.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.appScopedPage-29592.test.tsx new file mode 100644 index 00000000000..d3bc1546f0c --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.appScopedPage-29592.test.tsx @@ -0,0 +1,203 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The record view opens the record page scoped to the app it is opened in + * (`PageSchema.apps`, maintainer ruling A on objectstack#28979). + * + * The route's `:appName` segment is NOT the app's name: `/apps/SEGMENT` carries + * `appRouteSegment(app)`, the app's package id when it has one. So the view + * resolves the segment to its app (`matchAppBySegment`, package id first, then + * name) and hands the app's NAME to the assignment. Pins: + * + * - the ruling's own: inside the Duly app, a user record opens `duly_member` + * (`apps: ['duly']`), under its package-id segment and under its name + * segment; under Setup (`/apps/com.objectstack.setup/...`) the platform's + * `sys_user_detail` still opens; + * - two apps in one package share the package-id segment, which resolves to + * the first app, so the second app's scoped page opens under its name + * segment only; + * - the record drawer inside an app list reads the host route's app; + * - CONTROL: with no `apps` on any page, every app opens the default page. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { MetadataCtx } from '@object-ui/react'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => vi.fn(), +})); +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); +// Orthogonal chrome — this file observes which record page is drawn only. +vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +import { RecordDetailView } from './RecordDetailView'; +import { NavigationProvider } from '../context/NavigationContext'; + +const OBJECT = 'sys_user'; +const REC = 'u1'; + +const objectDef = { + name: OBJECT, + label: 'User', + fields: { id: { label: 'Id', type: 'text' }, name: { label: 'Name', type: 'text' } }, +}; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [], total: 0, hasMore: false, pageSize: 50 })), + findOne: vi.fn(async () => ({ id: REC, name: 'Ada' })), + create: vi.fn(async (_o: string, row: unknown) => row), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + getObjectSchema: async () => objectDef, + }; +} + +/** + * A full record page of the user object. Its accessible name (`aria.ariaLabel`, + * stamped on the page root) names the page, so the drawn page is readable. + */ +function userPage(name: string, extra: Record = {}) { + return { + name, + label: name, + type: 'record', + object: OBJECT, + aria: { ariaLabel: name }, + ...extra, + regions: [{ name: 'main', components: [] }], + }; +} + +const SYS_USER_DETAIL = userPage('sys_user_detail', { isDefault: true }); +const DULY_MEMBER = userPage('duly_member', { apps: ['duly'] }); + +const SETUP_APP = { name: 'setup', label: 'Setup', _packageId: 'com.objectstack.setup' }; +const DULY_APP = { name: 'duly', label: 'Duly', _packageId: 'com.duly' }; +// One package, two apps: the package id resolves to the first. +const ACME_SALES = { name: 'acme_sales', label: 'Sales', _packageId: 'com.acme' }; +const ACME_SERVICE = { name: 'acme_service', label: 'Service', _packageId: 'com.acme' }; +const APPS = [SETUP_APP, DULY_APP, ACME_SALES, ACME_SERVICE]; + +function makeMetadata(pages: unknown[]) { + return { + apps: APPS, objects: [], pages, loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, + ensureType: async () => pages, getItem: async () => null, + getItemsByType: () => pages, + } as never; +} + +/** Mount the record route `url` (or, with `drawer`, the list route with the record drawer) and read the page drawn. */ +async function drawnPage(url: string, pages: unknown[], drawer = false): Promise { + const ds = makeDataSource(); + const view = drawer ? ( + {}} + objectNameOverride={OBJECT} recordIdOverride={REC} embedded /> + ) : ( + {}} /> + ); + const utils = render( + + + + + + + + + , + ); + await waitFor(() => expect(ds.findOne).toHaveBeenCalled()); + await waitFor(() => expect(utils.container.querySelector('[data-page-type="record"][aria-label]')).not.toBeNull()); + const drawn = utils.container.querySelector('[data-page-type="record"][aria-label]')?.getAttribute('aria-label') ?? undefined; + cleanup(); + return drawn; +} + +const recordUrl = (segment: string) => `/apps/${segment}/${OBJECT}/record/${REC}`; + +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +beforeEach(() => { + // Unrelated chrome reaches for the platform API; serve it from a double so + // happy-dom never opens a socket (objectui#6640). + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ allowed: true, data: [] }), + text: async () => '{}', + })) as never); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('RecordDetailView — the record page scoped to the route\'s app opens (ruling A on objectstack#28979)', () => { + it('inside the Duly app, under its package-id segment, a user record opens duly_member', async () => { + expect(await drawnPage(recordUrl('com.duly'), [SYS_USER_DETAIL, DULY_MEMBER])).toBe('duly_member'); + }); + + it('inside the Duly app, under its name segment, a user record opens duly_member', async () => { + expect(await drawnPage(recordUrl('duly'), [SYS_USER_DETAIL, DULY_MEMBER])).toBe('duly_member'); + }); + + it('Setup → Users under /apps/com.objectstack.setup still opens sys_user_detail', async () => { + expect(await drawnPage(recordUrl('com.objectstack.setup'), [DULY_MEMBER, SYS_USER_DETAIL])).toBe('sys_user_detail'); + }); + + it('two apps in one package: the package-id segment opens the first app, so the second app\'s page opens under its name only', async () => { + const servicePage = userPage('acme_service_user', { apps: ['acme_service'] }); + const pages = [SYS_USER_DETAIL, servicePage]; + expect(await drawnPage(recordUrl('com.acme'), pages)).toBe('sys_user_detail'); + expect(await drawnPage(recordUrl('acme_service'), pages)).toBe('acme_service_user'); + // The first app's own scoped page is the one its package-id segment opens. + const salesPage = userPage('acme_sales_user', { apps: ['acme_sales'] }); + expect(await drawnPage(recordUrl('com.acme'), [SYS_USER_DETAIL, salesPage, servicePage])).toBe('acme_sales_user'); + }); + + it('the record drawer inside the Duly app\'s list opens duly_member; inside Setup\'s, sys_user_detail', async () => { + expect(await drawnPage(`/apps/com.duly/${OBJECT}`, [SYS_USER_DETAIL, DULY_MEMBER], true)).toBe('duly_member'); + expect(await drawnPage(`/apps/com.objectstack.setup/${OBJECT}`, [SYS_USER_DETAIL, DULY_MEMBER], true)).toBe('sys_user_detail'); + }); + + it('CONTROL: with no `apps` on any page, every app opens the default record page', async () => { + const pages = [userPage('first_authored'), SYS_USER_DETAIL]; + for (const segment of ['com.duly', 'duly', 'com.objectstack.setup', 'com.acme', 'acme_service']) { + expect(await drawnPage(recordUrl(segment), pages)).toBe('sys_user_detail'); + } + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index ebd6311f200..8c2b08ac87c 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -54,7 +54,7 @@ import { createConsoleServerActionHandler } from '../utils/consoleServerAction.j import { withNewTabUrlFastPath } from '../utils/newTabUrlFastPath.js'; import { modalTargetRefusalMessage } from '../utils/modalTargetDiagnostics.js'; import { actionContextOrg } from '../utils/actionContextOrg.js'; -import { resolveHostAppSegment } from '../utils/appRoute.js'; +import { matchAppBySegment, resolveHostAppSegment } from '../utils/appRoute.js'; import { hostInConsolePath } from '../utils/hostInConsolePath.js'; import { launchConsoleFlow } from '../utils/flowLaunch.js'; import { apiActionRefusalResult } from '../utils/flowResponse.js'; @@ -1041,13 +1041,23 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // overrides, not by the host route, so it never reads the segment. On the // record route itself `requestedPageName` is undefined and the lookup is the // object's default record page, exactly as before. + // + // The app the record opens in picks its own record page first + // (`PageSchema.apps`, maintainer ruling A on objectstack#28979): the + // route's `:appName` segment is resolved to its app and the app's NAME is + // what the assignment compares, never the segment itself, which carries the + // package id when the app has one (`appRouteSegment`). Where a package ships + // two apps, the package id resolves to the first (`matchAppBySegment`), so + // the second app's scoped page opens under its name segment only. A record + // drawer reads the same segment: it is opened inside the host route's app. const requestedPageName = embedded ? undefined : params.pageName; + const { pages: metadataPages, apps: metadataApps } = useMetadata(); + const routeAppName = matchAppBySegment(metadataApps, appName)?.name; const { page: resolvedPage, slots: resolvedSlots, loading: pageAssignmentLoading, - } = usePageAssignment(objectName, requestedPageName ? { pageName: requestedPageName } : undefined); - const metadataPages = useMetadata().pages; + } = usePageAssignment(objectName, requestedPageName ? { pageName: requestedPageName } : { app: routeAppName }); const namedPageState: 'none' | 'pending' | 'accepted' | 'refused' = useMemo(() => { if (!requestedPageName) return 'none'; if (pageAssignmentLoading) return 'pending'; @@ -1594,7 +1604,6 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // (objectui#13433). Only the route's app: outside an app route it is // undefined and the form keeps the app-less route. const { currentAppName } = useNavigationContext(); - const metadataApps = useMetadata().apps; const hostAppSegment = appName ?? resolveHostAppSegment(metadataApps, currentAppName); const navigateHandler = useCallback((url: string, options?: { external?: boolean; newTab?: boolean }) => { diff --git a/ui/packages/react/src/hooks/__tests__/usePageAssignment.appScope-29592.test.tsx b/ui/packages/react/src/hooks/__tests__/usePageAssignment.appScope-29592.test.tsx new file mode 100644 index 00000000000..6a00921ef44 --- /dev/null +++ b/ui/packages/react/src/hooks/__tests__/usePageAssignment.appScope-29592.test.tsx @@ -0,0 +1,134 @@ +/** + * ObjectUI — usePageAssignment: the record page scoped to the record's app opens + * Copyright (c) 2024-present ObjectStack Inc. + * + * Maintainer ruling A on objectstack#28979: a record page may name the apps + * whose routes open it for its object (`PageSchema.apps`). Inside one of those + * apps that page opens; everywhere else the object's one `isDefault` record + * page still opens. The spec allows one record page per object per app, and + * `defaultRecordPageObject(page, app)` is the one reading of that slot. + * + * The hook's `app` option carries the app's NAME. Resolving a route segment + * to that name is the caller's (`RecordDetailView`), pinned in app-shell. + * + * Each pin asserts WHICH PAGE is returned, against a control that holds the + * served list fixed and changes only the app. + */ + +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { defaultRecordPageObject } from '@objectstack/spec/ui'; +import { MetadataCtx, type MetadataContextValue } from '../../context/AppShellContext'; +import { usePageAssignment, type PageAssignmentOptions } from '../usePageAssignment'; + +type Page = Record; + +function wrapperFor(pages: Page[]) { + const ctx: MetadataContextValue = { + apps: [], + objects: [], + dashboards: [], + reports: [], + pages, + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => pages, + getItem: async () => null, + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, + }; + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +async function assign(pages: Page[], objectName: string, opts?: PageAssignmentOptions) { + const { result } = renderHook(() => usePageAssignment(objectName, opts), { + wrapper: wrapperFor(pages), + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + return result; +} + +const OBJECT = 'sys_user'; + +const record = (name: string, extra: Page = {}): Page => ({ + name, + label: name, + type: 'record', + object: OBJECT, + ...extra, +}); + +/** The platform's default record page for users (Setup → Users opens it). */ +const PLATFORM_DEFAULT = record('sys_user_detail', { isDefault: true }); +/** The app's own record page for users, scoped to the `duly` app. */ +const DULY_MEMBER = record('duly_member', { apps: ['duly'] }); + +describe('usePageAssignment — the page scoped to the record\'s app opens (ruling A on objectstack#28979)', () => { + it('the fixtures are read the way the platform\'s doors read them (precondition)', () => { + expect(defaultRecordPageObject(DULY_MEMBER, 'duly')).toBe(OBJECT); + expect(defaultRecordPageObject(DULY_MEMBER, 'setup')).toBeUndefined(); + expect(defaultRecordPageObject(DULY_MEMBER)).toBeUndefined(); + expect(defaultRecordPageObject(PLATFORM_DEFAULT)).toBe(OBJECT); + }); + + it('inside the app the page names, the scoped page opens, whatever the served order', async () => { + for (const pages of [[PLATFORM_DEFAULT, DULY_MEMBER], [DULY_MEMBER, PLATFORM_DEFAULT]]) { + const result = await assign(pages, OBJECT, { app: 'duly' }); + expect(result.current.page?.name).toBe('duly_member'); + } + }); + + it('inside another app (Setup) the default record page still opens', async () => { + const result = await assign([DULY_MEMBER, PLATFORM_DEFAULT], OBJECT, { app: 'setup' }); + expect(result.current.page?.name).toBe('sys_user_detail'); + }); + + it('with no app named, the default record page opens (the scope is never read without an app)', async () => { + const result = await assign([DULY_MEMBER, PLATFORM_DEFAULT], OBJECT); + expect(result.current.page?.name).toBe('sys_user_detail'); + }); + + it('a page listing several apps opens in each of them, and only there', async () => { + const shared = record('shared_member', { apps: ['duly', 'crew'] }); + expect((await assign([PLATFORM_DEFAULT, shared], OBJECT, { app: 'crew' })).current.page?.name).toBe('shared_member'); + expect((await assign([PLATFORM_DEFAULT, shared], OBJECT, { app: 'duly' })).current.page?.name).toBe('shared_member'); + expect((await assign([PLATFORM_DEFAULT, shared], OBJECT, { app: 'setup' })).current.page?.name).toBe('sys_user_detail'); + }); + + it('with no default, an app without a scoped page falls through to declaration order', async () => { + const first = record('first_authored'); + const result = await assign([first, DULY_MEMBER], OBJECT, { app: 'setup' }); + expect(result.current.page?.name).toBe('first_authored'); + }); + + it('a page of another object scoped to the app is not taken', async () => { + const otherObject = { ...DULY_MEMBER, name: 'duly_shift_page', object: 'duly_shift' }; + const result = await assign([otherObject, PLATFORM_DEFAULT], OBJECT, { app: 'duly' }); + expect(result.current.page?.name).toBe('sys_user_detail'); + }); + + it('a scoped slotted page hands back its slots, as the default slot does', async () => { + const slotted = record('duly_member_slots', { apps: ['duly'], kind: 'slotted', slots: { header: { type: 'text' } } }); + const result = await assign([PLATFORM_DEFAULT, slotted], OBJECT, { app: 'duly' }); + expect(result.current.page).toBeNull(); + expect(result.current.slots).toEqual({ header: { type: 'text' } }); + }); + + it('an explicit page name still wins over the app', async () => { + const result = await assign([PLATFORM_DEFAULT, DULY_MEMBER], OBJECT, { app: 'duly', pageName: 'sys_user_detail' }); + expect(result.current.page?.name).toBe('sys_user_detail'); + }); + + it('CONTROL: with no `apps` on any page, naming an app changes nothing', async () => { + const plain = [record('first_authored'), PLATFORM_DEFAULT]; + const withApp = await assign(plain, OBJECT, { app: 'duly' }); + const without = await assign(plain, OBJECT); + expect(withApp.current.page?.name).toBe('sys_user_detail'); + expect(withApp.current.page).toBe(without.current.page); + }); +}); diff --git a/ui/packages/react/src/hooks/usePageAssignment.ts b/ui/packages/react/src/hooks/usePageAssignment.ts index 452cb14bd99..d3aee9e2607 100644 --- a/ui/packages/react/src/hooks/usePageAssignment.ts +++ b/ui/packages/react/src/hooks/usePageAssignment.ts @@ -19,14 +19,16 @@ * `buildDefaultPageSchema(objectDef, { slots })` so omitted slots * fall through to synthesized defaults. * - * Selection is by the declared `PageSchema.isDefault`, then by DECLARATION - * ORDER among pages that are not the default: the spec allows one default - * record page per object, so the flag needs no tie-break here, and with no - * default the first matching page wins. No match returns null, so callers can + * Selection, in order: the page scoped to the app the record opens in + * (`PageSchema.apps`, when the caller names that app), then the declared + * `PageSchema.isDefault`, then DECLARATION ORDER among the remaining pages. + * The spec allows one record page per object per app and one default record + * page per object, so neither slot needs a tie-break here, and with neither + * the first matching page wins. No match returns null, so callers can * deterministically fall back to the auto-generated DetailView when no record * Page is authored. * - * Future work (deferred): recordType / profile / app / formFactor filtering. + * Future work (deferred): recordType / profile / formFactor filtering. * Any further author-driven ordering would have to be DECLARED on * `PageSchema` in `@objectstack/spec` first — see the note on the selection * itself below. @@ -41,7 +43,13 @@ export interface PageAssignmentOptions { recordType?: string; /** User profile filter (reserved for future use). */ profile?: string; - /** Owning app filter (reserved for future use). */ + /** + * The app the record opens in, by its NAME (`AppSchema.name`): the page + * whose `PageSchema.apps` lists it opens before the object's default + * record page. ⛔ Never a route segment: `/apps/` carries the + * app's package id when it has one, so resolve the segment to its app + * first and pass `app.name`. + */ app?: string; /** Form factor filter (reserved for future use). */ formFactor?: 'desktop' | 'tablet' | 'phone'; @@ -171,6 +179,18 @@ export function usePageAssignment( if (!candidates.length) return null; + // The page scoped to the record's app decides first (`PageSchema.apps`, + // maintainer ruling A on objectstack#28979): inside an app a record page + // names, that page opens. The spec refuses a second page naming the same + // app for the same object at the same doors that refuse a second default, + // through the same reading (`defaultRecordPageObject` with the app passed), + // so the first match is the only one. With no app named, or no page + // scoped to it, the default slot below decides, as it always has. + if (opts.app) { + const scoped = candidates.find(p => defaultRecordPageObject(p, opts.app) === objectName); + if (scoped) return scoped; + } + // The object's DEFAULT record page decides; with none, declaration order. // // At most one `isDefault: true` record page may exist for an object in a