Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/12032-approval-requests-data-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions .changeset/12045-approval-decision-panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 10 additions & 0 deletions .changeset/12045-request-record-surfaces.md
Original file line number Diff line number Diff line change
@@ -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.
103 changes: 51 additions & 52 deletions apps/console/src/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })));
Expand Down Expand Up @@ -294,57 +296,52 @@ export const systemRoutes = (
</>
);

/** One routed adapter per console adapter, keyed on the adapter object itself. */
const recordReadsByAdapter = new WeakMap<object, unknown>();

function approvalRecordReadsFor<T extends DataSource>(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<DataSource | null>(
() =>
adapter
? createApprovalRequestsDataSource({ host: adapter, scope: { kind: 'all' } })
: null,
[adapter],
);
return (
<RecordDetailView
dataSource={dataSource}
objects={objects}
objectNameOverride={APPROVAL_REQUEST_OBJECT}
onEdit={() => navigate('edit')}
/>
<AdapterCtx.Provider value={adapter ? approvalRecordReadsFor(adapter) : null}>
{children}
</AdapterCtx.Provider>
);
}

// Exported for `services/approvalRequestsDataSource.recordRoute.test.tsx`,
// which mounts this exact fragment beside the generic record route.
export const approvalRequestRoutes = (
<Route path={`${APPROVAL_REQUEST_OBJECT}/record/:recordId`} element={<ApprovalRequestRecordRoute />} />
);

export function AppContent() {
const serverUrl = import.meta.env.VITE_SERVER_URL || '';
const endpoint = `${serverUrl}/api/v1/auth/me/permissions`;
Expand Down Expand Up @@ -380,10 +377,12 @@ export function AppContent() {
errorFallback={(err, retry) => <LoadingScreen error={err.message} onRetry={retry} />}
>
<LocalizationFetchProvider endpoint={localizationEndpoint}>
<DefaultAppContent
extraRoutes={<>{systemRoutes}{approvalRequestRoutes}</>}
extraRoutesNoApp={systemRoutes}
/>
<ApprovalRecordReads>
<DefaultAppContent
extraRoutes={systemRoutes}
extraRoutesNoApp={systemRoutes}
/>
</ApprovalRecordReads>
</LocalizationFetchProvider>
{/* objectui#11758 — asks an administrator once to set a still-default
workspace timezone. Here, inside `MePermissionsProvider`, because
Expand Down
99 changes: 99 additions & 0 deletions apps/console/src/__tests__/AppContent.approvalRecordReads.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
DefaultAppContent: function DefaultAppContentProbe() {
shellAdapter = useAdapter();
return null;
},
}));
vi.mock('@object-ui/permissions', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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(
<AdapterCtx.Provider value={host as never}>
<AppContent />
</AdapterCtx.Provider>,
);
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(
<AdapterCtx.Provider value={host as never}>
<AppContent />
</AdapterCtx.Provider>,
);
const adapter = shellAdapter as typeof host;
await expect(adapter.findOne('invoice', 'inv_1')).resolves.toEqual({ id: 'inv_1', from: 'host' });
expect(wire).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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',
});
Expand Down Expand Up @@ -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 <RecordDetailView dataSource={useAdapter()} objects={metadata.objects as never} onEdit={() => {}} />;
}

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(
<I18nProvider config={EN} persistLanguage={false}>
<MemoryRouter initialEntries={[`/${APPROVAL_REQUEST_OBJECT}/record/${REQ}`]}>
<MetadataCtx.Provider value={metadata as never}>
<AdapterCtx.Provider value={host as never}>
<Routes>
<Route path=":objectName/record/:recordId" element={<div data-testid="generic-record-route" />} />
{approvalRequestRoutes}
</Routes>
<ApprovalRecordReads>
<Routes>
<Route path=":objectName/record/:recordId" element={<GenericRecordRoute />} />
</Routes>
</ApprovalRecordReads>
</AdapterCtx.Provider>
</MetadataCtx.Provider>
</MemoryRouter>
</I18nProvider>,
);
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(
<MemoryRouter initialEntries={['/invoice/record/inv_1']}>
<Routes>
<Route path=":objectName/record/:recordId" element={<div data-testid="generic-record-route" />} />
{approvalRequestRoutes}
</Routes>
</MemoryRouter>,
<I18nProvider config={EN} persistLanguage={false}>
<MemoryRouter initialEntries={[`/${APPROVAL_REQUEST_OBJECT}/record/${REQ}`]}>
<MetadataCtx.Provider value={metadata as never}>
<AdapterCtx.Provider value={host as never}>
<Routes>
<Route path=":objectName/record/:recordId" element={<GenericRecordRoute />} />
</Routes>
</AdapterCtx.Provider>
</MetadataCtx.Provider>
</MemoryRouter>
</I18nProvider>,
);
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);
});
});
Loading
Loading