From e79a50321c6b961e9ca4e0cb0bdd0474f470b65f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:06:05 +0000 Subject: [PATCH 1/2] feat(app-shell): render a record on a named record page at /record/ID/page/PAGE A new console route, `:objectName/record/:recordId/page/:pageName`, renders the record on the named page when it is a record page bound to the object, and shows the page-not-found state for any other name, never the default page. The record route is unchanged. `@object-ui/react` exports `isRecordPageOf`, the one reading of "a record page of object O"; `usePageAssignment`'s object-based lookup filters with it (behaviour unchanged) and the route asks it before rendering a named page. Claude-Session: https://claude.ai/code/session_01ACSfW1aH5ptDiXVMpWKPgv Co-authored-by: Claude --- .changeset/12715-is-record-page-of.md | 7 + .changeset/12715-named-record-page-route.md | 13 + packages/app-shell/src/console/AppContent.tsx | 21 ++ ...dDetailView.namedRecordPage-12715.test.tsx | 293 ++++++++++++++++++ .../app-shell/src/views/RecordDetailView.tsx | 64 +++- ...geAssignment.isRecordPageOf-12715.test.tsx | 111 +++++++ packages/react/src/hooks/usePageAssignment.ts | 26 +- 7 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 .changeset/12715-is-record-page-of.md create mode 100644 .changeset/12715-named-record-page-route.md create mode 100644 packages/app-shell/src/views/RecordDetailView.namedRecordPage-12715.test.tsx create mode 100644 packages/react/src/hooks/__tests__/usePageAssignment.isRecordPageOf-12715.test.tsx diff --git a/.changeset/12715-is-record-page-of.md b/.changeset/12715-is-record-page-of.md new file mode 100644 index 0000000000..40c52db6ad --- /dev/null +++ b/.changeset/12715-is-record-page-of.md @@ -0,0 +1,7 @@ +--- +'@object-ui/react': minor +--- + +New export `isRecordPageOf(page, objectName)` (objectui#12715): true when `page` is a record page bound to `objectName` (`type: 'record'` and `object` names it). It is the one reading of "a record page of object O": `usePageAssignment`'s object-based lookup now filters its candidates with it, unchanged in behaviour, and the console's named record page route asks it before it renders a named page for a record. + +`usePageAssignment`'s `pageName` option is unchanged: it still returns the page with that name whatever its type or object, so a caller that renders a record on the page it gets back asks `isRecordPageOf` first. diff --git a/.changeset/12715-named-record-page-route.md b/.changeset/12715-named-record-page-route.md new file mode 100644 index 0000000000..a6a30fe927 --- /dev/null +++ b/.changeset/12715-named-record-page-route.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': minor +--- + +The console renders a record on a NAMED record page (objectui#12715, the objectui layer of the print-page series). + +**URL shape:** `/apps/APP/OBJECT/record/ID/page/PAGE` (route `:objectName/record/:recordId/page/:pageName`, exported inside the console as `RECORD_NAMED_PAGE_ROUTE`). It shows record ID of object OBJECT on the record page named PAGE, so a page that is not the object's default record page (an invoice document, a delivery note) renders for the same record at a URL a browser can load. The server's print service can bind to this spelling to print any record page, not only the default one. + +- PAGE renders only when it is a record page bound to OBJECT (`type: 'record'`, `object: OBJECT`). A page that declares `print` renders with its print stylesheet and print root (`data-print-page`), and `window.__objectui.idle` behaves as on the record route. +- Any other name (an unknown name, a page that is not a record page, another object's record page) shows the console's "Page not found" state. It never falls back to the object's default page and never renders the other page, and it reads no record. While the page list loads, only the skeleton is drawn. +- The record route `/apps/APP/OBJECT/record/ID` is unchanged: it still opens the object's default record page. + +The name is a path segment, beside the record route's `/edit`, not a query parameter: an optional query would let a stray parameter change which document the record route draws. diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index c8a8a7d5d2..a280da7698 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -144,6 +144,24 @@ function DraftReviewNavigator({ appName }: { appName: string | undefined }) { */ export { buildExpressionUser }; +/** + * The console URL that renders a record on a NAMED record page + * (objectui#12715): `/apps/APP/OBJECT/record/ID/page/PAGE`. + * + * The record route `:objectName/record/:recordId` shows a record on the page + * its object's page assignment picks (the object's default record page). This + * sibling names the page in the path instead, so a document that is not the + * default (a print page, a delivery note) renders for the same record at a URL + * a browser can load. It is a path segment, the way the record route already + * spells its `/edit` sub-route, never a query: an optional query would let a + * stray parameter change which document the default route draws, and `page` is + * a page-scoped list parameter elsewhere. `RecordDetailView` renders the named + * page only when it is a record page of OBJECT, and shows a not-found state for + * any other name; it never falls back to the default page. The server's print + * service binds to this spelling, so it is exported for the route test to pin. + */ +export const RECORD_NAMED_PAGE_ROUTE = ':objectName/record/:recordId/page/:pageName'; + export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = {}) { const [connectionState, setConnectionState] = useState('disconnected'); const { user, getAuthConfig, activeOrganization } = useAuth(); @@ -1138,6 +1156,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = } /> + + } /> } /> } /> } /> diff --git a/packages/app-shell/src/views/RecordDetailView.namedRecordPage-12715.test.tsx b/packages/app-shell/src/views/RecordDetailView.namedRecordPage-12715.test.tsx new file mode 100644 index 0000000000..d0e7a765d2 --- /dev/null +++ b/packages/app-shell/src/views/RecordDetailView.namedRecordPage-12715.test.tsx @@ -0,0 +1,293 @@ +/** + * 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. + */ + +/** + * objectui#12715 — a record rendered on a NAMED record page. + * + * The console's record route (`:objectName/record/:recordId`) shows a record on + * the page its object's page assignment picks: the object's default record + * page. Its sibling `RECORD_NAMED_PAGE_ROUTE` + * (`:objectName/record/:recordId/page/:pageName`) names the page in the URL, + * so a document that is not the default (the invoice's print page) renders for + * the same record at a URL the server's print driver can load. + * + * Both routes are mounted here from the route constant `AppContent` declares, + * under the same `/apps/:appName` parent, so what is measured is the console + * URL, not a hand-fed prop. Pinned: + * + * - the named page renders, as a print page, with the print root and the + * idle signal behaving as they do on the record route (the print driver + * waits on both); + * - the record route is unchanged: it still opens the default record page; + * - a name that is not a record page of the object (unknown, a page of + * another type, another object's record page) shows the page-not-found + * state and never the default page, a synthesized page, or the other page; + * - while the pages are loading nothing but the skeleton is drawn. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, cleanup, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } 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 page renders, and its root. +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'; +import { RECORD_NAMED_PAGE_ROUTE } from '../console/AppContent'; +import { + installSettleSignalGlobal, + beginRequest, + endRequest, + __resetSettleSignal, +} from '../observability/settleSignal'; + +const OBJECT = 'invoice_12715'; +const OTHER_OBJECT = 'delivery_12715'; +const REC = 'INV-1'; + +const objectDef = { + name: OBJECT, + label: 'Invoice', + fields: { id: { label: 'Id', type: 'text' }, name: { label: 'Number', type: 'text' } }, +}; + +/** A page with one marker text in its main region, so the test can tell which page drew. */ +function page(name: string, extra: Record) { + return { + name, + label: name, + ...extra, + regions: [{ name: 'main', components: [{ type: 'text', content: `PAGE:${name}` }] }], + }; +} + +/** The invoice's default record page — the record view. */ +const DEFAULT_PAGE = page('invoice_record', { type: 'record', object: OBJECT, isDefault: true }); +/** A second record page of the invoice — the printable invoice document. */ +const PRINT_PAGE = page('invoice_document', { + type: 'record', + object: OBJECT, + print: { paperSize: 'A4' }, +}); +/** A page of the same name space that is not a record page. */ +const APP_PAGE = page('invoice_dashboard', { type: 'app' }); +/** A record page of ANOTHER object, carrying `print` so a wrong render would be a print page. */ +const OTHER_OBJECT_PAGE = page('delivery_note', { + type: 'record', + object: OTHER_OBJECT, + print: { paperSize: 'A4' }, +}); + +const PAGES = [DEFAULT_PAGE, PRINT_PAGE, APP_PAGE, OTHER_OBJECT_PAGE]; + +/** A data source whose record read is held open until released, counted by the settle signal. */ +function makeDataSource() { + let release: () => void = () => {}; + const gate = new Promise((resolve) => { release = resolve; }); + const findOne = vi.fn(async () => { + beginRequest(); + try { + await gate; + return { id: REC, name: 'INV-0001' }; + } finally { + endRequest(); + } + }); + return { + ds: { + find: vi.fn(async () => ({ data: [], total: 0, hasMore: false, pageSize: 50 })), + findOne, + create: vi.fn(async (_o: string, row: unknown) => row), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + getObjectSchema: async () => objectDef, + }, + release: () => release(), + }; +} + +function makeMetadata(pages: unknown[], loading = false) { + return { + objects: [], pages, loading, error: null, + refresh: async () => {}, invalidate: () => {}, + ensureType: loading ? () => new Promise(() => {}) : async () => pages, + getItem: async () => null, + getItemsByType: () => pages, + } as never; +} + +function mount(url: string, opts: { loading?: boolean } = {}) { + const { ds, release } = makeDataSource(); + const view = {}} />; + const utils = render( + + + + + + + + + + + + , + ); + return { ...utils, ds, release }; +} + +const objectui = () => (window as unknown as { __objectui: { idle: boolean } }).__objectui; +const printRoot = (container: HTMLElement) => container.querySelector('[data-print-page]'); + +/** + * The server's ready predicate, read the way its print driver reads it: the + * print root is in the document and the app is idle. Asserted false while the + * record read is in flight, then true once it settles. + */ +async function expectPrintReadyAfterRecordLoads( + container: HTMLElement, + ds: ReturnType['ds'], + release: () => void, +) { + await waitFor(() => expect(ds.findOne).toHaveBeenCalled()); + expect(objectui().idle).toBe(false); + release(); + await waitFor(() => expect(objectui().idle).toBe(true)); + await waitFor(() => expect(printRoot(container)).not.toBeNull()); +} + +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); + installSettleSignalGlobal(); +}); + +beforeEach(() => { + __resetSettleSignal(); + // 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(); +}); + +describe('the named record page route (objectui#12715)', () => { + it('spells the route as a path segment under the record route', () => { + expect(RECORD_NAMED_PAGE_ROUTE).toBe(':objectName/record/:recordId/page/:pageName'); + }); + + it('renders the record on the named record page, as a print page, ready when idle', async () => { + const { container, ds, release } = mount(`/apps/demo/${OBJECT}/record/${REC}/page/invoice_document`); + + await expectPrintReadyAfterRecordLoads(container, ds, release); + expect(ds.findOne).toHaveBeenCalledWith(OBJECT, REC); + await screen.findByText('PAGE:invoice_document'); + expect(screen.queryByText('PAGE:invoice_record')).toBeNull(); + expect(screen.queryByTestId('record-page-not-found')).toBeNull(); + }); + + it('opens the default record page on the record route, unchanged', async () => { + const { container, ds, release } = mount(`/apps/demo/${OBJECT}/record/${REC}`); + + await waitFor(() => expect(ds.findOne).toHaveBeenCalledWith(OBJECT, REC)); + release(); + await screen.findByText('PAGE:invoice_record'); + expect(screen.queryByText('PAGE:invoice_document')).toBeNull(); + expect(screen.queryByTestId('record-page-not-found')).toBeNull(); + // The default page declares no `print`: no print root on this route. + expect(printRoot(container)).toBeNull(); + }); + + it('gives the record route the same print root and idle signal when its default page is a print page', async () => { + // Parity for the print driver: the default-route behaviour the named route + // must match, measured on the same harness. + const pages = [{ ...PRINT_PAGE, isDefault: true }]; + const { ds, release } = makeDataSource(); + const view = {}} />; + const { container } = render( + + + + + + + + + , + ); + + await expectPrintReadyAfterRecordLoads(container, ds, release); + await screen.findByText('PAGE:invoice_document'); + }); + + describe('a name that is not a record page of the object is refused, visibly', () => { + const cases: Array<[string, string]> = [ + ['an unknown page name', 'no_such_page'], + ['a page that is not a record page', APP_PAGE.name], + ["another object's record page", OTHER_OBJECT_PAGE.name], + ]; + + it.each(cases)('%s shows page-not-found, never the default page or the other page', async (_label, name) => { + const { container, ds } = mount(`/apps/demo/${OBJECT}/record/${REC}/page/${name}`); + + await screen.findByTestId('record-page-not-found'); + // No silent fallback: neither the default page nor any other page drew, + // there is no print root for a driver to accept, and the record was not + // read for a page that will not render. + for (const p of PAGES) expect(screen.queryByText(`PAGE:${p.name}`)).toBeNull(); + expect(container.querySelector('[data-page-type]')).toBeNull(); + expect(printRoot(container)).toBeNull(); + expect(ds.findOne).not.toHaveBeenCalled(); + }); + }); + + it('draws nothing but the skeleton while the pages are loading', async () => { + const { container, ds } = mount(`/apps/demo/${OBJECT}/record/${REC}/page/invoice_document`, { loading: true }); + + // Give every effect a turn; the page list never arrives. + await new Promise((r) => setTimeout(r, 50)); + for (const p of PAGES) expect(screen.queryByText(`PAGE:${p.name}`)).toBeNull(); + expect(container.querySelector('[data-page-type]')).toBeNull(); + expect(printRoot(container)).toBeNull(); + expect(screen.queryByTestId('record-page-not-found')).toBeNull(); + expect(ds.findOne).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 22b783e445..fb52a5edba 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -15,11 +15,11 @@ import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/componen import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; import { useDisplayLocale } from '@object-ui/i18n'; -import { ActionProvider, useObjectTranslation, useObjectLabel, useActionTextLocalizer, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, InlineEditProvider, resolveApprovalLock, useGlobalUndo, useDataInvalidation, notifyDataChanged, subscribeDataChanges, dataChangeMatches, useRowPredicate, classifyLoadError } from '@object-ui/react'; +import { ActionProvider, useObjectTranslation, useObjectLabel, useActionTextLocalizer, usePageAssignment, isRecordPageOf, useMetadata, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, InlineEditProvider, resolveApprovalLock, useGlobalUndo, useDataInvalidation, notifyDataChanged, subscribeDataChanges, dataChangeMatches, useRowPredicate, classifyLoadError } from '@object-ui/react'; import { buildExpandFields, captureUpdateUndoData, recordDelete, resolveAffordance, resolveRecordIdParamSeed, userActionPredicates, withoutDeniedFields, type AffordanceGrantPrincipal, type SchemaLike } from '@object-ui/core'; import { toast } from 'sonner'; import { useRecordPresence, PresenceAvatars, isCommentAuthor } from '@object-ui/collaboration'; -import { Database, ChevronLeft, Lock, AlertTriangle, RotateCw, Printer } from 'lucide-react'; +import { Database, ChevronLeft, Lock, AlertTriangle, RotateCw, Printer, FileText } from 'lucide-react'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { SkeletonDetail } from '../skeletons/index.js'; import { ManagedByBadge } from '../components/ManagedByBadge.js'; @@ -487,6 +487,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri appName?: string; objectName?: string; recordId?: string; + pageName?: string; }>(); const appName = params.appName; const objectName = objectNameOverride ?? params.objectName; @@ -637,7 +638,36 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // so the default detail page rides the same SchemaRenderer pipeline as // custom pages. (ADR-0085 PR4 removed the legacy DetailView monolith and // its `renderViaSchema` kill-switch — schema-driven is the only path.) - const { page: assignedPage, slots: assignedSlots } = usePageAssignment(objectName); + // + // objectui#12715 — the NAMED record page route + // (`:objectName/record/:recordId/page/:pageName`, `RECORD_NAMED_PAGE_ROUTE` + // in `console/AppContent.tsx`) names the page in the URL. It is honoured only + // when that page is a record page of THIS object (`isRecordPageOf`, the same + // reading the object-based lookup filters with); any other name is refused + // with a visible not-found state. There is no fallback: while the pages load + // nothing is drawn, and a refused name never draws the default page or a + // synthesized one, so a print of this URL can never put a different document + // on paper. An embedded detail (drawer / split pane) is keyed by its + // 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. + const requestedPageName = embedded ? undefined : params.pageName; + const { + page: resolvedPage, + slots: resolvedSlots, + loading: pageAssignmentLoading, + } = usePageAssignment(objectName, requestedPageName ? { pageName: requestedPageName } : undefined); + const metadataPages = useMetadata().pages; + const namedPageState: 'none' | 'pending' | 'accepted' | 'refused' = useMemo(() => { + if (!requestedPageName) return 'none'; + if (pageAssignmentLoading) return 'pending'; + const pages: unknown[] = Array.isArray(metadataPages) ? metadataPages : []; + const named = pages.find((p) => (p as { name?: unknown } | null)?.name === requestedPageName); + return isRecordPageOf(named, objectName) ? 'accepted' : 'refused'; + }, [requestedPageName, pageAssignmentLoading, metadataPages, objectName]); + const namedPageBlocked = namedPageState === 'pending' || namedPageState === 'refused'; + const assignedPage = namedPageBlocked ? null : resolvedPage; + const assignedSlots = namedPageBlocked ? null : resolvedSlots; const synthesizedPage = useMemo(() => { // Synthesizer drives two cases: // 1) no assignedPage at all → pure default detail page @@ -647,8 +677,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // render time once the synth parts (sections, related, …) are known. if (assignedPage) return null; if (!objectDef) return null; + // A named page that is still loading or was refused is never replaced by + // the synthesized default (objectui#12715). + if (namedPageBlocked) return null; return buildDefaultPageSchema(objectDef as any, assignedSlots ? { slots: assignedSlots } : undefined); - }, [assignedPage, assignedSlots, objectDef]); + }, [assignedPage, assignedSlots, objectDef, namedPageBlocked]); const effectivePage = assignedPage || synthesizedPage; const [pageRecord, setPageRecord] = useState(null); // 'idle' | 'loading' | 'loaded' | 'missing' | 'forbidden' | 'failed' — @@ -2956,6 +2989,29 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri ); } + // objectui#12715 — the named record page route. While the pages load, + // draw the skeleton (never the default page); a name that is not a record + // page of this object shows the console's page-not-found state. Neither + // stamps the print root, so a print driver never reads either as ready. + if (namedPageState === 'pending') { + return ; + } + if (namedPageState === 'refused') { + return ( +
+ +
+ +
+ {t('empty.pageNotFound')} + + {t('empty.pageNotFoundDescription', { name: requestedPageName })} + +
+
+ ); + } + // Record-not-found short-circuit. Previously we rendered the page chrome // (rail, discussion, breadcrumb with the truncated raw id) even when the // record didn't exist, which made invalid links look like a partially- diff --git a/packages/react/src/hooks/__tests__/usePageAssignment.isRecordPageOf-12715.test.tsx b/packages/react/src/hooks/__tests__/usePageAssignment.isRecordPageOf-12715.test.tsx new file mode 100644 index 0000000000..62e8e5cda3 --- /dev/null +++ b/packages/react/src/hooks/__tests__/usePageAssignment.isRecordPageOf-12715.test.tsx @@ -0,0 +1,111 @@ +/** + * ObjectUI — `isRecordPageOf`: the one reading of "a record page of object O" + * 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. + * + * objectui#12715. The console's named record page route renders record R of + * object O on the page its URL names, and only when that page is a record + * page of O. It asks `isRecordPageOf`, and `usePageAssignment`'s object-based + * lookup filters its candidates with the same function, so the route and the + * default lookup cannot disagree on what "a record page of O" is. + * + * Pinned here: the predicate's answers, that the object-based lookup still + * reads through it (a page it refuses is never assigned), and that the hook's + * `pageName` override is left as published: it returns the page with that + * name whatever it is, which is why a caller rendering a record on it asks the + * predicate first. + */ + +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { MetadataCtx, type MetadataContextValue } from '../../context/AppShellContext'; +import { usePageAssignment, isRecordPageOf, 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 = 'invoice'; + +describe('isRecordPageOf (objectui#12715)', () => { + it('is true for a record page bound to the object', () => { + expect(isRecordPageOf({ name: 'doc', type: 'record', object: OBJECT }, OBJECT)).toBe(true); + }); + + it('is false for a record page of another object', () => { + expect(isRecordPageOf({ name: 'note', type: 'record', object: 'delivery' }, OBJECT)).toBe(false); + }); + + it('is false for a page that is not a record page, even one naming the object', () => { + for (const type of ['app', 'home', 'utility', 'list']) { + expect(isRecordPageOf({ name: 'p', type, object: OBJECT }, OBJECT)).toBe(false); + } + }); + + it('reads `type` alone: the refused `pageType` spelling decides nothing (objectui#9674)', () => { + expect(isRecordPageOf({ name: 'p', pageType: 'record', object: OBJECT }, OBJECT)).toBe(false); + }); + + it('is false for no page, and for no object', () => { + expect(isRecordPageOf(undefined, OBJECT)).toBe(false); + expect(isRecordPageOf(null, OBJECT)).toBe(false); + expect(isRecordPageOf({ name: 'p', type: 'record', object: OBJECT }, undefined)).toBe(false); + expect(isRecordPageOf({ name: 'p', type: 'record' }, OBJECT)).toBe(false); + }); +}); + +describe('usePageAssignment reads the binding through isRecordPageOf', () => { + it('assigns only a page the predicate accepts', async () => { + const pages = [ + { name: 'other', type: 'record', object: 'delivery' }, + { name: 'app_page', type: 'app', object: OBJECT }, + { name: 'the_record_page', type: 'record', object: OBJECT }, + ]; + const result = await assign(pages, OBJECT); + expect(result.current.page?.name).toBe('the_record_page'); + expect(isRecordPageOf(result.current.page, OBJECT)).toBe(true); + }); + + it('leaves the published `pageName` override as it was: it returns the named page whatever it is', async () => { + // The named record page route does not rely on the override to check + // anything; it asks `isRecordPageOf` itself. This pins that the hook's + // contract was not narrowed on the way. + const pages = [ + { name: 'the_record_page', type: 'record', object: OBJECT }, + { name: 'delivery_note', type: 'record', object: 'delivery' }, + ]; + const result = await assign(pages, OBJECT, { pageName: 'delivery_note' }); + expect(result.current.page?.name).toBe('delivery_note'); + expect(isRecordPageOf(result.current.page, OBJECT)).toBe(false); + }); +}); diff --git a/packages/react/src/hooks/usePageAssignment.ts b/packages/react/src/hooks/usePageAssignment.ts index 685e6ad301..452cb14bd9 100644 --- a/packages/react/src/hooks/usePageAssignment.ts +++ b/packages/react/src/hooks/usePageAssignment.ts @@ -74,6 +74,29 @@ export interface PageAssignmentResult { error: Error | null; } +/** + * True when `page` is a record page bound to `objectName`: its `type` is + * `'record'` and its `object` names that object. + * + * This is the ONE reading of "a record page of object O" in the console. The + * object-based lookup below filters its candidates with it, and a route that + * names a page explicitly (the console's named record page route, + * objectui#12715) asks it before rendering the named page for a record of O, + * so a name that is a page of another object, or not a record page at all, is + * refused rather than rendered. The `pageName` override of + * {@link usePageAssignment} does not ask it: that option returns the page with + * that name whatever it is, and a caller that renders a record on the page it + * gets back asks this first. + * + * `type` is the one discriminator, as the lookup's own comment explains + * (objectui#9674): `pageType` is a key `PageSchema` refuses, so it is not read. + */ +export function isRecordPageOf(page: unknown, objectName: string | undefined | null): boolean { + if (!page || typeof page !== 'object' || !objectName) return false; + const p = page as { type?: unknown; object?: unknown }; + return p.type === 'record' && p.object === objectName; +} + function matchesAssignment(_page: any, _opts: PageAssignmentOptions): boolean { // Placeholder — every page matches until rules-based assignment lands. return true; @@ -142,8 +165,7 @@ export function usePageAssignment( // designer-side `'record_detail'` is not expressible either: it left // `PageTypeSchema` (framework#2265), so `type: 'record_detail'` is an // invalid enum value. - if (p.type !== 'record') return false; - if (p.object !== objectName) return false; + if (!isRecordPageOf(p, objectName)) return false; return matchesAssignment(p, opts); }); From b7f3ecb9141e81aeeaf5ecf0f1d126bad4aac19f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:28:07 +0000 Subject: [PATCH 2/2] docs(guide): name the named record page URL in the route table and print pages Claude-Session: https://claude.ai/code/session_01ACSfW1aH5ptDiXVMpWKPgv Co-authored-by: Claude --- content/docs/guide/console-architecture.md | 1 + content/docs/guide/print-pages.md | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/content/docs/guide/console-architecture.md b/content/docs/guide/console-architecture.md index 62b7469ce4..72ea91e85c 100644 --- a/content/docs/guide/console-architecture.md +++ b/content/docs/guide/console-architecture.md @@ -113,6 +113,7 @@ Setup catalog". | `/apps/:appName/:objectName/view/:viewId` | `ObjectView` | Specific view for an object | | `/apps/:appName/:objectName/data` | `ObjectDataPage` | Bare data surface — URL `filter[]=` conditions, not bound to any saved view (ADR-0055) | | `/apps/:appName/:objectName/record/:recordId` | `RecordDetailView` | Single-record detail | +| `/apps/:appName/:objectName/record/:recordId/page/:pageName` | `RecordDetailView` | The same record on the NAMED record page `:pageName`; a name that is not a record page of the object shows "Page not found" | | `/apps/:appName/create-app` | `CreateAppPage` | App creation wizard (4-step) | | `/apps/:appName/edit-app/:editAppName` | `EditAppPage` | Edit existing app configuration | diff --git a/content/docs/guide/print-pages.md b/content/docs/guide/print-pages.md index a1b07f18be..4f0e71d0e0 100644 --- a/content/docs/guide/print-pages.md +++ b/content/docs/guide/print-pages.md @@ -82,3 +82,21 @@ A block reads whether it is on a print page with `usePrintPage()` from When the record page bound to a record declares `print`, the record view shows a **Print** button that opens the browser's print preview of that page. Saving a PDF is the browser's own "Save as PDF" destination. + +## Printing a page that is not the default + +The record route, `/apps/APP/OBJECT/record/ID`, shows a record on its object's +default record page. Any other record page of the object renders for the same +record at: + +``` +/apps/APP/OBJECT/record/ID/page/PAGE +``` + +So an invoice keeps its record view as the default page, and its print page +(`invoice_document` above) opens at `…/invoice/record/ID/page/invoice_document`, +with the same print stylesheet, print root and Print action. PAGE must be a +record page bound to OBJECT (`type: 'record'`, `object: OBJECT`). Any other +name, a page that is not a record page or a page of another object, shows +"Page not found": the console never falls back to the default page, so a print +of that URL never puts a different document on paper.