From 0ac78c0c5e4e97ba66a179f5a8be81023c56e43e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:02:46 +0000 Subject: [PATCH 1/4] fix(console): every surface reads an approval request through the approvals routes; the served page's timeline counts and labels (objectui#12045) B1's page-mount step. The console's AppContent re-provides the adapter below it as `routeApprovalRecordReads(adapter)`: a request's findOne reads GET /approvals/requests/:id and one request's timeline reads the actions route, on the record route, ObjectView drawers and split overlays, and the record views embedded in ObjectDataPage and InterfaceListPage. Lists of requests stay on the data API, so the timeline's request_id label read is no longer refused. The console's own request record route retires. A timeline read honours $count, so the served page's Timeline tab shows its count. Claude-Session: https://claude.ai/code/session_01JZnppgHRp4sREcouhkQYDB Co-authored-by: Claude --- .changeset/12045-approval-decision-panel.md | 2 + .changeset/12045-request-record-surfaces.md | 10 + apps/console/src/AppContent.tsx | 103 +++-- ...alRequestsDataSource.decisionPage.test.tsx | 63 +-- ...ovalRequestsDataSource.recordReads.test.ts | 113 ++++++ ...ovalRequestsDataSource.servedPage.test.tsx | 383 ++++++++++++++++++ ...pprovalRequestsDataSource.timeline.test.ts | 8 + .../services/approvalRequestsDataSource.ts | 163 ++++++-- 8 files changed, 730 insertions(+), 115 deletions(-) create mode 100644 .changeset/12045-request-record-surfaces.md create mode 100644 apps/console/src/services/approvalRequestsDataSource.recordReads.test.ts create mode 100644 apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx diff --git a/.changeset/12045-approval-decision-panel.md b/.changeset/12045-approval-decision-panel.md index 270c4820b7..0fa161a6d7 100644 --- a/.changeset/12045-approval-decision-panel.md +++ b/.changeset/12045-approval-decision-panel.md @@ -15,3 +15,5 @@ A pointer field draws the record it points at, the console reads an approval req No language-pack key is added, and `CellRendererProps` is unchanged. **Superseded in part (objectui#12072):** outside a `sys_approval_request` record page the panel now draws a short localized notice instead of nothing, so the sentence above saying it renders nothing there no longer holds when both changes release together. + +**Corrected and superseded in part (objectui#12045, the page-mount step):** the last sentence of the console bullet, that a request's own page no longer asks for approvals opened on the request itself, is an `@object-ui/app-shell` change (the record view's approvals read), not a console one. And the console no longer declares a record route of its own for `sys_approval_request`: every surface that opens a request reads it through the console's adapter with the request's record reads routed, so the console bullet's first sentence describes the route that change replaces, when both release together. diff --git a/.changeset/12045-request-record-surfaces.md b/.changeset/12045-request-record-surfaces.md new file mode 100644 index 0000000000..452002ca59 --- /dev/null +++ b/.changeset/12045-request-record-surfaces.md @@ -0,0 +1,10 @@ +--- +'@object-ui/console': patch +--- + +An approval request reads the same way on every console surface that opens it, and the Timeline tab of its served page shows its count (objectui#12045, the page-mount step of B1 in objectui#2763). + +- **One layer for every surface.** The console's `AppContent` wraps the shell's content in `ApprovalRecordReads`, which hands every surface below it the console's adapter with two reads routed: a `findOne` of `sys_approval_request` reads `GET /approvals/requests/:id` (with the `viewer` block and the decision tally), and one request's `sys_approval_action` timeline reads `GET /approvals/requests/:id/actions`. That covers the record route, an `ObjectView` drawer or split overlay, and the record views embedded in the object data page and in an interface list page. A list of requests, and every other read, stay on the data API. The console's own `sys_approval_request` record route is retired: the generic record route reads through this layer. +- **The timeline's count and its label read.** A timeline read now honours `$count`, so the Timeline tab of the page objectstack serves for a request shows how many actions it has. The timeline's label read of its `request_id` column now reaches the data API instead of being refused by the approvals list. + +No language-pack key is added, and no `@object-ui/*` export, prop or option changes. diff --git a/apps/console/src/AppContent.tsx b/apps/console/src/AppContent.tsx index 18eecb62c5..28b1d5bead 100644 --- a/apps/console/src/AppContent.tsx +++ b/apps/console/src/AppContent.tsx @@ -4,19 +4,21 @@ * The full inner-SPA shell (ConsoleLayout, CommandPalette, ObjectView etc.) * lives in @object-ui/app-shell as DefaultAppContent. This wrapper only * injects console-specific system routes (AppManagement / Profile / Settings - * and the legacy-URL redirects) — third-party hosts that don't need those - * routes use DefaultAppContent directly. + * and the legacy-URL redirects) and routes an approval request's record reads + * to the approvals routes (`ApprovalRecordReads`) — third-party hosts that + * don't need those use DefaultAppContent directly. */ -import { lazy, Suspense, useMemo } from 'react'; -import { Route, useParams, useLocation, useNavigate, Navigate } from 'react-router-dom'; -import { DefaultAppContent, ENVIRONMENT_SCOPE_QUERY, LoadingScreen, RecordDetailView, useAdapter, useMetadata } from '@object-ui/app-shell'; +import { lazy, Suspense, useMemo, type ReactNode } from 'react'; +import { Route, useParams, useLocation, Navigate } from 'react-router-dom'; +import { DefaultAppContent, ENVIRONMENT_SCOPE_QUERY, LoadingScreen, useAdapter } from '@object-ui/app-shell'; +import { AdapterCtx } from '@object-ui/react'; import { MePermissionsProvider } from '@object-ui/permissions'; import { createAuthenticatedFetch } from '@object-ui/auth'; import type { DataSource } from '@object-ui/types'; import { LocalizationFetchProvider } from './LocalizationFetchProvider'; import { WorkspaceTimezonePrompt } from './pages/settings/WorkspaceTimezonePrompt'; -import { APPROVAL_REQUEST_OBJECT, createApprovalRequestsDataSource } from './services/approvalRequestsDataSource'; +import { routeApprovalRecordReads } from './services/approvalRequestsDataSource'; const AppManagementPage = lazy(() => import('./pages/system/AppManagementPage').then(m => ({ default: m.AppManagementPage }))); const ProfilePage = lazy(() => import('./pages/system/ProfilePage').then(m => ({ default: m.ProfilePage }))); @@ -294,57 +296,52 @@ export const systemRoutes = ( ); +/** One routed adapter per console adapter, keyed on the adapter object itself. */ +const recordReadsByAdapter = new WeakMap(); + +function approvalRecordReadsFor(adapter: T): T { + const hit = recordReadsByAdapter.get(adapter) as T | undefined; + if (hit) return hit; + const routed = routeApprovalRecordReads(adapter); + recordReadsByAdapter.set(adapter, routed); + return routed; +} + /** - * Every `sys_approval_request` record page reads through the approvals routes - * (objectui#12045, B1 of objectui#2763). + * Every surface that opens an approval request reads it through the approvals + * routes (objectui#12045, B1 of objectui#2763). + * + * The console's adapter is the data API. For an approval request that is the + * wrong door for a record page: it attaches neither the `viewer` block the + * request's declared decision actions gate on nor the `decision_progress` + * tally, and it refuses the request's timeline (`sys_approval_action`) to an + * ordinary approver. The approvals routes answer all three for anyone who is a + * party to the request. * - * The generic record route (`:objectName/record/:recordId`, declared by - * `DefaultAppContent` in `@object-ui/app-shell`) hands `RecordDetailView` the - * console's adapter, the data API. For an approval request that read is the - * wrong door: the data API attaches neither the `viewer` block the request's - * declared decision actions gate on nor the `decision_progress` tally, and it - * refuses the request's timeline (`sys_approval_action`) to an ordinary - * approver. The approvals routes answer all three for anyone who is a party to - * the request. So this route, more specific than the generic one and therefore - * the one React Router picks for this object, mounts the same view over the - * routed approvals source (`createApprovalRequestsDataSource`): the request is - * read from `GET /approvals/requests/:id`, its timeline from - * `GET /approvals/requests/:id/actions`, and every other read the page makes - * goes to the adapter unchanged. + * A request opens on more than one surface, and `DefaultAppContent` hands each + * of them the adapter this context provides: its record route, an + * `ObjectView` drawer or split overlay (when a view authors one; with none, + * the object's field count opens the record route), and the record views + * embedded in the object data page and in an interface list page. So the + * re-routing is done once, here, for all of them, rather than once per + * surface: below this provider, `useAdapter()` answers the console's adapter + * with a request's `findOne` and its timeline read sent to the approvals + * routes (`routeApprovalRecordReads`). Lists of requests, and every other + * read and member, stay the adapter's. * - * The page lists no requests of its own; the source's `all` scope only means - * one would be read within the approvals service's own visibility. Records of - * this object are engine-owned, so the page offers no edit; the handler opens - * the record's edit route, which is where the generic route's page-mode edit - * goes. + * The routed adapter is cached on the adapter object in a `WeakMap`, never in + * a `useMemo`: every surface below keys its reads on this identity, and + * React may discard a memo (AGENTS.md #10). */ -function ApprovalRequestRecordRoute() { +export function ApprovalRecordReads({ children }: { children: ReactNode }) { const adapter = useAdapter(); - const { objects } = useMetadata(); - const navigate = useNavigate(); - const dataSource = useMemo( - () => - adapter - ? createApprovalRequestsDataSource({ host: adapter, scope: { kind: 'all' } }) - : null, - [adapter], - ); return ( - navigate('edit')} - /> + + {children} + ); } -// Exported for `services/approvalRequestsDataSource.recordRoute.test.tsx`, -// which mounts this exact fragment beside the generic record route. -export const approvalRequestRoutes = ( - } /> -); - export function AppContent() { const serverUrl = import.meta.env.VITE_SERVER_URL || ''; const endpoint = `${serverUrl}/api/v1/auth/me/permissions`; @@ -380,10 +377,12 @@ export function AppContent() { errorFallback={(err, retry) => } > - {systemRoutes}{approvalRequestRoutes}} - extraRoutesNoApp={systemRoutes} - /> + + + {/* objectui#11758 — asks an administrator once to set a still-default workspace timezone. Here, inside `MePermissionsProvider`, because diff --git a/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx b/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx index 59373e509a..74116b6927 100644 --- a/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx +++ b/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx @@ -25,9 +25,12 @@ * as A2's card; * - the decision as the declared action's POST, through `ActionParamDialog`. * - * The last describe block mounts the console's own route for this object - * beside the generic record route, as `AppContent` hands both to the shell, and - * pins that the request's record page is the routed one. + * The page reads through `routeApprovalRecordReads`, the console's adapter + * with a request's record reads routed, as every surface reads it. The last + * describe block mounts the generic record route under the console's + * `ApprovalRecordReads` provider, as `AppContent` wraps the shell, and pins + * that the request's record page is the routed one. The page objectstack + * actually serves is pinned in `approvalRequestsDataSource.servedPage.test.tsx`. */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, cleanup, waitFor, fireEvent, within, act } from '@testing-library/react'; @@ -49,16 +52,16 @@ vi.mock('sonner', () => ({ }), })); -import { AdapterCtx, MetadataCtx } from '@object-ui/react'; +import { AdapterCtx, MetadataCtx, useAdapter } from '@object-ui/react'; import { I18nProvider, type I18nConfig } from '@object-ui/i18n'; import { RecordDetailView } from '@object-ui/app-shell'; import type { DataSource } from '@object-ui/types'; import { APPROVAL_ACTION_OBJECT, APPROVAL_REQUEST_OBJECT, - createApprovalRequestsDataSource, + routeApprovalRecordReads, } from './approvalRequestsDataSource'; -import { approvalRequestRoutes } from '../AppContent'; +import { ApprovalRecordReads } from '../AppContent'; import { PageSchema } from '@objectstack/spec/ui'; import { ComponentRegistry } from '@object-ui/core'; @@ -234,9 +237,7 @@ const metadata = { const EN: I18nConfig = { defaultLanguage: 'en', detectBrowserLanguage: false }; function openRequestPage() { - const ds = createApprovalRequestsDataSource({ - host: host as unknown as DataSource, - scope: { kind: 'all' }, + const ds = routeApprovalRecordReads(host as unknown as DataSource, { fetch: transport, baseUrl: 'http://test.local/api/v1', }); @@ -383,37 +384,51 @@ describe('record:approval_decision on a decided request, and for a viewer who ma }); }); -describe("the console's route for this object (objectui#12045)", () => { - it('wins over the generic record route and reads the request through the approvals get', async () => { +/** `DefaultAppContent`'s generic record route: the view over `useAdapter()`. */ +function GenericRecordRoute() { + return {}} />; +} + +describe("the console's adapter layer for this object (objectui#12045)", () => { + it('the generic record route, under ApprovalRecordReads, reads the request through the approvals get', async () => { render( - - } /> - {approvalRequestRoutes} - + + + } /> + + , ); expect(await screen.findByTestId('approval-decision-panel', undefined, { timeout: 8000 })).toBeTruthy(); - expect(screen.queryByTestId('generic-record-route')).toBeNull(); expect(requestGets().length).toBeGreaterThan(0); expect(host.findOne.mock.calls.some(([resource]) => resource === APPROVAL_REQUEST_OBJECT)).toBe(false); }); - it('control: another object still takes the generic record route', async () => { + it('control: without the provider, the same route reads the request from the adapter', async () => { render( - - - } /> - {approvalRequestRoutes} - - , + + + + + + } /> + + + + + , + ); + await waitFor( + () => expect(host.findOne.mock.calls.some(([resource]) => resource === APPROVAL_REQUEST_OBJECT)).toBe(true), + { timeout: 8000 }, ); - expect(screen.getByTestId('generic-record-route')).toBeTruthy(); + expect(requestGets()).toHaveLength(0); }); }); diff --git a/apps/console/src/services/approvalRequestsDataSource.recordReads.test.ts b/apps/console/src/services/approvalRequestsDataSource.recordReads.test.ts new file mode 100644 index 0000000000..2fe28188ea --- /dev/null +++ b/apps/console/src/services/approvalRequestsDataSource.recordReads.test.ts @@ -0,0 +1,113 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectui#12045 (B1's page-mount step) — `routeApprovalRecordReads`, the + * console's adapter with the two reads a request's record page makes sent to + * the approvals routes, on every surface that opens a request. + * + * The transport is a fake approvals server: the get route answers one request + * with its `viewer` block, the actions route answers one request's timeline. + * The host is a class instance, as the console's adapter is, so the pins can + * hold that the routed adapter still reads as the host to everything else. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + APPROVAL_ACTION_OBJECT, + APPROVAL_REQUEST_OBJECT, + routeApprovalRecordReads, +} from './approvalRequestsDataSource'; + +const BASE = 'http://test.local/api/v1'; +const VIEWER = { can_act: true, is_submitter: false, can_override: false }; +const TIMELINE = [ + { id: 'act_1', request_id: 'req_1', action: 'submit', created_at: '2026-10-01T09:00:00Z' }, + { id: 'act_2', request_id: 'req_1', action: 'approve', created_at: '2026-10-02T09:00:00Z' }, +]; + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} + +/** Every approvals request the fake server saw, path + query. */ +let wire: string[] = []; + +const transport = vi.fn(async (input: RequestInfo | URL): Promise => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url); + wire.push(url.pathname + url.search); + const actions = /^\/api\/v1\/approvals\/requests\/([^/]+)\/actions$/.exec(url.pathname); + if (actions) return json(200, { data: TIMELINE.filter((row) => row.request_id === decodeURIComponent(actions[1])) }); + const item = /^\/api\/v1\/approvals\/requests\/([^/]+)$/.exec(url.pathname); + if (item) return json(200, { id: decodeURIComponent(item[1]), status: 'pending', viewer: VIEWER }); + return json(404, {}); +}); + +/** The console's adapter, doubled as a class instance with state and methods. */ +class FakeAdapter { + readonly connected = true; + find = vi.fn(async (_resource: string, _params?: unknown) => ({ data: [{ id: 'from_host' }], total: 1 })); + findOne = vi.fn(async (_resource: string, id: string) => ({ id, from: 'host' })); + create = vi.fn(async () => ({})); + update = vi.fn(async () => ({})); + delete = vi.fn(async () => true); + aggregate = vi.fn(async () => [{ status: 'pending', count: 3 }]); + getObjectSchema = vi.fn(async () => ({})); +} + +let host: FakeAdapter; +const routed = () => routeApprovalRecordReads(host, { fetch: transport, baseUrl: BASE }); + +beforeEach(() => { + wire = []; + host = new FakeAdapter(); + transport.mockClear(); +}); + +describe("a request's record reads go to the approvals routes (objectui#12045)", () => { + it("a request's findOne reads the approvals get route, with the viewer block, and never the host", async () => { + const row = await routed().findOne(APPROVAL_REQUEST_OBJECT, 'req_1', { $expand: ['submitter_id'] }); + expect(wire).toEqual(['/api/v1/approvals/requests/req_1']); + expect(row).toMatchObject({ id: 'req_1', viewer: VIEWER }); + expect(host.findOne).not.toHaveBeenCalled(); + }); + + it("one request's timeline reads the actions route, and never the host", async () => { + const result = await routed().find(APPROVAL_ACTION_OBJECT, { $filter: { request_id: 'req_1' } }); + expect(wire).toEqual(['/api/v1/approvals/requests/req_1/actions']); + expect(result.data.map((row) => (row as { id: string }).id)).toEqual(['act_1', 'act_2']); + expect(host.find).not.toHaveBeenCalled(); + }); +}); + +describe('lists of requests, and everything else, stay the adapter\'s (objectui#12045)', () => { + it.each([ + ["a Setup list view's read, with its filter and sort", { $filter: { status: 'pending' }, $orderby: [{ field: 'updated_at', order: 'desc' }], $top: 50 }], + ["the timeline's label read of its request_id column", { $filter: { id: { $in: ['req_1'] } }, $top: 1 }], + ])('%s goes to the data API unchanged, and is never refused', async (_case, params) => { + const result = await routed().find(APPROVAL_REQUEST_OBJECT, params as never); + expect(host.find).toHaveBeenCalledWith(APPROVAL_REQUEST_OBJECT, params); + expect(result.data).toEqual([{ id: 'from_host' }]); + expect(wire).toEqual([]); + }); + + it("another object's reads, a timeline read that is not one request's, and the host's other row reads are the host's", async () => { + const source = routed(); + await source.findOne('invoice', 'inv_1'); + await source.find(APPROVAL_ACTION_OBJECT, { $filter: { actor_id: 'u1' } }); + await source.aggregate?.(APPROVAL_REQUEST_OBJECT, { field: 'status', function: 'count', groupBy: 'status' } as never); + expect(host.findOne).toHaveBeenCalledWith('invoice', 'inv_1', undefined); + expect(host.find).toHaveBeenCalledWith(APPROVAL_ACTION_OBJECT, { $filter: { actor_id: 'u1' } }); + expect(host.aggregate).toHaveBeenCalledTimes(1); + expect(wire).toEqual([]); + }); + + it('reads as the host to everything else: its class, its state, and one identity per method', () => { + const source = routed(); + expect(source).toBeInstanceOf(FakeAdapter); + expect(source.connected).toBe(true); + expect(source.create).toBe(source.create); + expect(source.find).toBe(source.find); + void source.create(); + expect(host.create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx b/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx new file mode 100644 index 0000000000..f867c32205 --- /dev/null +++ b/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx @@ -0,0 +1,383 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectui#12045 (B1's page-mount step) — the page objectstack serves for an + * approval request, on the REAL `RecordDetailView`, opened the way the console + * opens it: the generic record route reads the adapter the console's + * `ApprovalRecordReads` provides. + * + * `SERVED_PAGE` is objectstack's `sys_approval_request_detail` as + * `plugin-approvals` ships it (`sys-approval-request.page.ts`), copied whole, + * not the build round's in-repo fixture. The object definitions are the + * served objects' own fields, field groups, `highlightFields` and declared + * actions that the page reads, abridged to those. Only the HTTP transport and + * the console adapter are doubled. + * + * What the page reads, and from where: + * - the request from `GET /approvals/requests/:id` (with `viewer` and + * `decision_progress`), through the provider, never through the adapter; + * - the Timeline tab's list AND its count from + * `GET /approvals/requests/:id/actions`; + * - the timeline's label read of its `request_id` column from the adapter, + * never refused (the build round's carried pin: under its route the same + * read was refused by the routed source's list). + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, fireEvent, within } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), +})); +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(), + }), +})); + +import { AdapterCtx, MetadataCtx, useAdapter } from '@object-ui/react'; +import { I18nProvider, type I18nConfig } from '@object-ui/i18n'; +import { RecordDetailView } from '@object-ui/app-shell'; +import { PageSchema } from '@objectstack/spec/ui'; +import { APPROVAL_ACTION_OBJECT, APPROVAL_REQUEST_OBJECT } from './approvalRequestsDataSource'; +import { ApprovalRecordReads } from '../AppContent'; + +const REQ = 'req_12045'; +const ROW = { + id: REQ, + display_title: 'invoice_approval · inv_1', + process_name: 'invoice_approval', + object_name: 'invoice', + record_id: 'inv_1', + status: 'pending', + current_step: 'manager_review', + submitter_id: 'u2', + updated_at: '2026-10-02T09:00:00Z', + viewer: { can_act: true, is_submitter: false, can_override: false }, + decision_progress: { behavior: 'quorum', got: 1, need: 2 }, +}; +const TIMELINE = [ + { id: 'act_1', request_id: REQ, action: 'submit', actor_id: 'u2', step_name: 'manager_review', comment: 'Please review the laptop order', created_at: '2026-10-01T09:00:00Z' }, + { id: 'act_2', request_id: REQ, action: 'approve', actor_id: 'u3', step_name: 'manager_review', comment: 'Budget checked', created_at: '2026-10-02T09:00:00Z' }, +]; + +/** The served decision gate: the leaf-guarded form the object declares. */ +const CAN_ACT = { + dialect: 'cel', + source: 'has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true', +}; +const DECISION_PARAMS = [ + { name: 'comment', label: 'Comment', type: 'textarea', required: false }, + { name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false }, +]; +const REQUEST_DEF = { + name: APPROVAL_REQUEST_OBJECT, + label: 'Approval Request', + pluralLabel: 'Approval Requests', + nameField: 'display_title', + titleFormat: '{process_name} · {record_id}', + highlightFields: ['process_name', 'object_name', 'record_id', 'status', 'current_step', 'submitter_id', 'updated_at'], + fields: { + id: { label: 'Request ID', type: 'text', readonly: true, group: 'System' }, + display_title: { label: 'Title', type: 'formula', returnType: 'text', group: 'Target' }, + process_name: { label: 'Source', type: 'text', group: 'Target' }, + object_name: { label: 'Object', type: 'text', group: 'Target' }, + record_id: { label: 'Record ID', type: 'text', referenceVia: 'object_name', group: 'Target' }, + submitter_id: { label: 'Submitter', type: 'lookup', reference: 'sys_user', group: 'Target' }, + status: { + label: 'Status', type: 'select', group: 'State', + options: [{ value: 'pending', label: 'Pending' }, { value: 'approved', label: 'Approved' }], + }, + current_step: { label: 'Current Step', type: 'text', group: 'State' }, + updated_at: { label: 'Updated At', type: 'datetime', group: 'System' }, + }, + actions: [ + { + name: 'approval_approve', label: 'Approve', type: 'api', method: 'POST', + target: '/api/v1/approvals/requests/{id}/approve', params: DECISION_PARAMS, + visible: CAN_ACT, locations: ['record_section', 'list_item'], refreshAfter: true, + }, + { + name: 'approval_reject', label: 'Reject', type: 'api', method: 'POST', + target: '/api/v1/approvals/requests/{id}/reject', params: DECISION_PARAMS, + visible: CAN_ACT, locations: ['record_section', 'list_item'], refreshAfter: true, + }, + ], + enable: { apiMethods: ['get', 'list'] }, +}; +const ACTION_DEF = { + name: APPROVAL_ACTION_OBJECT, + label: 'Approval Action', + pluralLabel: 'Approval Actions', + nameField: 'display_title', + fields: { + id: { label: 'Action ID', type: 'text', readonly: true }, + request_id: { label: 'Request', type: 'lookup', reference: APPROVAL_REQUEST_OBJECT }, + step_name: { label: 'Step', type: 'text' }, + action: { + label: 'Action', type: 'select', + options: [{ value: 'submit', label: 'Submitted' }, { value: 'approve', label: 'Approved' }], + }, + actor_id: { label: 'Actor', type: 'lookup', reference: 'sys_user' }, + comment: { label: 'Comment', type: 'textarea' }, + attachments: { label: 'Attachments', type: 'file', multiple: true }, + created_at: { label: 'Created At', type: 'datetime' }, + }, + enable: { apiMethods: ['get', 'list'] }, +}; +const USER_DEF = { name: 'sys_user', label: 'User', nameField: 'name', fields: { name: { label: 'Name', type: 'text' } } }; +const DEFS: Record = { + [APPROVAL_REQUEST_OBJECT]: REQUEST_DEF, + [APPROVAL_ACTION_OBJECT]: ACTION_DEF, + sys_user: USER_DEF, +}; + +/** objectstack `plugin-approvals`' `SysApprovalRequestDetailPage`, whole. */ +const SERVED_PAGE = { + name: 'sys_approval_request_detail', + label: 'Approval Request', + type: 'record', + object: 'sys_approval_request', + template: 'default', + kind: 'slotted', + isDefault: true, + regions: [], + slots: { + actions: [{ type: 'record:approval_decision' }], + highlights: { + type: 'record:highlights', + properties: { + fields: ['process_name', 'object_name', 'status', 'current_step', 'submitter_id', 'updated_at'], + }, + }, + tabs: { + type: 'page:tabs', + properties: { + tabStyle: 'line', + position: 'top', + items: [ + { + label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, + icon: 'file-text', + children: [{ type: 'record:details' }], + }, + { + label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + icon: 'history', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_approval_action', + relationshipField: 'request_id', + columns: ['created_at', 'action', 'actor_id', 'step_name', 'comment', 'attachments'], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 50, + showViewAll: true, + title: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, + }, + }, + ], + }, + ], + }, + }, + discussion: [], + }, +}; + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} + +/** Every request the page sent over HTTP: method, then path and query. */ +let wire: string[] = []; + +/** The approvals routes, answered as objectstack's REST server answers them. */ +const transport = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const url = new URL(raw, 'http://test.local'); + const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase(); + wire.push(`${method} ${url.pathname}${url.search}`); + if (method === 'GET' && url.pathname === `/api/v1/approvals/requests/${REQ}`) return json(200, ROW); + if (method === 'GET' && url.pathname === `/api/v1/approvals/requests/${REQ}/actions`) return json(200, { data: TIMELINE }); + return json(200, { success: true, data: [] }); +}); +vi.stubGlobal('fetch', transport); + +/** The console adapter, doubled: everything the provider does not route lands here. */ +const host = { + find: vi.fn(async (_resource: string, _params?: unknown) => ({ data: [], total: 0 })), + findOne: vi.fn(async (_resource: string, _id: string) => null), + create: vi.fn(), update: vi.fn(), delete: vi.fn(), + getObjectSchema: vi.fn(async (name: string) => DEFS[name] ?? null), +}; + +const metadata = { + apps: [], objects: [REQUEST_DEF, ACTION_DEF, USER_DEF], dashboards: [], reports: [], + pages: [SERVED_PAGE], loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, ensureType: async () => [], + getItem: vi.fn(async (type: string, name: string) => (type === 'object' ? (DEFS[name] ?? null) : null)), + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, +}; + +const EN: I18nConfig = { defaultLanguage: 'en', detectBrowserLanguage: false }; + +/** `DefaultAppContent`'s generic record route: the view over `useAdapter()`. */ +function GenericRecordRoute() { + return {}} />; +} + +function openServedPage() { + return render( + + + + + + + } /> + + + + + + , + ); +} + +const requestGets = () => wire.filter((w) => w === `GET /api/v1/approvals/requests/${REQ}`); +const timelineGets = () => wire.filter((w) => w === `GET /api/v1/approvals/requests/${REQ}/actions`); + +afterEach(() => { + cleanup(); + wire = []; + host.find.mockClear(); + host.findOne.mockClear(); +}); + +describe("the served request page, opened through the console's adapter layer (objectui#12045)", () => { + it('the served page parses as a spec page', () => { + const parsed = PageSchema.safeParse(SERVED_PAGE); + expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); + }); + + it("draws the decision panel's tally and the decisions the served viewer may take, read from the approvals get", async () => { + openServedPage(); + const panel = await screen.findByTestId('approval-decision-panel', undefined, { timeout: 8000 }); + const bar = await within(panel).findByRole('progressbar', undefined, { timeout: 8000 }); + expect(bar.getAttribute('aria-valuenow')).toBe('1'); + expect(bar.getAttribute('aria-valuemax')).toBe('2'); + expect(await within(panel).findByTestId('declared-action-approval_approve', undefined, { timeout: 8000 })).toBeTruthy(); + expect(within(panel).getByTestId('declared-action-approval_reject')).toBeTruthy(); + expect(requestGets().length).toBeGreaterThan(0); + expect(host.findOne.mock.calls.some(([resource]) => resource === APPROVAL_REQUEST_OBJECT)).toBe(false); + expect(screen.getAllByRole('tab').map((tab) => tab.textContent?.replace(/\d+$/, ''))).toEqual(['Details', 'Timeline']); + }); + + it("the Timeline tab counts and lists the request's actions from the actions route, never from the adapter", async () => { + openServedPage(); + const tab = await screen.findByRole('tab', { name: /Timeline/ }, { timeout: 8000 }); + expect(await within(tab).findByText('2', undefined, { timeout: 8000 })).toBeTruthy(); + fireEvent.mouseDown(tab); + fireEvent.click(tab); + expect(await screen.findByText('Budget checked', undefined, { timeout: 8000 })).toBeTruthy(); + expect(screen.getByText('Please review the laptop order')).toBeTruthy(); + expect(timelineGets().length).toBeGreaterThan(0); + expect(host.find.mock.calls.some(([resource]) => resource === APPROVAL_ACTION_OBJECT)).toBe(false); + }); + + it("the timeline's label read of its request_id column reaches the adapter, and no read the page makes is refused", async () => { + const warn = vi.spyOn(console, 'warn'); + try { + openServedPage(); + const tab = await screen.findByRole('tab', { name: /Timeline/ }, { timeout: 8000 }); + fireEvent.mouseDown(tab); + fireEvent.click(tab); + await screen.findByText('Budget checked', undefined, { timeout: 8000 }); + await waitFor( + () => + expect( + host.find.mock.calls.some( + ([resource, params]) => + resource === APPROVAL_REQUEST_OBJECT && + JSON.stringify((params as { $filter?: unknown })?.$filter ?? null).includes(REQ), + ), + 'the label read asked the adapter for the parent request', + ).toBe(true), + { timeout: 8000 }, + ); + const lookupFailures = warn.mock.calls.filter(([message]) => String(message).includes('Failed to resolve lookups')); + expect(lookupFailures, 'no lookup-label read was refused').toEqual([]); + } finally { + warn.mockRestore(); + } + }); +}); + +describe('ApprovalRecordReads (objectui#12045)', () => { + function Probe({ seen }: { seen: unknown[] }) { + seen.push(useAdapter()); + return null; + } + + it('answers useAdapter() with one routed adapter per console adapter, across renders and remounts', () => { + const seen: unknown[] = []; + const tree = () => ( + + + + + + ); + const first = render(tree()); + first.rerender(tree()); + first.unmount(); + render(tree()); + expect(seen.length).toBeGreaterThanOrEqual(3); + expect(seen[0]).not.toBe(host); + expect(new Set(seen).size, 'one identity for every surface below, whatever React keeps or discards').toBe(1); + }); + + it("sends a request's findOne to the approvals get, and leaves another object's read and a list of requests to the adapter", async () => { + let routed: { findOne: typeof host.findOne; find: typeof host.find } | null = null; + function Capture() { + routed = useAdapter() as never; + return null; + } + render( + + + + + , + ); + expect(routed).not.toBeNull(); + const source = routed as unknown as { findOne: typeof host.findOne; find: typeof host.find }; + await expect(source.findOne(APPROVAL_REQUEST_OBJECT, REQ)).resolves.toMatchObject({ id: REQ, viewer: ROW.viewer }); + await source.findOne('invoice', 'inv_1'); + await source.find(APPROVAL_REQUEST_OBJECT, { $filter: { status: 'pending' } }); + expect(requestGets()).toHaveLength(1); + expect(host.findOne.mock.calls.map(([resource]) => resource)).toEqual(['invoice']); + expect(host.find).toHaveBeenCalledWith(APPROVAL_REQUEST_OBJECT, { $filter: { status: 'pending' } }); + }); + + it('control: with no console adapter yet, it provides none', () => { + const seen: unknown[] = []; + render( + + + , + ); + expect(seen.every((value) => value === null)).toBe(true); + }); +}); diff --git a/apps/console/src/services/approvalRequestsDataSource.timeline.test.ts b/apps/console/src/services/approvalRequestsDataSource.timeline.test.ts index e53650d47b..3324f91da3 100644 --- a/apps/console/src/services/approvalRequestsDataSource.timeline.test.ts +++ b/apps/console/src/services/approvalRequestsDataSource.timeline.test.ts @@ -103,6 +103,14 @@ describe("one request's timeline reads the actions route (objectui#12045)", () = ]); }); + it("honours $count: a tab strip's count probe reads the timeline's total from the route, and is never refused", async () => { + const result = await source().find(APPROVAL_ACTION_OBJECT, { $filter: { request_id: 'req_1' }, $top: 1, $count: true }); + expect(result.total).toBe(3); + expect(ids(result)).toEqual(['act_1']); + expect(wire).toEqual(['/api/v1/approvals/requests/req_1/actions']); + expect(host.find).not.toHaveBeenCalled(); + }); + it('drops $select and $expand: they shape columns, not which rows come back', async () => { const result = await source().find(APPROVAL_ACTION_OBJECT, { $filter: { request_id: 'req_1' }, diff --git a/apps/console/src/services/approvalRequestsDataSource.ts b/apps/console/src/services/approvalRequestsDataSource.ts index c2a8380e69..725588d9a1 100644 --- a/apps/console/src/services/approvalRequestsDataSource.ts +++ b/apps/console/src/services/approvalRequestsDataSource.ts @@ -79,6 +79,10 @@ * value, as the data API orders them; * - `$top` / `$skip` window the ordered rows, and `total` is the timeline's * length, so a paged list pages over the right set; + * - `$count` is honoured as it stands: it asks for `total`, which every + * timeline read returns. A tab strip probes a related list's size with + * `{ $filter, $top: 1, $count: true }`; refusing it would leave the + * request's Timeline tab without its count; * - `$select` and `$expand` are dropped: they shape columns, never which * rows come back, and the route serves its own display names * (`actor_name`) in place of an expansion; @@ -87,6 +91,26 @@ * * A read of `sys_approval_action` with any other filter is not one request's * timeline, and goes to the host unchanged. + * + * ## Every record surface reads a request through the same doors + * (objectui#12045, the page-mount step) + * + * A request opens on more than one surface: the record route, an `ObjectView` + * drawer or split overlay, and the record views embedded in the object data + * page and an interface list page. Every one of them takes its `dataSource` + * from the console's adapter. `routeApprovalRecordReads` wraps that adapter so + * the reads that make a request's record page right go to the approvals + * routes on every surface, and nothing else changes: + * + * - a `findOne` of `sys_approval_request` reads `GET /approvals/requests/:id`, + * the one door that serves the `viewer` block and the decision tally; + * - one request's timeline reads the actions route, as above. + * + * Unlike a scoped source, it serves no list of requests: a `find` of + * `sys_approval_request` (Setup's list views, with their filters and sorts, + * and the timeline's label read of its `request_id` column, which asks for + * `{ id: { $in: [...] } }`) stays on the data API, which serves each caller + * the requests the approvals door would. Every other member is the host's. */ import { ApiDataSource } from '@object-ui/core'; import { createAuthenticatedFetch } from '@object-ui/auth'; @@ -191,8 +215,11 @@ function timelineRequestId(params: QueryParams | undefined): string | null { return typeof id === 'string' && id.trim() !== '' ? id : null; } -/** Names a timeline read applies itself, or drops because they shape columns. */ -const TIMELINE_APPLIED = new Set(['$filter', '$orderby', '$top', '$skip']); +/** + * Names a timeline read applies itself, or drops because they shape columns. + * `$count` asks for `total`, which every timeline read returns. + */ +const TIMELINE_APPLIED = new Set(['$filter', '$orderby', '$top', '$skip', '$count']); const TIMELINE_DROPPED = new Set(['$select', '$expand']); /** The refusal a timeline read throws: the same code `ListView` classifies. */ @@ -244,20 +271,22 @@ function windowBound(name: '$top' | '$skip', value: unknown): number | undefined return n; } -/** - * Build the routed source for one scope. Hand it to `ListView` / - * `RecordDetailView` as their `dataSource`; mount one per scope. - */ -export function createApprovalRequestsDataSource( - options: ApprovalRequestsDataSourceOptions, -): DataSource { - const { host, scope } = options; +/** The approvals routes a request's record page reads, built once per source. */ +interface ApprovalReadDoors { + /** `GET /approvals/requests` — the list route, for a scoped source. */ + url: string; + fetchFn: NonNullable; + /** One request, from `GET /approvals/requests/:id` (`null` only on a 404). */ + findRequest(id: string): Promise; + /** One request's timeline, from the actions route, ordered and windowed here. */ + findTimeline(requestId: string, params: QueryParams): Promise>; +} + +function approvalReadDoors(options: Pick): ApprovalReadDoors { const fetchFn = options.fetch ?? createAuthenticatedFetch(); const url = `${(options.baseUrl ?? API_BASE).replace(/\/+$/, '')}/approvals/requests`; - const scopeParams = toScopeParams(scope); const itemDoor = new ApiDataSource({ read: { url, method: 'GET' }, fetch: fetchFn }); - /** One request's timeline, from the actions route, ordered and windowed here. */ const findTimeline = async (requestId: string, params: QueryParams): Promise> => { const refused = Object.keys(params) .filter((key) => !TIMELINE_APPLIED.has(key) && !TIMELINE_DROPPED.has(key)) @@ -287,47 +316,103 @@ export function createApprovalRequestsDataSource( return { data: page, total: rows.length }; }; + return { + url, + fetchFn, + findRequest: (id) => itemDoor.findOne(APPROVAL_REQUEST_OBJECT, encodeURIComponent(id)), + findTimeline, + }; +} + +/** + * The host's other members, bound to it, and cached per host function so a + * consumer reads one identity for a method across renders. `wrap` replaces a + * method's implementation; it sees the host's own, already bound. + */ +function forwardToHost( + host: T, + own: Readonly>, + wrap?: (prop: string, method: (...args: unknown[]) => unknown) => ((...args: unknown[]) => unknown) | undefined, +): T { + const bound = new Map(); + return new Proxy(host, { + get(target, prop) { + if (typeof prop === 'string' && Object.prototype.hasOwnProperty.call(own, prop)) return own[prop]; + const value = Reflect.get(target, prop, target); + if (typeof value !== 'function') return value; + const hit = bound.get(prop); + if (hit && hit.source === value) return hit.fn; + const method = (value as (...args: unknown[]) => unknown).bind(target); + const fn = (typeof prop === 'string' && wrap?.(prop, method)) || method; + bound.set(prop, { source: value, fn }); + return fn; + }, + }); +} + +/** + * Build the routed source for one scope. Hand it to `ListView` / + * `RecordDetailView` as their `dataSource`; mount one per scope. + */ +export function createApprovalRequestsDataSource( + options: ApprovalRequestsDataSourceOptions, +): DataSource { + const { host, scope } = options; + const doors = approvalReadDoors(options); + const scopeParams = toScopeParams(scope); + const find = async (resource: string, params?: QueryParams): Promise> => { if (resource === APPROVAL_ACTION_OBJECT) { const requestId = timelineRequestId(params); - return requestId === null ? host.find(resource, params) : findTimeline(requestId, params ?? {}); + return requestId === null ? host.find(resource, params) : doors.findTimeline(requestId, params ?? {}); } if (resource !== APPROVAL_REQUEST_OBJECT) return host.find(resource, params); const query = toListQuery(params); if (scopeParams === null) return { data: [], total: 0 }; const listDoor = new ApiDataSource({ - read: { url, method: 'GET', params: { ...scopeParams, ...query } }, - fetch: fetchFn, + read: { url: doors.url, method: 'GET', params: { ...scopeParams, ...query } }, + fetch: doors.fetchFn, }); return listDoor.find(resource); }; const findOne = async (resource: string, id: string, params?: QueryParams): Promise => { if (resource !== APPROVAL_REQUEST_OBJECT) return host.findOne(resource, id, params); - return itemDoor.findOne(resource, encodeURIComponent(id)); + return doors.findRequest(id); }; - // Every other member is the host's, bound to it, and cached per host function - // so a consumer reads one identity for a method across renders. - const bound = new Map(); - return new Proxy(host, { - get(target, prop) { - if (prop === 'find') return find; - if (prop === 'findOne') return findOne; - const value = Reflect.get(target, prop, target); - if (typeof value !== 'function') return value; - const hit = bound.get(prop); - if (hit && hit.source === value) return hit.fn; - const method = value as (...args: unknown[]) => unknown; - const fn = - typeof prop === 'string' && REFUSED_HOST_READS.has(prop) - ? (resource: unknown, ...rest: unknown[]) => - resource === APPROVAL_REQUEST_OBJECT - ? Promise.reject(refusal(prop)) - : method.call(target, resource, ...rest) - : method.bind(target); - bound.set(prop, { source: value, fn }); - return fn; - }, - }); + return forwardToHost(host, { find, findOne }, (prop, method) => + REFUSED_HOST_READS.has(prop) + ? (resource: unknown, ...rest: unknown[]) => + resource === APPROVAL_REQUEST_OBJECT ? Promise.reject(refusal(prop)) : method(resource, ...rest) + : undefined, + ); +} + +/** + * The console's adapter, with the reads a request's record page makes routed + * to the approvals routes on every surface (objectui#12045): a `findOne` of + * `sys_approval_request` and one request's `sys_approval_action` timeline. + * Every other read, a `find` of `sys_approval_request` included, and every + * other member are the host's. See the module doc for why lists stay on the + * data API. + * + * Returns the host's own type: the result IS the host, with two reads + * re-routed, so whatever reads the host reads it. + */ +export function routeApprovalRecordReads( + host: T, + options: Pick = {}, +): T { + const doors = approvalReadDoors(options); + + const find = async (resource: string, params?: QueryParams): Promise> => { + const requestId = resource === APPROVAL_ACTION_OBJECT ? timelineRequestId(params) : null; + return requestId === null ? host.find(resource, params) : doors.findTimeline(requestId, params ?? {}); + }; + + const findOne = async (resource: string, id: string, params?: QueryParams): Promise => + resource === APPROVAL_REQUEST_OBJECT ? doors.findRequest(id) : host.findOne(resource, id, params); + + return forwardToHost(host, { find, findOne }); } From bce9baf5cf2e4fa2853b61e01e9ec70d8c9a9f85 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:11:39 +0000 Subject: [PATCH 2/4] test(console): pin that AppContent hands the shell the routed adapter; type the record-reads doubles (objectui#12045) The one wiring every request surface shares is DefaultAppContent's useAdapter(); a probe in its place, under the real AppContent, reads the routed adapter. The record-reads unit test's adapter double now types its findOne and aggregate parameters, which the console type-check reads. Claude-Session: https://claude.ai/code/session_01JZnppgHRp4sREcouhkQYDB Co-authored-by: Claude --- .../AppContent.approvalRecordReads.test.tsx | 99 +++++++++++++++++++ ...ovalRequestsDataSource.recordReads.test.ts | 6 +- .../services/approvalRequestsDataSource.ts | 3 +- 3 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 apps/console/src/__tests__/AppContent.approvalRecordReads.test.tsx diff --git a/apps/console/src/__tests__/AppContent.approvalRecordReads.test.tsx b/apps/console/src/__tests__/AppContent.approvalRecordReads.test.tsx new file mode 100644 index 0000000000..bb55f23138 --- /dev/null +++ b/apps/console/src/__tests__/AppContent.approvalRecordReads.test.tsx @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12045 (B1's page-mount step) — the console's `AppContent` hands the + * shell's content the console's adapter with an approval request's record + * reads routed to the approvals routes. + * + * Every surface that opens a request takes its `dataSource` from what + * `DefaultAppContent` reads through `useAdapter()`: its record route, an + * `ObjectView` drawer or split overlay, and the record views embedded in the + * object data page and an interface list page. So this file pins the one + * wiring all of them share: `DefaultAppContent`, rendered by the REAL + * `AppContent`, reads a routed adapter. `DefaultAppContent` itself is replaced + * by a probe that records what it reads, and the providers around it that + * fetch on mount are passed through; what the routed adapter does is pinned in + * `services/approvalRequestsDataSource.recordReads.test.ts`. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { AdapterCtx, useAdapter } from '@object-ui/react'; + +let shellAdapter: unknown = undefined; + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + DefaultAppContent: function DefaultAppContentProbe() { + shellAdapter = useAdapter(); + return null; + }, +})); +vi.mock('@object-ui/permissions', async (importOriginal) => ({ + ...(await importOriginal>()), + MePermissionsProvider: ({ children }: { children: ReactNode }) => <>{children}, +})); +vi.mock('../LocalizationFetchProvider', () => ({ + LocalizationFetchProvider: ({ children }: { children: ReactNode }) => <>{children}, +})); +vi.mock('../pages/settings/WorkspaceTimezonePrompt', () => ({ + WorkspaceTimezonePrompt: () => null, +})); + +import { AppContent } from '../AppContent'; + +const REQ = 'req_12045'; +let wire: string[] = []; +vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const url = new URL(raw, 'http://test.local'); + wire.push(url.pathname); + return new Response(JSON.stringify({ id: REQ, status: 'pending', viewer: { can_act: true } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }), +); + +const host = { + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(async (_resource: string, id: string) => ({ id, from: 'host' })), + create: vi.fn(), update: vi.fn(), delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({})), +}; + +afterEach(() => { + cleanup(); + shellAdapter = undefined; + wire = []; + host.findOne.mockClear(); +}); + +describe("AppContent hands the shell's content the routed adapter (objectui#12045)", () => { + it("DefaultAppContent reads the console's adapter with a request's findOne sent to the approvals get", async () => { + render( + + + , + ); + expect(shellAdapter).toBeTruthy(); + expect(shellAdapter, 'the shell reads the routed adapter, not the bare one').not.toBe(host); + const adapter = shellAdapter as typeof host; + await expect(adapter.findOne('sys_approval_request', REQ)).resolves.toMatchObject({ id: REQ, viewer: { can_act: true } }); + expect(wire).toEqual([`/api/v1/approvals/requests/${REQ}`]); + expect(host.findOne).not.toHaveBeenCalled(); + }); + + it("control: another object's record read is still the console adapter's", async () => { + render( + + + , + ); + const adapter = shellAdapter as typeof host; + await expect(adapter.findOne('invoice', 'inv_1')).resolves.toEqual({ id: 'inv_1', from: 'host' }); + expect(wire).toEqual([]); + }); +}); diff --git a/apps/console/src/services/approvalRequestsDataSource.recordReads.test.ts b/apps/console/src/services/approvalRequestsDataSource.recordReads.test.ts index 2fe28188ea..ffc1050ebc 100644 --- a/apps/console/src/services/approvalRequestsDataSource.recordReads.test.ts +++ b/apps/console/src/services/approvalRequestsDataSource.recordReads.test.ts @@ -46,11 +46,11 @@ const transport = vi.fn(async (input: RequestInfo | URL): Promise => { class FakeAdapter { readonly connected = true; find = vi.fn(async (_resource: string, _params?: unknown) => ({ data: [{ id: 'from_host' }], total: 1 })); - findOne = vi.fn(async (_resource: string, id: string) => ({ id, from: 'host' })); + findOne = vi.fn(async (_resource: string, id: string, _params?: unknown) => ({ id, from: 'host' })); create = vi.fn(async () => ({})); update = vi.fn(async () => ({})); delete = vi.fn(async () => true); - aggregate = vi.fn(async () => [{ status: 'pending', count: 3 }]); + aggregate = vi.fn(async (_resource: string, _params?: unknown) => [{ status: 'pending', count: 3 }]); getObjectSchema = vi.fn(async () => ({})); } @@ -94,7 +94,7 @@ describe('lists of requests, and everything else, stay the adapter\'s (objectui# const source = routed(); await source.findOne('invoice', 'inv_1'); await source.find(APPROVAL_ACTION_OBJECT, { $filter: { actor_id: 'u1' } }); - await source.aggregate?.(APPROVAL_REQUEST_OBJECT, { field: 'status', function: 'count', groupBy: 'status' } as never); + await source.aggregate(APPROVAL_REQUEST_OBJECT, { field: 'status', function: 'count', groupBy: 'status' }); expect(host.findOne).toHaveBeenCalledWith('invoice', 'inv_1', undefined); expect(host.find).toHaveBeenCalledWith(APPROVAL_ACTION_OBJECT, { $filter: { actor_id: 'u1' } }); expect(host.aggregate).toHaveBeenCalledTimes(1); diff --git a/apps/console/src/services/approvalRequestsDataSource.ts b/apps/console/src/services/approvalRequestsDataSource.ts index 725588d9a1..70725e1230 100644 --- a/apps/console/src/services/approvalRequestsDataSource.ts +++ b/apps/console/src/services/approvalRequestsDataSource.ts @@ -110,7 +110,8 @@ * `sys_approval_request` (Setup's list views, with their filters and sorts, * and the timeline's label read of its `request_id` column, which asks for * `{ id: { $in: [...] } }`) stays on the data API, which serves each caller - * the requests the approvals door would. Every other member is the host's. + * the requests the approvals door would (objectstack's request read gate, + * objectstack-ai/objectstack#22559). Every other member is the host's. */ import { ApiDataSource } from '@object-ui/core'; import { createAuthenticatedFetch } from '@object-ui/auth'; From b688d164db5e9e985502af7149b11a450d0b2368 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:19:31 +0000 Subject: [PATCH 3/4] test(console): read the routed adapter through the probe array, not a reassigned outer variable (objectui#12045) react-hooks/globals refuses a render-time reassignment of an outer `let`; the provider pin now reads what the probe component recorded. Claude-Session: https://claude.ai/code/session_01JZnppgHRp4sREcouhkQYDB Co-authored-by: Claude --- .../approvalRequestsDataSource.servedPage.test.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx b/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx index f867c32205..082f69a1d2 100644 --- a/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx +++ b/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx @@ -349,20 +349,16 @@ describe('ApprovalRecordReads (objectui#12045)', () => { }); it("sends a request's findOne to the approvals get, and leaves another object's read and a list of requests to the adapter", async () => { - let routed: { findOne: typeof host.findOne; find: typeof host.find } | null = null; - function Capture() { - routed = useAdapter() as never; - return null; - } + const seen: unknown[] = []; render( - + , ); - expect(routed).not.toBeNull(); - const source = routed as unknown as { findOne: typeof host.findOne; find: typeof host.find }; + expect(seen[0]).toBeTruthy(); + const source = seen[0] as { findOne: typeof host.findOne; find: typeof host.find }; await expect(source.findOne(APPROVAL_REQUEST_OBJECT, REQ)).resolves.toMatchObject({ id: REQ, viewer: ROW.viewer }); await source.findOne('invoice', 'inv_1'); await source.find(APPROVAL_REQUEST_OBJECT, { $filter: { status: 'pending' } }); From 7680faeca0717beda7e368310670ef1edec50fa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:33:34 +0000 Subject: [PATCH 4/4] docs(changeset): the 12032 entry says what the console reads once its request route retires (objectui#12045) Append-only, after the existing "Superseded in part" paragraph: the console declares no request record route of its own; ApprovalRecordReads sends a request's single read and its timeline to the approvals routes on every surface, and lists of requests stay on the data API. Frontmatter and every existing line are unchanged (claim amendment 6185211283). Claude-Session: https://claude.ai/code/session_01JZnppgHRp4sREcouhkQYDB Co-authored-by: Claude --- .changeset/12032-approval-requests-data-source.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/12032-approval-requests-data-source.md b/.changeset/12032-approval-requests-data-source.md index d5ebb3e26e..94e5909252 100644 --- a/.changeset/12032-approval-requests-data-source.md +++ b/.changeset/12032-approval-requests-data-source.md @@ -11,3 +11,5 @@ Approval requests can now be read by the standard list and record views, and `Ap Nothing is added to any package entry: no export, prop, type member or language-pack key. **Superseded in part (objectui#12045):** the console now mounts this source on the `sys_approval_request` record route, so the sentence above saying nothing mounts it no longer holds when both changes release together. + +**Superseded again (objectui#12045, the page-mount step):** the console no longer declares a record route of its own for `sys_approval_request`. On every surface that opens a request, the console-wide `ApprovalRecordReads` layer sends the request's single read to `GET /approvals/requests/:id` and one request's timeline to `GET /approvals/requests/:id/actions`; lists of requests, and every other read, stay on the console's adapter, the data API.