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
13 changes: 13 additions & 0 deletions .changeset/12342-register-approval-decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/app-shell': minor
'@object-ui/types': minor
'@object-ui/cli': patch
---

The page block `record:approval_decision` renders (objectui#12342). `@objectstack/spec` 18 declares it, and until now a page naming it passed the spec and drew nothing, because no registration mounted the decision panel.

- **Registered (`@object-ui/app-shell`).** The package entry registers `ApprovalDecisionRenderer` as `record:approval_decision` (no bare twin), from the new side-effect leaf `views/record-approval-decision-renderer.tsx`, which joins the package's `sideEffects` array. On an approval request's record page the node draws the request's decision progress above its declared decision actions; off a request page it draws the localized notice it already drew. The renderer is still not exported from the package entry: a page reaches it by type.
- **Armed (`@object-ui/types`).** `RecordApprovalDecisionBlockSchema` (zod, from `@object-ui/types/zod`) and its TypeScript twin of the same name (from `@object-ui/types`) are new, and `AnyComponentSchema` gains the arm, so `safeValidateSchema` and the strict face accept a `record:approval_decision` node. Its `properties` bag is the spec's empty strict row, restated because the spec exports the row under no name: the only bag it accepts is `{}`, and a node-level `children` or `body` is refused by name.
- **`objectui check` and `objectui validate` (`@object-ui/cli`).** The generated list of registered types gains `record:approval_decision`, and `objectui validate` no longer refuses the type at `type`.

Studio's block palette still does not offer the block; its exclusion reason now says it renders.
Original file line number Diff line number Diff line change
Expand Up @@ -977,7 +977,7 @@ const UNJUDGED_SPEC_BLOCKS: Record<string, string> = {
'mcp:connect-agent':
'EMPTY SPEC SHAPE. New in `@objectstack/spec` 17.3.0, which declares it with no top-level key at all. Registered propless by `@object-ui/app-shell` (`src/console/connect/ConnectAgentWidget.tsx`), outside this file\'s import graph; the empty shape is the load-bearing half either way. objectui#7122, ledger objectui#8176.',
'record:approval_decision':
'EMPTY SPEC SHAPE. New in `@objectstack/spec` 18 (objectui#12030), which declares it with no top-level key at all, so neither direction has a question to ask. `ApprovalDecisionRenderer` (`@object-ui/app-shell` `src/views/approval-decision/ApprovalDecisionPanel.tsx`) is written, and no registration mounts it yet; the empty shape is the load-bearing half either way. objectui#12030, ledger objectui#8176.',
'EMPTY SPEC SHAPE. New in `@objectstack/spec` 18 (objectui#12030), which declares it with no top-level key at all. Registered by `@object-ui/app-shell` (`src/views/record-approval-decision-renderer.tsx`, objectui#12342), outside this file\'s import graph; the empty shape is the load-bearing half either way, exactly as for `app:launcher` above. objectui#12030, ledger objectui#8176.',
'record:approvals':
'EMPTY SPEC SHAPE. New in `@objectstack/spec` 18 (objectui#12030), which declares it with no top-level key at all. Registered by `@object-ui/app-shell` (`src/views/record-approvals-renderer.tsx`), outside this file\'s import graph; the empty shape is the load-bearing half either way, exactly as for `app:launcher` above. objectui#12030, ledger objectui#8176.',
'record:attachments':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,11 @@
* plugin-approvals: a `kind: 'slotted'` record page for `sys_approval_request`
* whose `actions` slot holds the decision panel and whose discussion is empty.
*
* The panel is module-internal this round (claim amendment 3 on objectui#12045,
* `6081385696`): nothing in source registers it, because a registered type
* needs a zod arm derived from the v18 spec row (objectstack-ai/objectstack#22472).
* So this file registers the shipped renderer itself, under the proposed name,
* before anything renders, as the page-mount step's registration will.
* Everything else on it is synthesized from the object, as on a served page.
* The panel is the page block `record:approval_decision`, and this file does
* NOT register it: the registration is the one `@object-ui/app-shell` ships
* (objectui#12342), reached through the package entry this file imports, so a
* page that names the block draws the panel only because the package registers
* it. Everything else on it is synthesized from the object, as on a served page.
* Only the HTTP transport and the console adapter are doubled, so what the page
* reads, and from where, is what the shipped code sends:
*
Expand All @@ -31,7 +30,7 @@
* pins that the request's record page is the routed one.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor, fireEvent, within } from '@testing-library/react';
import { render, screen, cleanup, waitFor, fireEvent, within, act } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';

vi.mock('@object-ui/auth', async (importOriginal) => ({
Expand Down Expand Up @@ -63,16 +62,9 @@ import { approvalRequestRoutes } from '../AppContent';
import { PageSchema } from '@objectstack/spec/ui';
import { ComponentRegistry } from '@object-ui/core';

// The shipped renderer, from its module (the package entry does not export it),
// registered here under the proposed type name; see the header.
const { ApprovalDecisionRenderer } = await vi.importActual<{
ApprovalDecisionRenderer: Parameters<typeof ComponentRegistry.register>[1];
}>('../../../../packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel');
ComponentRegistry.register('approval_decision', ApprovalDecisionRenderer, { namespace: 'record', skipFallback: true });

const REQ = 'req_12045';
const VIEWER = { can_act: true, is_submitter: false, can_override: false };
const ROW = {
const PENDING_ROW = {
id: REQ,
process_name: 'invoice_approval',
object_name: 'invoice',
Expand All @@ -82,6 +74,25 @@ const ROW = {
viewer: VIEWER,
decision_progress: { behavior: 'quorum', got: 1, need: 2 },
};
/**
* The same request once decided, as the approvals get route serves it: the
* server clears the pending approvers and the tally, and the viewer may no
* longer act.
*/
const DECIDED_ROW = {
...PENDING_ROW,
status: 'approved',
pending_approvers: [],
viewer: { can_act: false, is_submitter: false, can_override: false },
decision_progress: undefined,
};
/** The pending request, read by its submitter: a viewer who may not decide. */
const SUBMITTER_ROW = {
...PENDING_ROW,
viewer: { can_act: false, is_submitter: true, can_override: false },
};
/** The request row the approvals get route serves in the current test. */
let ROW: Record<string, unknown> = PENDING_ROW;
const TIMELINE = [
{ id: 'act_1', request_id: REQ, action: 'submit', comment: 'Please review the laptop order', created_at: '2026-10-01T09:00:00Z' },
{ id: 'act_2', request_id: REQ, action: 'approve', comment: 'Budget checked', created_at: '2026-10-02T09:00:00Z' },
Expand Down Expand Up @@ -116,6 +127,14 @@ const REQUEST_DEF = {
target: '/api/v1/approvals/requests/{id}/reject', params: DECISION_PARAMS,
visible: DECIDE, locations: ['record_section', 'list_item'], refreshAfter: true,
},
{
// The submitter's own control on a pending request: drawn by the same bar,
// so a viewer who may not decide still sees the bar evaluate its actions.
name: 'approval_recall', label: 'Recall', type: 'api', method: 'POST',
target: '/api/v1/approvals/requests/{id}/recall',
visible: { dialect: 'cel', source: "record.viewer.is_submitter && record.status == 'pending'" },
locations: ['record_section'], refreshAfter: true,
},
],
};
const ACTION_DEF = {
Expand Down Expand Up @@ -207,7 +226,7 @@ const metadata = {
apps: [], objects: [REQUEST_DEF, ACTION_DEF, INVOICE_DEF], dashboards: [], reports: [],
pages: [REQUEST_PAGE], loading: false, error: null,
refresh: async () => {}, invalidate: () => {}, ensureType: async () => [],
getItem: async (type: string, name: string) => (type === 'object' ? (DEFS[name] ?? null) : null),
getItem: vi.fn(async (type: string, name: string) => (type === 'object' ? (DEFS[name] ?? null) : null)),
getItemsByType: () => [],
getTypeStatus: () => 'ready' as const,
};
Expand Down Expand Up @@ -244,6 +263,7 @@ const requestGets = () => wire.filter((w) => w.method === 'GET' && w.path === `/

afterEach(() => {
cleanup();
ROW = PENDING_ROW;
wire = [];
host.find.mockClear();
host.findOne.mockClear();
Expand All @@ -255,6 +275,10 @@ describe("an approval request's record page with record:approval_decision (objec
expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true);
});

it('the block is registered by the package this file imports, not by this file (objectui#12342)', () => {
expect(ComponentRegistry.has('record:approval_decision')).toBe(true);
});

it("the decision panel draws the request's tally and its declared decisions, gated by the served viewer block", async () => {
openRequestPage();
const panel = await screen.findByTestId('approval-decision-panel', undefined, { timeout: 8000 });
Expand Down Expand Up @@ -314,6 +338,51 @@ describe("an approval request's record page with record:approval_decision (objec
});
});

/** The request's declared actions have reached the page: the object read resolved and the page settled. */
async function settledOnDeclaredActions() {
await waitFor(
() => expect(metadata.getItem).toHaveBeenCalledWith('object', APPROVAL_REQUEST_OBJECT),
{ timeout: 8000 },
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
}

describe('record:approval_decision on a decided request, and for a viewer who may not decide (objectui#12342)', () => {
it('a decided request: the panel draws, with no tally and no decision', async () => {
ROW = DECIDED_ROW;
openRequestPage();
const panel = await screen.findByTestId('approval-decision-panel', undefined, { timeout: 8000 });
await settledOnDeclaredActions();
expect(within(panel).queryByRole('progressbar'), 'a decided request carries no tally').toBeNull();
expect(within(panel).queryByTestId('declared-action-approval_approve')).toBeNull();
expect(within(panel).queryByTestId('declared-action-approval_reject')).toBeNull();
expect(within(panel).queryByTestId('declared-action-approval_recall'), 'recall is for a pending request').toBeNull();
expect(requestGets().length).toBeGreaterThan(0);
});

it('a viewer who may not decide (the submitter): the tally and the recall, and no decision', async () => {
ROW = SUBMITTER_ROW;
openRequestPage();
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');
// The bar evaluated the request's actions for this viewer: the submitter's own control is drawn.
expect(await within(panel).findByTestId('declared-action-approval_recall', undefined, { timeout: 8000 })).toBeTruthy();
expect(within(panel).queryByTestId('declared-action-approval_approve')).toBeNull();
expect(within(panel).queryByTestId('declared-action-approval_reject')).toBeNull();
});

it('CONTROL: the viewer who may decide sees both decisions and no recall', async () => {
openRequestPage();
const panel = await screen.findByTestId('approval-decision-panel', undefined, { timeout: 8000 });
expect(await within(panel).findByTestId('declared-action-approval_approve', undefined, { timeout: 8000 })).toBeTruthy();
expect(within(panel).getByTestId('declared-action-approval_reject')).toBeTruthy();
expect(within(panel).queryByTestId('declared-action-approval_recall')).toBeNull();
});
});

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 () => {
render(
Expand Down
20 changes: 20 additions & 0 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,26 @@ submitter's remind posts the existing `POST /approvals/requests/:id/remind`
(throttled server-side). Copy reuses the Approval Center's
`approvalsInbox.*` i18n keys so the two surfaces never drift.

## Approval request decision panel (`record:approval_decision`)

An approval request's own record page (`sys_approval_request`) decides the
request through the `record:approval_decision` block, which this package
registers:

```json
{ "type": "record:approval_decision" }
```

The block takes no props — its `properties` bag accepts only `{}`. It draws
from the request record the page binds: the server-computed decision progress
(when the request is pending and aggregates more than one decision) above the
request's own declared decision actions at `record_section`, each shown or
hidden by its `visible` predicate over the request's `viewer` block. A decided
request, or a viewer who may not decide, gets no decision buttons. After a
decision the request record and its timeline are re-read in place. Placed on
any other page, the block draws a short notice that it works only on approval
request pages. `className` styles the wrapper.

## User-scoped state (favorites, recent items)

`<ConsoleShell>` includes `FavoritesProvider` and `RecentItemsProvider` —
Expand Down
2 changes: 2 additions & 0 deletions packages/app-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"./dist/views/global-search-renderer.js",
"./dist/views/metadata-admin/register-builtins.js",
"./dist/views/nav-menu-renderer.js",
"./dist/views/record-approval-decision-renderer.js",
"./dist/views/record-approvals-renderer.js",
"./dist/views/record-attachments-renderer.js",
"./dist/views/record-preview/record-pointer-card-renderer.js",
Expand All @@ -35,6 +36,7 @@
"./src/views/global-search-renderer.tsx",
"./src/views/metadata-admin/register-builtins.ts",
"./src/views/nav-menu-renderer.tsx",
"./src/views/record-approval-decision-renderer.tsx",
"./src/views/record-approvals-renderer.tsx",
"./src/views/record-attachments-renderer.tsx",
"./src/views/record-preview/record-pointer-card-renderer.tsx",
Expand Down
3 changes: 3 additions & 0 deletions packages/app-shell/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,9 @@ import './views/record-attachments-renderer.js';
// `record:approvals` — schema-addressable approval panel referenced by
// synthesized record pages when the record has approval requests (#3461).
import './views/record-approvals-renderer.js';
// `record:approval_decision` — an approval request page's decision panel, the
// third record block `@objectstack/spec` 18 declares (objectui#12342).
import './views/record-approval-decision-renderer.js';
// A2's record card as the details grid's face for a `referenceVia` pointer
// pair, registered under `@object-ui/fields`' `RECORD_POINTER_CARD_TYPE`
// (objectui#12045).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
/**
* The approval decision panel (objectui#12045, B1 of objectui#2763): how it
* composes A3's progress with the request's declared decision actions over the
* bound record, and that this round registers it as no type (claim amendment 3,
* `6081385696`: the registration lands with its zod arm, derived from the v18
* spec row, in the page-mount step).
* bound record, and that the package registers it as the page block
* `record:approval_decision` `@objectstack/spec` 18 declares (objectui#12342),
* so a page node of that type reaches it through the real `SchemaRenderer`.
*
* The bar is doubled to a probe that records the props it was handed: what is
* under test here is the panel's wiring (which object, which record, which
Expand All @@ -37,10 +37,13 @@ vi.mock('../DeclaredActionsBar.js', () => ({
}));

import { ComponentRegistry } from '@object-ui/core';
import { RecordContextProvider, subscribeDataChanges, type DataChange } from '@object-ui/react';
import { RecordContextProvider, SchemaRenderer, subscribeDataChanges, type DataChange } from '@object-ui/react';
import { I18nProvider } from '@object-ui/i18n';
import { builtInLocales } from '@object-ui/i18n/locales';
import { ApprovalDecisionRenderer } from './ApprovalDecisionPanel';
// The package's registration leaf, imported for its side effect as the
// package entry imports it (objectui#12342).
import '../record-approval-decision-renderer';

afterEach(() => {
cleanup();
Expand Down Expand Up @@ -77,9 +80,21 @@ const packNotice = (language: string): unknown =>
const noticeText = () => screen.getByTestId('approval-decision-off-request-page').textContent;

describe('the approval decision panel (objectui#12045)', () => {
it('is module-internal this round: loading it registers no component type', () => {
expect(ComponentRegistry.has('record:approval_decision')).toBe(false);
expect(ComponentRegistry.has('approval_decision')).toBe(false);
it('is registered as `record:approval_decision`, and under no bare name (objectui#12342)', () => {
expect(ComponentRegistry.has('record:approval_decision')).toBe(true);
expect(ComponentRegistry.get('record:approval_decision')).toBe(ApprovalDecisionRenderer);
expect(ComponentRegistry.has('approval_decision'), 'skipFallback: no bare twin').toBe(false);
});

it('a page node of that type renders the panel through the real SchemaRenderer (objectui#12342)', () => {
render(
<RecordContextProvider objectName="sys_approval_request" recordId={REQUEST.id} data={REQUEST}>
<SchemaRenderer schema={{ type: 'record:approval_decision' }} />
</RecordContextProvider>,
);
expect(screen.getByTestId('approval-decision-panel')).toBeTruthy();
expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe('1');
expect(barProps[barProps.length - 1]).toMatchObject({ objectName: 'sys_approval_request', location: 'record_section' });
});

it("draws the request's tally and runs its declared actions at record_section against the request row", () => {
Expand Down
Loading
Loading