Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit d99b731

Browse files
feat(console): approval requests through the standard provider:'api' door (objectui#12032) (#12043)
Fixes #12032 Clause-②: no A1 of the approvals rebuild (objectui#2763): approval requests now reach the standard `ListView` and `RecordDetailView` through the existing `provider: 'api'` door, with `viewer` and `decision_progress` as ordinary fields. Built to the claim amendment on the card (comment 6077174661), which answered the dev report's three questions (comment 6077126267) with Q1 A, Q2 A and Q3 A. ## What changed **`apps/console/src/services/approvalRequestsDataSource.ts` (new).** `createApprovalRequestsDataSource({ host, scope })` returns a routed `DataSource`, one per inbox scope: | scope | list request | |---|---| | `awaiting_me` | `status=pending&approverId=` every identity, comma-joined | | `submitted_by_me` | `submitterId=` the user | | `all` | no scope parameter | - Reads of `sys_approval_request` go to `/approvals/requests` through `ApiDataSource`. Every other resource and method goes to the host adapter, untouched. `RecordDetailView` needs this, because it sends its side reads (the `sys_user` directory on every mount, comments, activities, history) through the same source. - A scope that names no identity reads as an empty list and sends nothing. It never falls through to the unscoped list. - **List translation.** The list route has a closed parameter set and answers any other name with a 400. So `$top` becomes `limit`, `$skip` becomes `offset` and `$search` becomes `q`. `$select` is dropped: the route has no projection, so dropping it widens columns, never rows. Every other name (`$filter`, `$orderby`, `$expand`, `$searchFields`, or anything unknown) is refused before any request with an `UNSUPPORTED_QUERY_PARAM` error. `ListView` classifies that code as a rejected query and shows its error panel, never unfiltered rows. - **The get** goes through a door with no scope params and sends none of the caller's `QueryParams`, because the route reads none. The id is encoded into the path. - Rows come back as the approvals service serves them. `viewer` is on list and get rows. `decision_progress` is on the get only, which is the contract: single-request reads of pending requests. **`packages/core/src/adapters/ApiDataSource.ts`.** Only `findOne`'s `catch` is narrowed. It answers `null` on a 404 and rethrows every other failure (a 403, a 5xx, a transport error), as `ObjectStackAdapter.findOne` does. The 404 is recognised from the message the class's own `request` composes ("ApiDataSource: HTTP 404 …"), so no error shape changes. Before this, a refused or failed read through any `provider: 'api'` source reached the record page as "Record not found" instead of its "no access" or "could not load" state (objectui#11902). **`apps/console/src/services/approvalsApi.ts`.** `API_BASE` is now exported, so the new module reaches the same routes without a second spelling of where they live. This is the one helper the claim allows sharing; nothing else in the file moved. **`.changeset/12032-approval-requests-data-source.md`:** `patch` on `@object-ui/core` and `@object-ui/console`. There is no new package export, prop, registry type or pack key. Nothing mounts the source yet; B1 and B2 will. ## Pins `apps/console/src/services/approvalRequestsDataSource.test.tsx`, against a fake approvals server. The fake restates the list route's closed set verbatim (`APPROVAL_REQUEST_LIST_PARAMS` from objectstack's REST server) and its bare-row get: - each scope's list request, and the empty-identity case (no request, empty result); - the translation (`$top`, `$skip`, `$search`, with `$select` dropped and `total` from the envelope), and the scope and paging riding together; - each of `$filter`, `$orderby`, `$expand` and `$searchFields` refused before any request, with `code` `UNSUPPORTED_QUERY_PARAM` and the parameter named; - **through the real `ListView`:** a scoped list reads with `limit` and no `$` name and draws the rows; the toolbar search reaches the route as `q`; and a view that needs `$filter`, `$orderby`, `$expand` or `$searchFields` shows the panel with `data-error-kind` `rejected`, draws no rows and sends no request; - the get, with `viewer` and `decision_progress` as fields and no query on the wire; the id encoded; a 404 reading as `null`; - the routed source: another resource's find and findOne go to the host, writes and metadata go to the host, the host's other row reads are refused for approval requests and are the host's for everything else, and one identity per method across reads. `apps/console/src/services/approvalRequestsDataSource.recordPage.test.tsx` mounts the real `RecordDetailView` over the routed source, with only the transport doubled: - a 403 renders `record-access-denied` and never "Record not found"; - a 500 renders `record-load-failed` with Retry; - controls: a 404 still renders "Record not found", and a found request renders, read once from the get route with no query, while the page's side reads go to the host. `packages/core/src/adapters/__tests__/ApiDataSource.test.ts`: `findOne` rejects on a 403, on a 500 and on a transport failure, beside the unchanged "should return null on 404" pin. ## Reverse verification Both runs used objectstack's `scripts/ablation-replace.mjs` in wrap mode, from commit `db70859c0`. The anchor was hit once and the blob change was proven on disk. Each restore was proven: blob equal to HEAD, and `git diff HEAD` empty. Both runs resolve to source through the vitest alias for `@object-ui/core`, so no build is in the path. - **A, restore the catch-all in `findOne`** (`throw err;` replaced with `return null;`). Predicted red: the three core rejection pins and the record page's 403 and 500 pins. Observed: `Tests 5 failed | 32 passed (37)`, exactly those five. The 404 control and the found-request control stayed green. - **B, drop the refusal of `$filter`** (`$filter` added to the dropped set). Predicted red: the `$filter` unit refusal and the `ListView` `$filter` panel pin. Observed: `Tests 2 failed | 22 passed (24)`, exactly those two. ## Local gates (all on `db70859c0`, from the worktree root) - `pnpm exec vitest run packages/core/`: `Test Files 198 passed (198)`, `Tests 3939 passed | 27 skipped (3966)`. - `pnpm exec vitest run apps/console/src/services/`: `Test Files 3 passed (3)`, `Tests 37 passed (37)`. - The other packages' tests that construct `ApiDataSource` or `resolveDataSource` (15 files across components, fields, plugin-detail, plugin-gantt, plugin-kanban, plugin-list, plugin-view and react): `Test Files 15 passed (15)`, `Tests 260 passed (260)`. - `pnpm --filter @object-ui/core type-check` and `pnpm --filter @object-ui/console type-check`: exit 0, after building the dependency closure (`turbo run build --filter='@object-ui/console^...'`, 34 tasks successful). - `pnpm exec eslint` on the 6 touched files (count from `--format json`): 0 errors. The 11 warnings are all `no-explicit-any` in pre-existing lines; the new files have none. Type-aware linting is not enabled in `eslint.config.js` (no `projectService` or `parserOptions.project`), so this diff cannot move another file's verdict. - `check:control-bytes`, `check:test-path-roots`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:new-line-citations`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:phantom-deps`, `check:unused-deps`, `check:self-import` and `check:unreferenced-sources`: each exit 0. `node scripts/check-changeset-presence.mjs`: "6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)". - NOT MEASURED: `check:eager-closure` and the first-load bytes, because a console production build was out of reach locally; CI's Bundle Analysis owns them. Nothing eager imports the new module. The core change is a few lines inside one method. ## Acceptance notes - **Two places this narrows "every other method goes to the host".** (1) The host's other row reads (`aggregate`, `queryGroupHeaders`, `exportDownload`) are refused when called for `sys_approval_request`, with the same code. Forwarded, they would read approval requests through the data API: outside the scope and without `viewer`. So a grouped grid or a server export on these lists fails loudly instead of widening rows. (2) The get sends none of the caller's `QueryParams`, so the record page's `$expand` (it asks for every declared relation) is not sent. The route has no expansion and serves its own display names (`submitter_name`, `record_title`, …). Refusing `$expand` on the get would break every request page, because `sys_approval_request` declares lookups (`submitter_id`, `organization_id`). The seat may reverse either; each is pinned. - **For B2:** the list views plugin-approvals ships for `sys_approval_request` all declare `sort` and `filter`, and most show the `submitter_id` lookup. Mounted on this source, each would be refused (`$orderby`, `$filter`, `$expand`), as ruled. B2 authors views without them (scope comes from the source; use `submitter_name`), or a card with a pull takes the route-side option to the spec seat. - **For B2:** list rows carry `viewer` but never `decision_progress` (contract), and `ListView` honours a `schema.data` `provider: 'api'` config on the gantt view only. Both are recorded in the amendment. - The core README's `ApiDataSource` section states nothing about `findOne`'s failure semantics, so none of its text became false. It was left alone because it is outside the claim's surface. - The PM's original ablation list included "drop the scope"; the amendment's list does not, and it was not run. The scope pins assert the exact URL for each scope. Session: `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8` --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 47b1f0b commit d99b731

7 files changed

Lines changed: 732 additions & 3 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
Approval requests can now be read by the standard list and record views, and `ApiDataSource.findOne` now rejects a refused or failed read instead of reporting the record as missing (objectui#12032, A1 of objectui#2763).
7+
8+
- **`ApiDataSource.findOne` resolves `null` only on a 404.** Every other failure (a 403, a 5xx, a transport error) now rejects, the way `ObjectStackAdapter.findOne` does. Before, any failure resolved `null`, so a record page over a `provider: 'api'` source showed a refused or failed read as "Record not found". It now shows "no access", or "could not load" with Retry (the record page states from objectui#11902).
9+
- **A console data source for approval requests.** `apps/console/src/services/approvalRequestsDataSource.ts` routes reads of `sys_approval_request` to the approvals routes through `ApiDataSource`, and everything else to the console's own adapter. Each source serves one server-side scope: awaiting me, submitted by me, or all. Rows come back as the approvals service serves them, so `viewer` and (on a single read) `decision_progress` are fields a view can bind. On a list read, `$top`, `$skip` and `$search` become the route's `limit`, `offset` and `q`, and `$select` is dropped. Every other parameter, such as a filter, a sort or an expansion, is refused with an `UNSUPPORTED_QUERY_PARAM` error, so the list shows its error panel and never shows unfiltered rows. Nothing mounts the source yet; the approvals list and detail pages that will use it come later.
10+
11+
Nothing is added to any package entry: no export, prop, type member or language-pack key.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* objectui#12032 — one approval request on the REAL record page, read through
6+
* the routed approvals source.
7+
*
8+
* `RecordDetailView` tells three failed answers apart (objectui#11902): a
9+
* refusal ("no access"), a failure ("could not load", with Retry) and a 404
10+
* ("not found"). That needs the adapter to resolve `null` ONLY for a 404 and
11+
* to reject everything else. The door's `findOne` used to resolve `null` for
12+
* every failure, so a refused or failed approvals get read as "not found".
13+
* These pins mount the page over the routed source with only the HTTP
14+
* transport doubled, so each answer reaches the page as the door hands it.
15+
*/
16+
import { describe, it, expect, vi, afterEach } from 'vitest';
17+
import { render, screen, cleanup, waitFor } from '@testing-library/react';
18+
import { MemoryRouter } from 'react-router-dom';
19+
20+
vi.mock('@object-ui/auth', async (importOriginal) => ({
21+
...(await importOriginal<Record<string, unknown>>()),
22+
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
23+
}));
24+
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
25+
...(await importOriginal<Record<string, unknown>>()),
26+
useRecordPresence: () => [],
27+
PresenceAvatars: () => null,
28+
}));
29+
vi.mock('sonner', () => ({
30+
toast: Object.assign(vi.fn(), {
31+
success: vi.fn(), error: vi.fn(), info: vi.fn(),
32+
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
33+
}),
34+
}));
35+
36+
import { MetadataCtx } from '@object-ui/react';
37+
import { I18nProvider, type I18nConfig } from '@object-ui/i18n';
38+
import { RecordDetailView } from '@object-ui/app-shell';
39+
import type { DataSource } from '@object-ui/types';
40+
import { APPROVAL_REQUEST_OBJECT, createApprovalRequestsDataSource } from './approvalRequestsDataSource';
41+
42+
const BASE = 'http://test.local/api/v1';
43+
const REQ = 'req_12032';
44+
const ROW = {
45+
id: REQ, process_name: 'invoice_approval', object_name: 'invoice', record_id: 'inv_1', status: 'pending',
46+
viewer: { can_act: true, is_submitter: false, can_override: false },
47+
decision_progress: { behavior: 'quorum', got: 1, need: 2 },
48+
};
49+
50+
const objectDef = {
51+
name: APPROVAL_REQUEST_OBJECT,
52+
label: 'Approval Request',
53+
nameField: 'process_name',
54+
fields: {
55+
id: { label: 'Id', type: 'text' },
56+
process_name: { label: 'Process', type: 'text' },
57+
status: { label: 'Status', type: 'text' },
58+
// A declared relation, so the page asks for `$expand`, as it will for the real object.
59+
submitter_id: { label: 'Submitter', type: 'lookup', reference: 'sys_user' },
60+
},
61+
};
62+
63+
type Answer = 'ok' | 'forbidden' | 'serverError' | 'notFound';
64+
let answer: Answer = 'ok';
65+
let getReads: string[] = [];
66+
67+
function json(status: number, body: unknown): Response {
68+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
69+
}
70+
71+
/** The approvals get, answered as objectstack's route answers it. */
72+
const approvalsTransport = vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
73+
const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
74+
getReads.push(url.pathname + url.search);
75+
switch (answer) {
76+
case 'forbidden': return json(403, { code: 'FORBIDDEN', error: 'not a party to this request' });
77+
case 'serverError': return json(500, { code: 'APPROVAL_REQUEST_GET_FAILED', error: 'db down' });
78+
case 'notFound': return json(404, { code: 'REQUEST_NOT_FOUND', error: `Approval request '${REQ}' not found` });
79+
default: return json(200, ROW);
80+
}
81+
});
82+
83+
/** The page's own global-`fetch` side reads; never a real socket. */
84+
vi.stubGlobal('fetch', vi.fn(async () => json(200, { success: true, data: [] })));
85+
86+
/** The console's adapter, doubled: the page's side reads land here. */
87+
const host = {
88+
find: vi.fn(async (_resource: string, _params?: unknown) => ({ data: [], total: 0 })),
89+
findOne: vi.fn(async () => null),
90+
create: vi.fn(), update: vi.fn(), delete: vi.fn(),
91+
getObjectSchema: vi.fn(async () => objectDef),
92+
};
93+
94+
const EN: I18nConfig = { defaultLanguage: 'en', detectBrowserLanguage: false };
95+
96+
function openRequestPage() {
97+
const ds = createApprovalRequestsDataSource({
98+
host: host as unknown as DataSource,
99+
scope: { kind: 'awaiting_me', approverIds: ['u1'] },
100+
fetch: approvalsTransport,
101+
baseUrl: BASE,
102+
});
103+
const metadata = {
104+
objects: [objectDef], pages: [], loading: false, error: null,
105+
refresh: async () => {}, invalidate: () => {},
106+
ensureType: async () => [], getItem: async () => null, getItemsByType: () => [],
107+
} as never;
108+
render(
109+
<I18nProvider config={EN} persistLanguage={false}>
110+
<MemoryRouter initialEntries={[`/apps/demo/${APPROVAL_REQUEST_OBJECT}/record/${REQ}`]}>
111+
<MetadataCtx.Provider value={metadata}>
112+
<RecordDetailView
113+
dataSource={ds as never}
114+
objects={[objectDef] as never}
115+
onEdit={() => {}}
116+
objectNameOverride={APPROVAL_REQUEST_OBJECT}
117+
recordIdOverride={REQ}
118+
/>
119+
</MetadataCtx.Provider>
120+
</MemoryRouter>
121+
</I18nProvider>,
122+
);
123+
}
124+
125+
const NOT_FOUND = 'Record not found';
126+
127+
afterEach(() => {
128+
cleanup();
129+
answer = 'ok';
130+
getReads = [];
131+
});
132+
133+
describe('the record page over the routed approvals source (objectui#12032)', () => {
134+
it('a 403 renders the no-access state, never not-found', async () => {
135+
answer = 'forbidden';
136+
openRequestPage();
137+
const state = await screen.findByTestId('record-access-denied', undefined, { timeout: 5000 });
138+
expect(state).toHaveTextContent('You don’t have access to Approval Request records');
139+
expect(screen.queryAllByText(NOT_FOUND)).toHaveLength(0);
140+
});
141+
142+
it('a 500 renders the retryable load error, never not-found', async () => {
143+
answer = 'serverError';
144+
openRequestPage();
145+
const state = await screen.findByTestId('record-load-failed', undefined, { timeout: 5000 });
146+
expect(state).toHaveTextContent('Couldn’t load this record');
147+
expect(screen.getByRole('button', { name: /Retry/ })).toBeInTheDocument();
148+
expect(screen.queryAllByText(NOT_FOUND)).toHaveLength(0);
149+
});
150+
151+
it('control: a 404 still renders not-found', async () => {
152+
answer = 'notFound';
153+
openRequestPage();
154+
expect(await screen.findByText(NOT_FOUND, undefined, { timeout: 5000 })).toBeInTheDocument();
155+
expect(screen.queryByTestId('record-access-denied')).toBeNull();
156+
expect(screen.queryByTestId('record-load-failed')).toBeNull();
157+
});
158+
159+
it('control: a found request renders, read once from the get route with no query', async () => {
160+
answer = 'ok';
161+
openRequestPage();
162+
expect((await screen.findAllByText('invoice_approval', undefined, { timeout: 5000 })).length).toBeGreaterThan(0);
163+
await waitFor(() => expect(screen.queryByTestId('record-load-failed')).toBeNull());
164+
expect(new Set(getReads)).toEqual(new Set([`/api/v1/approvals/requests/${REQ}`]));
165+
// The page's side reads went to the host, not to the approvals route.
166+
expect(host.find).toHaveBeenCalled();
167+
expect(host.find.mock.calls.every(([resource]) => resource !== APPROVAL_REQUEST_OBJECT)).toBe(true);
168+
});
169+
});

0 commit comments

Comments
 (0)