From 3a9b5d52bf7b707940c34fb7aa8ced627146398e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:04:32 +0000 Subject: [PATCH 1/2] feat(app-shell): register record:approval_decision, armed in @object-ui/types (objectui#12342) Spec 18 declares the block; the decision panel was written but no registration mounted it, so a page naming it passed the spec and drew nothing. Register it from a side-effect leaf in the app-shell entry, arm it in @object-ui/types/zod from the spec's empty strict row (the namespaced registered-types ratchet never rises), regenerate the CLI's known types, and pin a real render for a pending request, a decided one and a viewer who may not decide. Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- .../12342-register-approval-decision.md | 13 ++ .../registry-inputs-spec-parity.test.ts | 2 +- ...alRequestsDataSource.decisionPage.test.tsx | 101 ++++++++++--- packages/app-shell/README.md | 20 +++ packages/app-shell/package.json | 2 + packages/app-shell/src/index.ts | 3 + .../ApprovalDecisionPanel.test.tsx | 29 +++- .../ApprovalDecisionPanel.tsx | 34 ++--- .../exclusion-reason-truthfulness.test.ts | 1 + .../metadata-admin/previews/block-types.ts | 2 +- .../record-approval-decision-renderer.tsx | 44 ++++++ ...tered-types-validate-ratchet-10859.test.ts | 10 ++ packages/cli/src/utils/known-schema-types.ts | 1 + .../any-component-emit-headroom-11573.test.ts | 1 + ...record-approval-decision-arm-12342.test.ts | 133 ++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 7 +- packages/types/src/index.ts | 11 +- .../types/src/record-approval-decision.ts | 58 ++++++++ packages/types/src/zod/index.zod.ts | 8 ++ .../src/zod/record-approval-decision.zod.ts | 84 +++++++++++ 20 files changed, 518 insertions(+), 46 deletions(-) create mode 100644 .changeset/12342-register-approval-decision.md create mode 100644 packages/app-shell/src/views/record-approval-decision-renderer.tsx create mode 100644 packages/types/src/__tests__/record-approval-decision-arm-12342.test.ts create mode 100644 packages/types/src/record-approval-decision.ts create mode 100644 packages/types/src/zod/record-approval-decision.zod.ts diff --git a/.changeset/12342-register-approval-decision.md b/.changeset/12342-register-approval-decision.md new file mode 100644 index 0000000000..3f13542fcf --- /dev/null +++ b/.changeset/12342-register-approval-decision.md @@ -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. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index f2f81f3dd5..66ea62715c 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -977,7 +977,7 @@ const UNJUDGED_SPEC_BLOCKS: Record = { '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': diff --git a/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx b/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx index dc7e07e533..59373e509a 100644 --- a/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx +++ b/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx @@ -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: * @@ -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) => ({ @@ -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[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', @@ -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 = 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' }, @@ -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 = { @@ -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, }; @@ -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(); @@ -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 }); @@ -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( diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index 8871541409..549a42b53e 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -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) `` includes `FavoritesProvider` and `RecentItemsProvider` — diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index 5c11242433..024be7430b 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -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", @@ -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", diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index f6f73c2dae..498f6b8705 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -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). diff --git a/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx b/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx index 1076f21039..6a28d082e2 100644 --- a/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx +++ b/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx @@ -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 @@ -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(); @@ -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( + + + , + ); + 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", () => { diff --git a/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx b/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx index fe48ddfb6d..8ec18349d8 100644 --- a/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx +++ b/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx @@ -8,8 +8,8 @@ /** * The decision panel of an approval request's record page (objectui#12045, B1 - * of objectui#2763; ruling 6079807016, letter 乙), proposed to the spec as the - * page component type `record:approval_decision`. + * of objectui#2763; ruling 6079807016, letter 乙), the page component type + * `record:approval_decision`. * * One node, two parts, both already shipped and both reused as they are: * @@ -31,10 +31,10 @@ * The panel reads the record context and nothing else: the request row the * page bound (with its `viewer` and `decision_progress`, which the approvals * get route serves and the data API does not) and the object's declared - * actions. objectstack-ai/objectstack#22472 proposes exactly this contract - * for the spec, a `ComponentPropsMap` row that is an empty strict object, so a - * misspelled key on an authored node is a validation finding instead of a - * silently ignored prop. + * actions. `@objectstack/spec` 18 declares exactly this contract + * (objectstack-ai/objectstack#22472), a `ComponentPropsMap` row that is an + * empty strict object, so a misspelled key on an authored node is a + * validation finding instead of a silently ignored prop. * * Outside a `sys_approval_request` record page there is no request to decide * on, so the node draws a short notice saying it works only on approval request @@ -51,20 +51,16 @@ * decision appends a `sys_approval_action` row), both through the * invalidation bus their readers already subscribe to. * - * ## Module-internal until the spec row exists + * ## Registered as `record:approval_decision` * - * Nothing registers this renderer yet, and nothing re-exports it from the - * package entry (claim amendment 3 on objectui#12045, `6081385696`). A - * registered type that page metadata can reference needs an arm in - * `@object-ui/types/zod`, and an arm is derived from the spec's - * `ComponentPropsMap` row, which `@objectstack/spec` 17.7.0 does not carry for - * this type (objectstack-ai/objectstack#22472 adds it on v18). Registering it - * without one is what `registered-types-validate-ratchet-10859` in - * `@object-ui/cli` refuses. So the registration under the name the spec seat - * settles, its side-effect import from `src/index.ts`, its - * `known-schema-types.ts` line and its zod arm land together in the page-mount - * step. Until then spec 17.7.0's lint and `objectui validate` both refuse an - * authored node of this type, consistently. + * `@objectstack/spec` 18 declares the type and its `ComponentPropsMap` row + * (an empty strict object). The registration lives in the leaf + * `../record-approval-decision-renderer.tsx` (objectui#12342), imported for + * its side effect from `src/index.ts`, and it landed together with the type's + * arm in `@object-ui/types/zod` and its `known-schema-types.ts` line, so + * `registered-types-validate-ratchet-10859` in `@object-ui/cli` does not rise. + * Nothing re-exports the renderer from the package entry: a page reaches it + * through the registry, by type. * * It lives in app-shell rather than plugin-detail for the reason * `record:approvals` does: the bar's runtime depends on `@object-ui/auth`, diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/exclusion-reason-truthfulness.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/exclusion-reason-truthfulness.test.ts index bb79ec4d0f..6b0e607091 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/exclusion-reason-truthfulness.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/exclusion-reason-truthfulness.test.ts @@ -116,6 +116,7 @@ import '../../../app-launcher-renderer.js'; import '../../../global-notifications-renderer.js'; import '../../../global-search-renderer.js'; import '../../../nav-menu-renderer.js'; +import '../../../record-approval-decision-renderer.js'; import '../../../record-approvals-renderer.js'; import '../../../record-attachments-renderer.js'; import { PALETTE_EXCLUSIONS } from '../block-types'; diff --git a/packages/app-shell/src/views/metadata-admin/previews/block-types.ts b/packages/app-shell/src/views/metadata-admin/previews/block-types.ts index 6c034d2c38..9160a90bb8 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/block-types.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/block-types.ts @@ -239,7 +239,7 @@ export const PALETTE_EXCLUSIONS: Record = { // true of the block today. 'record:approvals': 'renders (registered by `@object-ui/app-shell`), not yet offered: `@objectstack/spec` 18 added the type, and offering it in the palette is a Studio decision the version bump does not make', 'record:attachments': 'renders (registered by `@object-ui/app-shell`), not yet offered: `@objectstack/spec` 18 added the type, and offering it in the palette is a Studio decision the version bump does not make', - 'record:approval_decision': 'not yet registered as a page block — `ApprovalDecisionRenderer` (`@object-ui/app-shell`) is written and no registration mounts it yet; `@objectstack/spec` 18 added the type, and registering and offering it is follow-up work', + 'record:approval_decision': 'renders (registered by `@object-ui/app-shell`), not yet offered: `@objectstack/spec` 18 added the type, and offering it in the palette is a Studio decision its registration does not make', }; /** diff --git a/packages/app-shell/src/views/record-approval-decision-renderer.tsx b/packages/app-shell/src/views/record-approval-decision-renderer.tsx new file mode 100644 index 0000000000..625854f2a2 --- /dev/null +++ b/packages/app-shell/src/views/record-approval-decision-renderer.tsx @@ -0,0 +1,44 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `record:approval_decision` — the decision panel of an approval request's + * record page, registered as the page block `@objectstack/spec` 18 declares + * (objectui#12342). The renderer itself is `ApprovalDecisionRenderer` + * (`./approval-decision/ApprovalDecisionPanel.tsx`, objectui#12045); this leaf + * only registers it, the way `record:approvals` and `record:attachments` + * register their panels. + * + * The spec's `ComponentPropsMap['record:approval_decision']` row is an empty + * strict object: the panel draws from the record context alone (the request + * row the page binds, with its `viewer` and `decision_progress`) and from + * `sys_approval_request`'s own declared actions. So the registration declares + * no input but `className`, a node-level key. Its arm in + * `@object-ui/types/zod` (`RecordApprovalDecisionBlockSchema`) landed with + * this registration, so `objectui validate` accepts a node of this type. + * + * Registered in app-shell rather than plugin-detail for the reason + * `record:approvals` is: the decision bar's runtime depends on + * `@object-ui/auth`, which plugin-detail deliberately does not pull in. The + * side-effect registration is imported from the app-shell barrel + * (`src/index.ts`), and this file is in the package's `sideEffects` array. + */ + +import { ComponentRegistry } from '@object-ui/core'; +import { ApprovalDecisionRenderer } from './approval-decision/ApprovalDecisionPanel.js'; + +ComponentRegistry.register('approval_decision', ApprovalDecisionRenderer, { + namespace: 'record', + skipFallback: true, + category: 'record', + label: 'Approval Decision', + icon: 'Gavel', + inputs: [{ name: 'className', type: 'string' }], +}); + +export default ApprovalDecisionRenderer; diff --git a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts index 4a551547fa..244e428bb8 100644 --- a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts +++ b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts @@ -620,6 +620,16 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet expect(refusedAtType('cloud:workspace-timezone-notice')).toBe(false); }); + it('counts `record:approval_decision` armed — it registered WITH its arm (objectui#12342)', () => { + // One registry key (`skipFallback: true`, so no bare `approval_decision`), + // armed in `@object-ui/types/zod` in the same change, so the pin above did not move. + // Its two spec-18 siblings, `record:approvals` and `record:attachments`, are + // registered without arms and stay inside the namespaced count. + expect(NAMESPACED_KEYS).toContain('record:approval_decision'); + expect(BARE_KEYS).not.toContain('approval_decision'); + expect(refusedAtType('record:approval_decision')).toBe(false); + }); + it('leaves a newly registered `field:` widget out of the count (objectui#12176)', () => { // A field widget registers with no arm, as every one does. Lit control: the // probe is refused at `type`, so it would count if it reached the diff --git a/packages/cli/src/utils/known-schema-types.ts b/packages/cli/src/utils/known-schema-types.ts index 5ce17481c4..f4cdbd7bbf 100644 --- a/packages/cli/src/utils/known-schema-types.ts +++ b/packages/cli/src/utils/known-schema-types.ts @@ -387,6 +387,7 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'record', 'record:activity', 'record:alert', + 'record:approval_decision', 'record:approvals', 'record:attachments', 'record:chatter', diff --git a/packages/types/src/__tests__/any-component-emit-headroom-11573.test.ts b/packages/types/src/__tests__/any-component-emit-headroom-11573.test.ts index 44934b8fbb..8be8d07786 100644 --- a/packages/types/src/__tests__/any-component-emit-headroom-11573.test.ts +++ b/packages/types/src/__tests__/any-component-emit-headroom-11573.test.ts @@ -315,6 +315,7 @@ describe('`AnyComponentSchema` declaration emit headroom (objectui#11573)', () = { member: 'PublicBlockComponentSchema', kind: 'category union', printsAs: 'PublicBlockComponentZodType' }, { member: 'CloudPlanStatusSchema', kind: 'arm', printsAs: 'inline' }, { member: 'CloudWorkspaceTimezoneNoticeSchema', kind: 'arm', printsAs: 'inline' }, + { member: 'RecordApprovalDecisionBlockSchema', kind: 'arm', printsAs: 'inline' }, ]); }); diff --git a/packages/types/src/__tests__/record-approval-decision-arm-12342.test.ts b/packages/types/src/__tests__/record-approval-decision-arm-12342.test.ts new file mode 100644 index 0000000000..aa9c908ae9 --- /dev/null +++ b/packages/types/src/__tests__/record-approval-decision-arm-12342.test.ts @@ -0,0 +1,133 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12342 — the `record:approval_decision` arm + * (`../zod/record-approval-decision.zod.ts`). + * + * `@object-ui/app-shell` registers `record:approval_decision`, the block + * `@objectstack/spec` 18 declares, and this arm lands with it so + * `AnyComponentSchema` claims the type instead of refusing it at `type`. Its + * props bag restates the spec's unnamed row, so the row is read here from the + * INSTALLED spec on every run: the arm's key set and its accept set must equal + * the row's. + * + * Refusals are asserted by issue `code` and `path` — the envelope a consumer + * reads — and not by message wording. + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; + +import { + AnyComponentSchema, + RecordApprovalDecisionBlockSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; + +const TYPE = 'record:approval_decision'; + +/** The spec's row, as the published map carries it. */ +const ROW = (ComponentPropsMap as unknown as Record)[TYPE]; + +/** The arm's `properties` member with its `.optional()` peeled off. */ +const BAG = (RecordApprovalDecisionBlockSchema.shape.properties as unknown as z.ZodOptional).unwrap() as z.ZodType; + +const keysOf = (schema: z.ZodType): string[] => + Object.keys((schema as unknown as { shape?: Record }).shape ?? {}).sort(); + +/** The issues of a refused parse, as `code` + dotted `path`. */ +function issuesOf(document: unknown): { code: string; path: string; message: string }[] { + const result = safeValidateSchema(document); + expect(result.success, 'expected a refusal').toBe(false); + if (result.success) return []; + return result.error.issues.map((issue) => ({ + code: issue.code, + path: issue.path.join('.'), + message: issue.message, + })); +} + +describe('`record:approval_decision` — the bag is the spec row (objectui#12342)', () => { + it('the installed spec carries the row, and it declares no key', () => { + expect(ROW, 'ComponentPropsMap has no record:approval_decision row').toBeDefined(); + expect(keysOf(ROW)).toEqual([]); + }); + + it("the arm's bag declares the row's key set", () => { + expect(keysOf(BAG)).toEqual(keysOf(ROW)); + }); + + it.each([ + ['the empty bag', {}], + ['a bag with a key', { request: 'req_1' }], + ['a bag that is not an object', 'req_1'], + ])('accepts or refuses %s exactly as the row does', (_case, bag) => { + expect(BAG.safeParse(bag).success).toBe(ROW.safeParse(bag).success); + }); + + it('CONTROL: the row itself accepts `{}` and refuses a key', () => { + expect(ROW.safeParse({}).success).toBe(true); + expect(ROW.safeParse({ request: 'req_1' }).success).toBe(false); + }); +}); + +describe('`record:approval_decision` — the node a request page authors (objectui#12342)', () => { + it('accepts the bare node on every face', () => { + const node = { type: TYPE }; + expect(RecordApprovalDecisionBlockSchema.safeParse(node).success).toBe(true); + expect(safeValidateSchema(node).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(node).success).toBe(true); + }); + + it('accepts an empty `properties` bag and the node-level keys a page writes', () => { + const node = { type: TYPE, id: 'decision', className: 'mt-2', properties: {} }; + expect(safeValidateSchema(node).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(node).success).toBe(true); + }); + + it('is claimed at `type` — the union no longer refuses the literal', () => { + // The ratchet's reading (`registered-types-validate-ratchet-10859.test.ts` in `@object-ui/cli`). + expect(safeValidateSchema({ type: TYPE }).success).toBe(true); + // Control: an unclaimed `record:` literal IS refused there. + const unclaimed = issuesOf({ type: 'record:no_such_block_12342' }); + expect(unclaimed.some((i) => i.code === 'invalid_union' && i.path === 'type')).toBe(true); + }); +}); + +describe('`record:approval_decision` — what the arm refuses (objectui#12342)', () => { + it('refuses any key inside the bag — the row declares none', () => { + expect(issuesOf({ type: TYPE, properties: { request: 'req_1' } })).toEqual([ + expect.objectContaining({ code: 'unrecognized_keys', path: 'properties' }), + ]); + }); + + it('refuses a flat key the renderer does not read, on the strict face', () => { + const strict = StrictAnyComponentSchema.safeParse({ type: TYPE, request: 'req_1' }); + expect(strict.success).toBe(false); + expect(strict.error?.issues.map((i) => i.code)).toContain('unrecognized_keys'); + }); + + it.each(['children', 'body'])('refuses `%s` by name — the panel reads neither content channel', (key) => { + const issues = issuesOf({ type: TYPE, [key]: [{ type: 'text', content: 'x' }] }); + expect(issues).toEqual([expect.objectContaining({ code: 'invalid_type', path: key })]); + }); + + it('reaches the arm through the union, not a second schema', () => { + const document = { type: TYPE, properties: { request: 'req_1' } }; + const bare = RecordApprovalDecisionBlockSchema.safeParse(document); + const viaUnion = AnyComponentSchema.safeParse(document); + expect(bare.success).toBe(false); + expect(viaUnion.success).toBe(false); + expect(viaUnion.error?.issues.map((i) => [i.code, i.path.join('.')])).toEqual( + bare.error?.issues.map((i) => [i.code, i.path.join('.')]), + ); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 5fe0f8f22c..1a6046109a 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -949,6 +949,7 @@ import { AIConfigSchema, AIFieldSuggestionSchema, AIFormAssistSchema, AIRecommen import { AppComponentSchema, AppSchemaRendererNodeSchema, MenuItemSchema as AppMenuItemSchema, NavigationAreaSchema, NavigationItemSchema } from '../zod/app.zod.js'; import { BaseSchema, ComponentConfigSchema, ComponentInputSchema, ComponentMetaSchema, KeyedI18nLabelSchema, SchemaNodeSchema } from '../zod/base.zod.js'; import { CloudPlanStatusSchema, CloudWorkspaceTimezoneNoticeSchema } from '../zod/cloud.zod.js'; +import { RecordApprovalDecisionBlockSchema } from '../zod/record-approval-decision.zod.js'; import { CalendarEventSchema, CalendarViewSchema, CarouselItemSchema, CarouselSchema, ChatbotSchema, ChatbotEnhancedSchema, ChatbotFloatingSchema, ChatMessageSchema, ChatMessageSourceSchema, ChatToolInvocationSchema, DashboardComponentSchema, DashboardConfigSchema, DashboardWidgetConfigSchema, DashboardWidgetLayoutSchema, DashboardWidgetSchema, FilterBuilderSchema, FilterFieldSchema, KanbanCardSchema, KanbanColumnSchema, CardTemplateSchema, FilterGroupSchema } from '../zod/complex.zod.js'; import { ActionSchema, CRUDDialogSchema, DetailSchema } from '../zod/crud.zod.js'; import { AlertSchema, AvatarSchema, BadgeSchema, BarChartSchema, ChartDataSeriesSchema, ChartSchema, DataTableSchema, DrillDownConfigSchema, HtmlSchema, KbdSchema, ListItemSchema, ListSchema, MarkdownSchema, PivotTableSchema, StaticTableColumnSchema, StatisticSchema, TableColumnSchema, TableSchema, TimelineEventSchema, TimelineFeedItemSchema, TimelineGanttItemBarSchema, TimelineGanttItemSchema, TimelineSchema, TreeNodeSchema, TreeViewSchema } from '../zod/data-display.zod.js'; @@ -967,6 +968,7 @@ import type { AIConfig as Ts_AIConfig, AIFieldSuggestion as Ts_AIFieldSuggestion import type { AppComponentSchema as Ts_AppComponentSchema, AppSchemaRendererNodeSchema as Ts_AppSchemaRendererNodeSchema, NavigationArea as Ts_NavigationArea } from '../app'; import type { BaseSchema as Ts_BaseSchema, ComponentConfig as Ts_ComponentConfig, ComponentInput as Ts_ComponentInput, ComponentMeta as Ts_ComponentMeta, KeyedI18nLabel as Ts_KeyedI18nLabel } from '../base'; import type { CloudPlanStatusSchema as Ts_CloudPlanStatusSchema, CloudWorkspaceTimezoneNoticeSchema as Ts_CloudWorkspaceTimezoneNoticeSchema } from '../cloud'; +import type { RecordApprovalDecisionBlockSchema as Ts_RecordApprovalDecisionBlockSchema } from '../record-approval-decision'; import type { CalendarEvent as Ts_CalendarEvent, CalendarViewSchema as Ts_CalendarViewSchema, CarouselItem as Ts_CarouselItem, CarouselSchema as Ts_CarouselSchema, ChatbotSchema as Ts_ChatbotSchema, ChatbotEnhancedSchema as Ts_ChatbotEnhancedSchema, ChatbotFloatingSchema as Ts_ChatbotFloatingSchema, ChatMessage as Ts_ChatMessage, ChatMessageSource as Ts_ChatMessageSource, ChatToolInvocation as Ts_ChatToolInvocation, DashboardComponentSchema as Ts_DashboardComponentSchema, DashboardWidgetLayout as Ts_DashboardWidgetLayout, DashboardWidgetSchema as Ts_DashboardWidgetSchema, FilterBuilderSchema as Ts_FilterBuilderSchema, FilterField as Ts_FilterField, KanbanCard as Ts_KanbanCard, KanbanColumn as Ts_KanbanColumn, CardTemplate as Ts_CardTemplate } from '../complex'; import type { BPMNEdge as Ts_BPMNEdge, BPMNLane as Ts_BPMNLane, BPMNNode as Ts_BPMNNode, DashboardConfig as Ts_DashboardConfig, DashboardWidgetConfig as Ts_DashboardWidgetConfig, DataModelDesignerSchema as Ts_DataModelDesignerSchema, DataModelEntity as Ts_DataModelEntity, DataModelField as Ts_DataModelField, DataModelRelationship as Ts_DataModelRelationship, DesignerCanvasConfig as Ts_DesignerCanvasConfig, DesignerFieldDefinition as Ts_DesignerFieldDefinition, DesignerFieldOption as Ts_DesignerFieldOption, DesignerPaletteCategory as Ts_DesignerPaletteCategory, DesignerPaletteItem as Ts_DesignerPaletteItem, DesignerPosition as Ts_DesignerPosition, FieldDesignerSchema as Ts_FieldDesignerSchema, ObjectDefinition as Ts_ObjectDefinition, ObjectManagerSchema as Ts_ObjectManagerSchema, PageDesignerSchema as Ts_PageDesignerSchema, ProcessDesignerSchema as Ts_ProcessDesignerSchema, ReportDesignerElement as Ts_ReportDesignerElement, ReportDesignerSchema as Ts_ReportDesignerSchema, ReportDesignerSection as Ts_ReportDesignerSection } from '../designer'; import type { CRUDDialogSchema as Ts_CRUDDialogSchema, DetailSchema as Ts_DetailSchema } from '../crud'; @@ -1969,6 +1971,8 @@ const MIRRORS = { 'overlay.zod.ts#PopoverSchema': PopoverSchema, 'overlay.zod.ts#SheetSchema': SheetSchema, 'overlay.zod.ts#TooltipSchema': TooltipSchema, + // objectui#12342 — the arm and its TS twin landed together. + 'record-approval-decision.zod.ts#RecordApprovalDecisionBlockSchema': RecordApprovalDecisionBlockSchema, 'reports.zod.ts#ReportBuilderSchema': ReportBuilderSchema, 'reports.zod.ts#ReportComponentSchema': ReportComponentSchema, 'reports.zod.ts#ReportExportConfigSchema': ReportExportConfigSchema, @@ -2169,6 +2173,7 @@ interface Declared { 'overlay.zod.ts#PopoverSchema': Ts_PopoverSchema; 'overlay.zod.ts#SheetSchema': Ts_SheetSchema; 'overlay.zod.ts#TooltipSchema': Ts_TooltipSchema; + 'record-approval-decision.zod.ts#RecordApprovalDecisionBlockSchema': Ts_RecordApprovalDecisionBlockSchema; 'reports.zod.ts#ReportBuilderSchema': Ts_ReportBuilderSchema; 'reports.zod.ts#ReportComponentSchema': Ts_ReportComponentSchema; 'reports.zod.ts#ReportExportConfigSchema': Ts_ReportExportConfig; @@ -5221,7 +5226,7 @@ const ZOD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'zod'); * MINUEND under it had moved. Nothing failed on any of those days, because nothing * compared the registry to a number. objectui#7433 is that absence, not the digits. */ -const EXPECTED_MIRROR_PAIRS = 195; +const EXPECTED_MIRROR_PAIRS = 196; /** * A ledger this file can size from its own AST. `WiderThanDeclared` joined at diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 61fae4b252..a11415df3a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -852,6 +852,7 @@ import type { CRUDComponentSchema } from './crud.js'; import type { ObjectQLComponentSchema, ListViewSchema } from './objectql.js'; import type { AppComponentSchema, AppSchemaRendererNodeSchema } from './app.js'; import type { CloudPlanStatusSchema, CloudWorkspaceTimezoneNoticeSchema } from './cloud.js'; +import type { RecordApprovalDecisionBlockSchema } from './record-approval-decision.js'; import type { PageDesignerSchema, DataModelDesignerSchema, @@ -981,6 +982,12 @@ export type { CloudWorkspaceTimezoneNoticeSchema, } from './cloud.js'; +export type { + // The `record:approval_decision` node's TS twin, landed with its zod arm + // and its `@object-ui/app-shell` registration (objectui#12342). + RecordApprovalDecisionBlockSchema, +} from './record-approval-decision.js'; + // The Block System re-export block is GONE, not emptied: `BlockSchema`, // `BlockMetadata`, `BlockVariable`, `BlockSlot`, `BlockLibraryItem`, // `BlockLibrarySchema`, `BlockEditorSchema`, `BlockInstanceSchema` and @@ -1079,7 +1086,9 @@ export type AnySchema = | AppSchemaRendererNodeSchema | CloudPlanStatusSchema // objectui#11930 — landed with its zod arm. - | CloudWorkspaceTimezoneNoticeSchema; + | CloudWorkspaceTimezoneNoticeSchema + // objectui#12342 — landed with its zod arm. + | RecordApprovalDecisionBlockSchema; /** * Utility type to extract the schema type from a type string. diff --git a/packages/types/src/record-approval-decision.ts b/packages/types/src/record-approval-decision.ts new file mode 100644 index 0000000000..dd70f751d1 --- /dev/null +++ b/packages/types/src/record-approval-decision.ts @@ -0,0 +1,58 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @object-ui/types - `record:approval_decision` Schema + * + * The TypeScript twin of `zod/record-approval-decision.zod.ts` (objectui#12342). + * + * @module record-approval-decision + * @packageDocumentation + */ + +import type { BaseSchema } from './base.js'; + +/** + * `record:approval_decision` — the decision panel of an approval request's + * record page, the page block `@objectstack/spec` 18 declares and + * `@object-ui/app-shell` registers (objectui#12342): the TypeScript twin of + * `zod/record-approval-decision.zod.ts`'s `RecordApprovalDecisionBlockSchema`, + * landed with the arm. + * + * - `properties` — optional and empty: the spec's + * `ComponentPropsMap['record:approval_decision']` row declares no prop. The + * panel draws from the approval request record the page binds. + * - Neither content channel is read, so both are refused by name, the twin + * of the arm's two `retirementTombstone` members. + * + * The parity pair is `record-approval-decision.zod.ts#RecordApprovalDecisionBlockSchema` + * in `__tests__/zod-mirror-parity.test.ts`. + */ +export interface RecordApprovalDecisionBlockSchema extends BaseSchema { + type: 'record:approval_decision'; + /** + * The props bag — the spec row declares no prop, so the only bag it + * accepts is `{}`. + */ + properties?: Record; + /** + * REFUSED BY NAME (objectui#12342) — `record:approval_decision` reads + * neither content channel: `ApprovalDecisionRenderer` reads only + * `className` and the designer attributes off the node, and + * `SchemaRenderer` strips both channels out of the props it spreads. + * + * @deprecated Not a channel `record:approval_decision` reads — nothing renders it. + */ + body?: never; + /** + * REFUSED BY NAME (objectui#12342), for the reason `body` gives. + * + * @deprecated Not a channel `record:approval_decision` reads — nothing renders it. + */ + children?: never; +} diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index e81edf3e09..32a60003de 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -515,6 +515,11 @@ export { // ============================================================================ export { CloudPlanStatusSchema, CloudWorkspaceTimezoneNoticeSchema } from './cloud.zod.js'; +// ============================================================================ +// `record:approval_decision` - `@object-ui/app-shell`'s approval decision panel (objectui#12342) +// ============================================================================ +export { RecordApprovalDecisionBlockSchema } from './record-approval-decision.zod.js'; + // ============================================================================ // Union Types - All Component Schemas // ============================================================================ @@ -538,6 +543,7 @@ import { AIComponentSchema } from './ai.zod.js'; import { DesignerUnionSchema } from './designer.zod.js'; import { PublicBlockComponentSchema } from './public-blocks.zod.js'; import { CloudPlanStatusSchema, CloudWorkspaceTimezoneNoticeSchema } from './cloud.zod.js'; +import { RecordApprovalDecisionBlockSchema } from './record-approval-decision.zod.js'; import { nestedComponentJudgment } from './nested-component-walk.js'; /** @@ -643,6 +649,8 @@ export const AnyComponentSchema = defineNodeComponentUnion(z.discriminatedUnion( PublicBlockComponentSchema, CloudPlanStatusSchema, CloudWorkspaceTimezoneNoticeSchema, + // objectui#12342 — landed with its registration. + RecordApprovalDecisionBlockSchema, ], { // Zod's default message for a missed discriminator spells out EVERY accepted // literal — measured, 1,462 chars naming all 107. That is the "print every diff --git a/packages/types/src/zod/record-approval-decision.zod.ts b/packages/types/src/zod/record-approval-decision.zod.ts new file mode 100644 index 0000000000..0beda9b49a --- /dev/null +++ b/packages/types/src/zod/record-approval-decision.zod.ts @@ -0,0 +1,84 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @object-ui/types/zod - `record:approval_decision` Zod Validator + * + * The arm for the decision panel of an approval request's record page, the + * page block `@objectstack/spec` 18 declares as a `PageComponentType` member + * and `@object-ui/app-shell` registers + * (`views/record-approval-decision-renderer.tsx`, objectui#12342). + * + * ## Why the arm lands with the registration + * + * `AnyComponentSchema` refuses a registered type it has no arm for, with one + * `invalid_union` issue at `type`, so a page placing the panel could not pass + * `safeValidateSchema` or `objectui validate`. The count of registered types + * still refused there is ratcheted by + * `packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts`, + * which never rises, so the registration and this arm ship together. + * + * ## Why it is not in `public-blocks.zod.ts` + * + * That module arms the ADR-0080 curated vocabulary (`PUBLIC_BLOCKS` in + * `@object-ui/core`), and its pin holds the union to exactly that list. This + * block is not curated: whether Studio offers it is a separate decision. So it + * gets a module of its own, as the `cloud:` widgets do. + * + * ## The props bag: the spec row, restated because it is unnamed + * + * `ComponentPropsMap['record:approval_decision']` is built inline by the spec + * (`emptyProps('record:approval_decision')`) and `@objectstack/spec/ui` + * exports it under no name, so it cannot cross the objectui#8317 boundary by + * reference — the same reason `element:divider`'s row is restated in + * `public-blocks.zod.ts`. The row declares no member and is strict, so the + * restatement is a closed object with no member: it accepts exactly `{}`. The + * renderer draws from the record context alone, never from the node. + * `../__tests__/record-approval-decision-arm-12342.test.ts` pins its key set + * and its accept set to the installed row's, so a prop the spec later adds + * turns that file red. + * + * `body` / `children` are refused by name: the panel renders no child list. + * + * ⛔ No `.default()` anywhere in this module — see the "authors no default" + * note in `index.zod.ts`. + * + * @module zod/record-approval-decision + * @packageDocumentation + */ + +import { z } from 'zod'; +import { BaseSchema } from './base.zod.js'; +import { retirementTombstone } from './tombstone.zod.js'; + +/** One refusal string for both content channels of `RecordApprovalDecisionBlockSchema`. */ +const RECORD_APPROVAL_DECISION_NEITHER_CHANNEL = + 'REFUSED (objectui#12342) — `record:approval_decision` reads NEITHER content channel: its registration ' + + '(`@object-ui/app-shell`) hands the node to `ApprovalDecisionRenderer`, which reads only `className` and ' + + 'the designer attributes off the node, and `SchemaRenderer` strips both channels out of the props bag it ' + + 'spreads. An authored value would render NOTHING. What it renders instead: the request\'s decision ' + + 'progress and its declared decision actions, drawn from the approval request record the page binds.'; + +/** + * `record:approval_decision` — `ComponentPropsMap['record:approval_decision']`, + * an empty strict row. Pinned by + * `../__tests__/record-approval-decision-arm-12342.test.ts`. + */ +export const RecordApprovalDecisionBlockSchema = BaseSchema.extend({ + type: z.literal('record:approval_decision'), + properties: z + .strictObject({}) + .optional() + .describe( + 'The `record:approval_decision` props bag — `@objectstack/spec` ' + + '`ComponentPropsMap[\'record:approval_decision\']` declares no prop, so the only bag it accepts is `{}`. ' + + 'The panel draws from the approval request record the page binds.', + ), + body: retirementTombstone(RECORD_APPROVAL_DECISION_NEITHER_CHANNEL), + children: retirementTombstone(RECORD_APPROVAL_DECISION_NEITHER_CHANNEL), +}); From e7e004c2e9c295b734c250106529af07c67934ca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:48:06 +0000 Subject: [PATCH 2/2] fix(types): record:approval_decision's channel refusal names the parser tier's warning (objectui#12342) The family-D refusal sentence carries the corrected objectui#10928 clause, as content-channel-parser-tier-clause-10928 requires of every member. Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- packages/types/src/zod/record-approval-decision.zod.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/types/src/zod/record-approval-decision.zod.ts b/packages/types/src/zod/record-approval-decision.zod.ts index 0beda9b49a..77c050c285 100644 --- a/packages/types/src/zod/record-approval-decision.zod.ts +++ b/packages/types/src/zod/record-approval-decision.zod.ts @@ -61,7 +61,9 @@ const RECORD_APPROVAL_DECISION_NEITHER_CHANNEL = 'REFUSED (objectui#12342) — `record:approval_decision` reads NEITHER content channel: its registration ' + '(`@object-ui/app-shell`) hands the node to `ApprovalDecisionRenderer`, which reads only `className` and ' + 'the designer attributes off the node, and `SchemaRenderer` strips both channels out of the props bag it ' - + 'spreads. An authored value would render NOTHING. What it renders instead: the request\'s decision ' + + 'spreads. An authored value would render NOTHING — no render-time error or warning and no element; only ' + + 'the parser tier\'s `not-a-container` warning (objectui#9910) noticed it, because the registration ' + + 'declares no `children` input. What it renders instead: the request\'s decision ' + 'progress and its declared decision actions, drawn from the approval request record the page binds.'; /**