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. 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/__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.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..ffc1050ebc --- /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, _params?: unknown) => ({ id, from: 'host' })); + create = vi.fn(async () => ({})); + update = vi.fn(async () => ({})); + delete = vi.fn(async () => true); + aggregate = vi.fn(async (_resource: string, _params?: unknown) => [{ 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' }); + 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..082f69a1d2 --- /dev/null +++ b/apps/console/src/services/approvalRequestsDataSource.servedPage.test.tsx @@ -0,0 +1,379 @@ +/** + * 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 () => { + const seen: unknown[] = []; + render( + + + + + , + ); + 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' } }); + 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..70725e1230 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,27 @@ * * 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 (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'; @@ -191,8 +216,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 +272,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 +317,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 }); }