diff --git a/.changeset/11168-action-family-published-inputs.md b/.changeset/11168-action-family-published-inputs.md new file mode 100644 index 0000000000..de86e623f0 --- /dev/null +++ b/.changeset/11168-action-family-published-inputs.md @@ -0,0 +1,37 @@ +--- +'@object-ui/components': minor +--- + +The four `action:*` blocks now publish the `@objectstack/spec` keys their +renderers honour, and stop publishing what the spec refuses (objectui#11168, +slice 1). Each key was decided by measuring it through `SchemaRenderer` and the +action runner, per the objectui#11111 ruling: declare what the block path +honours, refuse or retire what it does not. + +Newly published, so the SDUI manifest, the JSX intrinsics and the page +validator accept them instead of reporting `unknown-prop`: + +- `action:button` and `action:icon`: `visible`, `disabled`, `params`, + `description`, `openIn`, `method`, `bodyExtra`, `bodyShape`, `operation`, + `patch`, `confirmText`, `successMessage`, `errorMessage`, `refreshAfter`, + `locations`, `toast`, `resultDialog`, `onSuccess` and `objectName`; and + `recordIdField` on `action:button`. `params` is published as the list of + parameters to collect; static execution values stay under + `properties.params`. +- `action:group`: `location` and `visible`. +- `action:menu`: `size` and `visible`. + +Retired and narrowed (breaking for an author who wrote them, which the spec +already refuses): + +- `action:group` no longer publishes `name`. Nothing reads a group-level + `name`; each member action's own `name` identifies it. +- `actions` on `action:group` and `action:menu` is published as a list of action + objects, not an `object`. Both blocks read the key as a list, so an object + there could not render. +- `action:group.size` publishes `default`, `sm`, `lg` and `icon`, and no longer + `md`. A stored `md` still renders as before. + +Still unpublished, pending a decision on objectui#11168: `endpoint` on +`action:button` and `action:icon`, and `undoable` on `action:button`. The +renderers keep forwarding both, so nothing changes at runtime. diff --git a/apps/console/src/__tests__/public-block-binding-reach.test.tsx b/apps/console/src/__tests__/public-block-binding-reach.test.tsx index de4e1d7e72..f8eb8e5384 100644 --- a/apps/console/src/__tests__/public-block-binding-reach.test.tsx +++ b/apps/console/src/__tests__/public-block-binding-reach.test.tsx @@ -117,6 +117,19 @@ const NO_DATA_REACH: Readonly> = { // titles the panel. 'record:related_list': 'needs the parent record id from RecordContext before it may fetch; declines to fetch without one (objectstack#4413 ledger)', + // objectui#11168 slice 1 published the spec key `objectName` on the two + // action leaves, and on these blocks the key is not a data binding at all: + // it names the object the ACTION acts on. The block draws a button and + // fetches nothing; the key takes effect when the action is dispatched, where + // the server-action route becomes `/api/v1/actions/{objectName}/{action}` + // instead of the page's object. That dispatch read is pinned in + // `packages/components/src/renderers/action/__tests__/action-button-icon-inputs-11168.test.tsx`; + // what this ledger asserts is the other half — that the block still asks the + // data layer for NOTHING under that name. + 'action:button': + 'objectName names the object the action is dispatched against (the server-action route), not a data binding; the block renders a button and fetches nothing (objectui#11168)', + 'action:icon': + 'objectName names the object the action is dispatched against (the server-action route), not a data binding; the block renders an icon button and fetches nothing (objectui#11168)', // `list-view` and `embeddable-form` were the other two entries here for // exactly one release of this file. Neither registration bridged the @@ -178,6 +191,11 @@ const EXPECTED_CANDIDATES = [ // an `objectName`, so it joins this derived population — additive, and the // probe below is what decides whether the binding actually reaches. 'object-tree', + // objectui#11168 slice 1 published `objectName` on the two action leaves, so + // they join the population too; both are ledgered in NO_DATA_REACH, because + // on an action block the key is a dispatch target rather than a binding. + 'action:button', + 'action:icon', ]; /** 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 f07a871c22..b26db645d1 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -758,10 +758,10 @@ const owedIdsOf = (ledger: Record): string[] => */ const OBJECTUI_11111_LEDGER_CAPS = { unjudgedBlocks: 4, // objectui#11168: object-gantt, object-map, object-timeline, object-tree - offSpecInputs: 1, // objectui#11168: action:group.name - unpublishedKeys: 48, // objectui#11168: 46 (22 + 20 + 2 + 2); objectui#8652: 2; objectui#8649: 0 (struck by its landing) - refusedArms: 5, // objectui#11168: action:group ×2, action:menu, element:definition-list, object-form - memberPins: 6, // objectui#11168: action:group, action:menu, element:definition-list, element:repeater ×3 + offSpecInputs: 0, // objectui#11168 slice 1 retired action:group.name + unpublishedKeys: 5, // objectui#11168: 3 (action:button endpoint + undoable, action:icon endpoint); objectui#8652: 2; objectui#8649: 0 (struck by its landing) + refusedArms: 2, // objectui#11168: element:definition-list, object-form + memberPins: 4, // objectui#11168: element:definition-list, element:repeater ×3 } as const; /** @@ -941,18 +941,15 @@ const UNJUDGED_SPEC_BLOCKS: Record = { * state, not a deletion — every forward-direction assertion now runs with no * cover of any kind, which is the strongest reading this gate has ever had. * - * ⚠️ ONE ENTRY SINCE THE 17.5.0 BUMP, and it is ruled, not owed-by-default: - * `action:group` entered `covered` with 17.5.0 and declares a `name` input its - * spec row does not accept. objectui#11111 decision 3 = B booked it to - * objectui#11168 (see `OWED_TO`), capped at this one entry; that card's landing - * empties the map again. + * EMPTY AGAIN since objectui#11168 slice 1. The 17.5.0 bump booked one entry + * here — `action:group.name`, a published input its new spec row does not + * accept — to objectui#11168 under objectui#11111 decision 3 = B. That slice + * measured the key (nothing reads a group-level `name`; the spec refuses it + * with a prescription) and RETIRED it from the registration, so the entry went + * stale and was deleted with it. The pin is + * `packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx`. */ -const OFF_SPEC_EXEMPTIONS: Record = { - 'action:group.name': OWED_TO( - 'objectui#11168', - 'A DECLARED INPUT THE SPEC REFUSES: `action:group` publishes `name`, and `ComponentPropsMap[action:group]` does not accept it.', - ), -}; +const OFF_SPEC_EXEMPTIONS: Record = {}; /** * Spec-declared top-level keys deliberately NOT published, each with the reason. @@ -1386,38 +1383,34 @@ const UNPUBLISHED_EXEMPTIONS: Record = { * objectui#11111 ledger is capped at exactly the entries it lists`, which * each owner card's landing lowers. */ + // objectui#11168 slice 1 measured every `action:button` / `action:icon` key + // below through the real `SchemaRenderer` and `ActionRunner` and DECLARED the + // ones honoured on the block path — all but the three still listed here, + // which the slice held back with its measurement and a question for the + // card. The pins are + // `packages/components/src/renderers/action/__tests__/action-button-icon-inputs-11168.test.tsx`. ...owedEntries( 'action:button', - [ - 'visible', 'disabled', 'params', 'description', 'openIn', 'endpoint', 'method', 'bodyExtra', - 'bodyShape', 'operation', 'patch', 'confirmText', 'successMessage', 'errorMessage', 'refreshAfter', - 'undoable', 'recordIdField', 'locations', 'toast', 'resultDialog', 'onSuccess', 'objectName', - ], + ['endpoint'], 'objectui#11168', - 'A SPEC KEY NOT PUBLISHED: `action:button` entered `covered` with 17.5.0 and its `inputs` omit this key its spec row declares.', + 'A SPEC KEY HELD UNPUBLISHED AFTER MEASUREMENT (slice 1): the block forwards `endpoint` and the runner\'s built-in `api` executor reads it, but the console registers its own `api` handler, which reads `target` and never `endpoint` — so on the console an `api` action with an `endpoint` sends nothing there.', ), ...owedEntries( - 'action:icon', - [ - 'visible', 'disabled', 'params', 'description', 'openIn', 'endpoint', 'method', 'bodyExtra', - 'bodyShape', 'operation', 'patch', 'confirmText', 'successMessage', 'errorMessage', 'refreshAfter', - 'locations', 'toast', 'resultDialog', 'onSuccess', 'objectName', - ], - 'objectui#11168', - 'A SPEC KEY NOT PUBLISHED: `action:icon` entered `covered` with 17.5.0 and its `inputs` omit this key its spec row declares.', - ), - ...owedEntries( - 'action:group', - ['location', 'visible'], + 'action:button', + ['undoable'], 'objectui#11168', - 'A SPEC KEY NOT PUBLISHED: `action:group` entered `covered` with 17.5.0 and its `inputs` omit this key its spec row declares.', + 'A SPEC KEY HELD UNPUBLISHED AFTER MEASUREMENT (slice 1): the block forwards `undoable`, but the runner\'s `operation: update` path and the console runtime offer Undo only with a host `_rowRecord` stash this block never writes; only the record page\'s own `api` handler honours it.', ), ...owedEntries( - 'action:menu', - ['size', 'visible'], + 'action:icon', + ['endpoint'], 'objectui#11168', - 'A SPEC KEY NOT PUBLISHED: `action:menu` entered `covered` with 17.5.0 and its `inputs` omit this key its spec row declares.', + 'A SPEC KEY HELD UNPUBLISHED AFTER MEASUREMENT (slice 1): the block forwards `endpoint` and the runner\'s built-in `api` executor reads it, but the console registers its own `api` handler, which reads `target` and never `endpoint` — so on the console an `api` action with an `endpoint` sends nothing there.', ), + // `action:group`'s `location` / `visible` and `action:menu`'s `size` / + // `visible` stood here until objectui#11168 slice 1 measured each against its + // renderer through the real `SchemaRenderer` and DECLARED all four — the + // documented exit: a declaration retires its own cover. ...owedEntries( 'object-kanban', ['navigation'], @@ -1988,24 +1981,18 @@ const OFF_SPEC_ARM_EXEMPTIONS: Record = { */ /* - * ⚠️ FIVE 17.5.0 BOOKINGS — objectui#11111 decision 3 = B (record 5902351047). + * ⚠️ 17.5.0 BOOKINGS — objectui#11111 decision 3 = B (record 5902351047). * Arms the contract refuses on blocks 17.5.0 newly judges, plus * `object-form.layout`, whose spec row narrowed to two members. Booked to * objectui#11168, which narrows each declaration to what the spec accepts; - * capped at these five, and that card's landing empties the map again. + * capped at the entries listed, and that card's landing empties the map. + * + * objectui#11168 slice 1 took the three `action:*` entries by NARROWING the + * declarations: `action:group.actions` and `action:menu.actions` are now a + * list of objects (`type: 'array'`, `of: 'object'`, the shape both renderers + * read), and `action:group.size` publishes the Button primitive's four sizes + * without the refused `md`. */ - 'action:group.actions:object': OWED_TO( - 'objectui#11168', - 'A REFUSED ARM: `action:group.actions` declares the `object` kind, and the contract refuses the KIND itself.', - ), - 'action:group.size:enum': OWED_TO( - 'objectui#11168', - 'A REFUSED ARM: `action:group.size` declares the member `md`, and the contract refuses it.', - ), - 'action:menu.actions:object': OWED_TO( - 'objectui#11168', - 'A REFUSED ARM: `action:menu.actions` declares the `object` kind, and the contract refuses the KIND itself.', - ), 'element:definition-list.columns:enum': OWED_TO( 'objectui#11168', 'A REFUSED ARM: `element:definition-list.columns` declares the members `1` and `2`, and the contract refuses them.', @@ -2533,6 +2520,45 @@ interface MemberPin { pins: string; } +/** + * The member pins objectui#11168 slice 1 registered for the array/object-armed + * keys `action:button` and `action:icon` publish. ONE file drives both blocks + * through the same rows (`describe.each`), so each key's claim is identical on + * the two and is written once here; `actionLeafMemberPins` spells it out per + * block, so every id below still names its block and its key. + */ +const ACTION_LEAF_PIN_FILE = + 'packages/components/src/renderers/action/__tests__/action-button-icon-inputs-11168.test.tsx'; +const ACTION_LEAF_MEMBER_CLAIMS: Record = { + visible: + 'The `object` arm is the CEL predicate envelope `{ dialect: \'cel\', source }`, mounted through the real `SchemaRenderer` with `data` bound: the holding envelope renders the control and its failing twin removes it, and the boolean and bare-CEL arms carry the same holds/fails pair, so a gate never consulted (equal verdicts) is red (objectui#11168 slice 1).', + disabled: + 'The `object` arm is the CEL predicate envelope `{ dialect: \'cel\', source }`, mounted through the real `SchemaRenderer` with `data` bound: the holding envelope greys the control and its failing twin leaves it pressable, with the control ON SCREEN in both rows so "disabled" cannot read as "never rendered"; the boolean and bare-CEL arms carry the same pair (objectui#11168 slice 1).', + params: + 'Members are the `ActionParam` definitions the runner collects: the whole list, in order and unchanged, is what the parameter-collection handler is handed, and the value collected for a member reaches the executor under that member\'s `name`; with no `params` nothing is collected and the action runs straight away (objectui#11168 slice 1).', + bodyExtra: + 'Members are request-body fields, merged into the body the runner\'s built-in `api` executor sends, LAST: a member overrides a static `properties.params` value of the same name while the other static values survive; without the key the body is the static values alone (objectui#11168 slice 1).', + bodyShape: + 'The `object` arm\'s one member is `wrap`: `{ wrap: \'data\' }` nests the static values under `data` with `bodyExtra` riding flat beside it, while `flat` and an absent key send the same flat body — asserted on the body the runner\'s built-in `api` executor sends (objectui#11168 slice 1).', + patch: + 'Members are field values for `operation: update`, merged UNDER the supplied values on the way to the write route (the `script` dispatch the runner hands an update to): a member the user also supplies loses, a member only `patch` carries is written, and the same `patch` without `operation` is merged by nothing (objectui#11168 slice 1).', + locations: + 'Members are placement names, read at dispatch by `@object-ui/core`\'s `createServerActionHandler` (the console\'s `script` handler is built on it): a list carrying a record-scoped member (`record_header`) refuses to run with no record or selection in scope — an error toast and no request — while an object-level list (`list_toolbar`) runs (objectui#11168 slice 1).', + toast: + 'Members `showOnSuccess` / `showOnError` / `duration`, each asserted on the toast handler the runner calls: `showOnSuccess: false` silences a success, `showOnError: false` silences a failure, and `duration` is handed to the toast; with no `toast` both outcomes are toasted (objectui#11168 slice 1).', + resultDialog: + 'The spec reaches the reveal-dialog handler WHOLE (deep-equal, `fields` included) together with the response data, and it replaces the success toast. The runner reads no member itself — the members are the dialog\'s to interpret — so the pin is that none is dropped or rewritten on the way (objectui#11168 slice 1).', + onSuccess: + 'Members `navigate` and `openIn`: `navigate` is interpolated from the response (`${result.id}` becomes the handler\'s id) and handed to the navigation handler, and `openIn: newTab` opens a tab where its absence navigates in place; with no `onSuccess` a successful action navigates nowhere (objectui#11168 slice 1).', +}; +const actionLeafMemberPins = (type: 'action:button' | 'action:icon'): Record => + Object.fromEntries( + Object.entries(ACTION_LEAF_MEMBER_CLAIMS).map(([key, pins]) => [ + `${type}.${key}`, + { file: ACTION_LEAF_PIN_FILE, pins }, + ]), + ); + /** * The per-block member pins this repo has, by key. * @@ -2552,6 +2578,24 @@ interface MemberPin { * was built for. */ const MEMBER_PINS: Record = { + ...actionLeafMemberPins('action:button'), + ...actionLeafMemberPins('action:icon'), + 'action:group.actions': { + file: 'packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx', + pins: 'Members are ACTION OBJECTS the group draws and runs itself, driven through the real `SchemaRenderer`: `label` is the button text (the dropdown falls back to `name`), a member\'s own `visible` / `disabled` gate THAT member only (a sibling is the control), `variant`, `size` and `className` style it, with a member `size` outranking the group\'s and the group\'s outranking the `sm` fallback, `tags: [separator-before]` draws the dropdown divider, and `locations` is what the group\'s `location` filters on. A click hands the runner the member under its OWN `type` with its own keys (`target`, `objectName`). The list kind is pinned against the contract as well: the spec refuses an `object` at `actions` by kind and accepts a list of objects, which is the narrowing objectui#11168 slice 1 made to the declaration.', + }, + 'action:group.visible': { + file: 'packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx', + pins: 'The `object` arm is the CEL predicate envelope `{ dialect: \'cel\', source }`, mounted through the real `SchemaRenderer` with `data` bound: an envelope that holds renders the group and its twin that fails hides it, and the boolean and bare-CEL arms carry the same holds/fails pair — every row asserts both halves, so a gate never consulted (equal verdicts) is red (objectui#11168 slice 1).', + }, + 'action:menu.actions': { + file: 'packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx', + pins: 'Members are ACTION OBJECTS, one menu item each, read through the real `SchemaRenderer` with the Radix menu opened: `label` is the item text (falling back to `name`), a member\'s own `visible` hides and its own `disabled` greys THAT item (a sibling is the control), `tags: [separator-before]` draws the divider, `variant: destructive` paints the item, and selecting an item hands the runner the member under its OWN `type` with its own keys (`target`, `objectName`). The list kind is pinned against the contract: the spec refuses an `object` at `actions` by kind and accepts a list of objects (objectui#11168 slice 1).', + }, + 'action:menu.visible': { + file: 'packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx', + pins: 'The `object` arm is the CEL predicate envelope `{ dialect: \'cel\', source }`, mounted through the real `SchemaRenderer` with `data` bound: an envelope that holds renders the trigger and its twin that fails hides the whole menu, with the boolean and bare-CEL arms carrying the same holds/fails pair (objectui#11168 slice 1).', + }, 'element:button.action': { file: 'packages/components/src/__tests__/elementButtonActionMembers-8071.test.tsx', pins: 'The inline ActionDef\'s member set as a WHITELIST, driven through the real renderer and asserted on what the ActionRunner is handed: the forwarded keys are pinned as a SORTED SET, so a member silently added to or dropped from `ElementButtonRenderer`\'s explicit forward list is red in either direction — the defect shape objectstack#6837 (`bodyExtra`) and objectstack#6938 (`bodyShape`) were both filed for, an authored key that validates, publishes and then evaporates one hop before the runner. Three off-list keys are authored alongside and asserted ABSENT: `bodyShape` (deliberately not inline vocabulary — `action-bodyShape-forward.test.tsx` argues the boundary in prose, this pins it as behaviour) plus `icon` and `variant`, the block\'s own sibling props misplaced one level in. `actionType` OUTRANKS `type` with the losing spelling\'s handler asserted un-run, an ARRAY `params` is re-routed to `actionParams` while an OBJECT `params` stays `params` (objectstack#5777 direction A) with each arm controlling the other, the navigation members reach the navigation handler, and an omitted `action` dispatches nothing at all — the non-vacuity control for every row. LIMIT: `confirmText` is on the whitelist but unexercised, because the runner awaits confirmation before the dispatch this file observes. New file (objectui#8071 slice 7).', @@ -3172,22 +3216,12 @@ const MEMBER_PIN_EXEMPTIONS: Record = { // objectui#8176 correction is spent to the last key, and this header stays // only as a note for the next reader who greps for it. - // ⚠️ SIX 17.5.0 BOOKINGS — objectui#11111 decision 3 = B (record 5902351047). + // ⚠️ 17.5.0 BOOKINGS — objectui#11111 decision 3 = B (record 5902351047). // The array/object-armed inputs of the blocks 17.5.0 newly judges, owed a // member pin by objectui#11168. Counted into `MEMBER_PIN_EXEMPTION_CEILING` - // (1 -> 7) and capped at these six; that card's pins strike them. - ...owedEntries( - 'action:group', - ['actions'], - 'objectui#11168', - 'A MEMBER PIN OWED: `action:group.actions` is array/object-armed on a block 17.5.0 newly judges, and no pin states its members.', - ), - ...owedEntries( - 'action:menu', - ['actions'], - 'objectui#11168', - 'A MEMBER PIN OWED: `action:menu.actions` is array/object-armed on a block 17.5.0 newly judges, and no pin states its members.', - ), + // and capped at the entries listed; that card's pins strike them. + // `action:group.actions` and `action:menu.actions` stood here until + // objectui#11168 slice 1 registered their pins in `MEMBER_PINS`. ...owedEntries( 'element:definition-list', ['items'], @@ -3985,8 +4019,16 @@ const NEWLY_JUDGED_UNPINNED_MEMBERS: string[] = []; * `NO_READ_SITE_TO_PIN` unit above) + 6 booked = 7, the exact count, and * `the objectui#11111 ledger is capped at exactly the entries it lists` pins * the six by name. objectui#11168's pins take this back to 1. + * + * ## 7 -> 5 (objectui#11168 slice 1, the `action:*` family) — the ruling paid down + * + * The slice registered member pins for `action:group.actions` and + * `action:menu.actions` (the lists both containers draw and run themselves) + * and deleted their two booked entries, so the ceiling follows the list down + * in the same change. The four left are the `element:*` bookings, owed by the + * card's later slices. */ -const MEMBER_PIN_EXEMPTION_CEILING = 7; +const MEMBER_PIN_EXEMPTION_CEILING = 5; /** * Every test file a member pin can live in, as LAZY `?raw` loaders. @@ -5307,10 +5349,10 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) expect(declaredInputs(type) ?? [], `${type} stopped publishing ${key}`).toContain(key); expect(Object.keys(OFF_SPEC_EXEMPTIONS)).not.toContain(`${type}.${key}`); } - // Empty since rc.6 but for the one entry objectui#11111 decision 3 = B - // booked to objectui#11168 at the 17.5.0 bump — pinned by name, so nothing - // else rides in; objectui#11168's landing takes this back to `[]`. - expect(Object.keys(OFF_SPEC_EXEMPTIONS)).toEqual(['action:group.name']); + // Empty since rc.6. The 17.5.0 bump booked `action:group.name` here under + // objectui#11111 decision 3 = B; objectui#11168 slice 1 retired the input, + // and with it the entry. Pinned empty, so nothing rides back in. + expect(Object.keys(OFF_SPEC_EXEMPTIONS)).toEqual([]); }); // ── the MEMBER-PIN direction (objectui#8068) ─────────────────────────────── @@ -5564,6 +5606,6 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) reasons.filter((reason) => reason.startsWith(`${OWED_PREFIX}${owner}.`)).length, ]), ), - ).toEqual({ 'objectui#11168': 62, 'objectui#8652': 2, 'objectui#8649': 0 }); + ).toEqual({ 'objectui#11168': 13, 'objectui#8652': 2, 'objectui#8649': 0 }); }); }); diff --git a/content/docs/guide/layout.md b/content/docs/guide/layout.md index 4089ed7d8f..68de90b40b 100644 --- a/content/docs/guide/layout.md +++ b/content/docs/guide/layout.md @@ -585,6 +585,15 @@ The handler name goes in `actionType`, which `action:button` forwards to the act runner as the action's type; the runner dispatches to the handler registered under it. Same spelling as [Record Edit Modes](./record-edit-modes.md). +The rest of the action is authored on the same node. `action:button` and `action:icon` +evaluate `visible` and `disabled` themselves (a boolean, a CEL string, or a +`{ dialect, source }` envelope), and forward the action-definition keys to the runner: +`params`, `confirmText`, `successMessage`, `errorMessage`, `toast`, `bodyExtra`, +`bodyShape`, `operation` / `patch`, `onSuccess`, `objectName` and the others each +registration's `inputs` lists, with a `description` per key. For an `api` action, write +the request URL in `target`: `endpoint` is not published, because the console's `api` +handler never reads it. + ## Responsive Behavior The shell has exactly **one** layout breakpoint, at **768px** — Tailwind's `md`, and diff --git a/packages/components/src/renderers/action/__tests__/action-button-icon-inputs-11168.test.tsx b/packages/components/src/renderers/action/__tests__/action-button-icon-inputs-11168.test.tsx new file mode 100644 index 0000000000..b4c2952c99 --- /dev/null +++ b/packages/components/src/renderers/action/__tests__/action-button-icon-inputs-11168.test.tsx @@ -0,0 +1,529 @@ +/** + * 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#11168 slice 1 — the spec keys `action:button` and `action:icon` + * PUBLISH, each honoured through the block path. + * + * `@objectstack/spec` 17.5.0 gave both blocks a `ComponentPropsMap` row, and + * the repo-wide parity guard (`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`) + * booked every spec key the registrations did not publish to this card + * (objectui#11111 decision 3 = B). The ruling's terms: each key is declared by + * its own measurement, and a key counts as honoured only when the NODE's value + * reaches whatever acts on it. Most of these keys are action-definition keys: + * the renderer does not read them for its markup, it forwards them to the + * `ActionRunner`, and the runner (or the handler it dispatches to) acts. So + * every row below drives the whole path — the node through the REAL + * `SchemaRenderer` and registry, the renderer's forward, the REAL runner — and + * asserts on the effect the key's description promises, with a control row in + * which the key is absent (or holds the other value) and the effect is too. + * + * Where the runner hands a key to a HOST handler rather than acting on it + * itself, the handler used is the real one the console builds on where it lives + * in `@object-ui/core` (`createServerActionHandler`, for `objectName`, + * `locations` and `recordIdField`), and a recording double otherwise; the + * console's own reads of `method`, `bodyExtra`, `bodyShape`, `description` + * and `objectName` are the ones `scripts/check-action-forward-parity.mjs` + * extracts from its runtime. + * + * NOT published, and so not pinned here — each stays booked to objectui#11168 + * with its measurement on the card: `endpoint` on both blocks (the runner's + * built-in `api` executor reads it, the console's own `api` handler reads + * `target` and never `endpoint`) and `undoable` on `action:button` (the + * runner's update path offers Undo only with a host row stash this block never + * writes). + */ + +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React, { useEffect } from 'react'; +import { ComponentRegistry, createServerActionHandler } from '@object-ui/core'; +import type { ActionContext, ActionDef, ActionResult } from '@object-ui/core'; +import type { BaseSchema, DataSource } from '@object-ui/types'; +import { + ActionProvider, + PredicateScopeProvider, + SchemaRenderer, + SchemaRendererProvider, + useAction, +} from '@object-ui/react'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; +// Module-scope side-effect imports: the registry must hold both renderers when +// `SchemaRenderer` resolves the type, and the light `dom` project does not load +// the components graph. Module scope, not a `beforeAll`, per AGENTS.md 测试纪律. +import '../action-button'; +import '../action-icon'; + +const BLOCKS = ['action:button', 'action:icon'] as const; +type Block = (typeof BLOCKS)[number]; + +/** The spec keys slice 1 published on both blocks. */ +const LEAF_KEYS = [ + 'visible', 'disabled', 'params', 'description', 'openIn', 'method', 'bodyExtra', 'bodyShape', + 'operation', 'patch', 'confirmText', 'successMessage', 'errorMessage', 'refreshAfter', + 'locations', 'toast', 'resultDialog', 'onSuccess', 'objectName', +]; + +/** The keys each block now publishes, beyond the ones it published before. */ +const DECLARED: Record = { + 'action:button': [...LEAF_KEYS, 'recordIdField'], + 'action:icon': LEAF_KEYS, +}; + +/** The row every predicate below is evaluated against. */ +const DATA = { status: 'draft' }; +const HOLDS = { dialect: 'cel', source: 'has(data.status) && data.status == "draft"' }; +const FAILS = { dialect: 'cel', source: 'has(data.status) && data.status == "published"' }; + +type Handler = Mock<(action: ActionDef, ctx: ActionContext) => Promise>; + +let run: Handler; +let toast: Mock<(message: string, options?: { type?: string; duration?: number }) => void>; +let nav: Mock<(url: string, options?: { external?: boolean; newTab?: boolean }) => void>; +let confirm: Mock<(message: string) => Promise>; +let collect: Mock<(defs: unknown[], action?: ActionDef) => Promise | null>>; +let dialog: Mock<(spec: unknown, data: unknown, action?: ActionDef) => Promise>; +let fetchSpy: Mock<(url: string, init?: RequestInit) => Promise>; +/** The provider's last result, read the way a host reads it (`useAction()`). */ +let lastResult: ActionResult | null; + +beforeEach(() => { + run = vi.fn(async () => ({ success: true, data: { id: '42' } })); + toast = vi.fn(); + nav = vi.fn(); + confirm = vi.fn(async () => true); + collect = vi.fn(async () => ({ title: 'Collected' })); + dialog = vi.fn(async () => {}); + fetchSpy = vi.fn(async () => + new Response(JSON.stringify({ success: true, data: {} }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchSpy); + lastResult = null; +}); + +afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); +}); + +/** Reports the provider's `result` each time it changes — the host's view of an execution. */ +function ResultProbe({ onResult }: { onResult: (result: ActionResult | null) => void }) { + const { result } = useAction(); + useEffect(() => onResult(result), [result, onResult]); + return null; +} + +const recordResult = (result: ActionResult | null) => { + lastResult = result; +}; + +/** Read through a call, so the compiler does not narrow the reset below to `null`. */ +const currentResult = (): ActionResult | null => lastResult; + +interface MountOptions { + handlers?: Record Promise>; + context?: Record; +} + +/** Mount one node the way a page does: the real `SchemaRenderer` inside the real runner. */ +function mount(schema: Record, options: MountOptions = {}) { + return render( + + + + + + + + , + ); +} + +/** One node of `type`, running the recording `run` handler unless told otherwise. */ +const node = (type: Block, extra: Record = {}) => ({ + type, + name: 'probe_action', + label: 'Probe', + icon: 'check', + actionType: 'run', + ...extra, +}); + +const press = () => fireEvent.click(screen.getByRole('button')); + +/** The request body the runner's built-in `api` executor sent. */ +const sentBody = () => JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)); + +/** The server-action handler the console's `script` dispatch is built on (`@object-ui/core`). */ +const serverAction = () => + createServerActionHandler({ fetch: fetchSpy as never, resolveObject: () => 'page_object' }); + +describe.each(BLOCKS)('%s — publishes the spec keys objectui#11168 slice 1 measured honoured', (type) => { + it('every declared key is a published input AND a key the installed spec row accepts', () => { + const published = new Set((ComponentRegistry.getConfig(type)?.inputs ?? []).map((input) => input.name)); + const specKeys = Object.keys( + (ComponentPropsMap as unknown as Record }>)[type]?.shape ?? {}, + ); + for (const key of DECLARED[type]) { + expect(published.has(key), `${type} does not publish ${key}`).toBe(true); + // `shape` resolving at all is the non-vacuity half of this assertion. + expect(specKeys.length).toBeGreaterThan(0); + expect(specKeys, `${type} spec row does not declare ${key}`).toContain(key); + } + }); +}); + +describe.each(BLOCKS)('%s — keys the renderer consumes itself', (type) => { + it.each([ + ['a boolean', true, false], + ['a bare CEL expression', 'data.status == "draft"', 'data.status == "published"'], + ['the `{ dialect, source }` envelope', HOLDS, FAILS], + ])('`visible` as %s: the control renders when it holds and is absent when it fails', (_arm, holds, fails) => { + mount(node(type, { visible: holds })); + expect(screen.getByRole('button')).toBeInTheDocument(); + cleanup(); + mount(node(type, { visible: fails })); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it.each([ + ['a boolean', true, false], + ['a bare CEL expression', 'data.status == "draft"', 'data.status == "published"'], + ['the `{ dialect, source }` envelope', HOLDS, FAILS], + ])('`disabled` as %s: the control is on screen either way, and pressable only when it fails', (_arm, holds, fails) => { + mount(node(type, { disabled: holds })); + expect(screen.getByRole('button')).toBeDisabled(); + cleanup(); + mount(node(type, { disabled: fails })); + expect(screen.getByRole('button')).toBeEnabled(); + }); +}); + +describe.each(BLOCKS)('%s — keys the runner acts on, forwarded by the block', (type) => { + it('`params`: the members are the collection definition, and each value reaches the executor under its `name`', async () => { + const params = [ + { name: 'title', type: 'text', label: 'Title', required: true }, + { name: 'note', type: 'textarea', label: 'Note' }, + ]; + mount(node(type, { params })); + press(); + await waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + // The whole member list, in order and unchanged, is what the dialog is handed. + expect(collect).toHaveBeenCalledTimes(1); + expect(collect.mock.calls[0][0]).toEqual(params); + // The value collected for member `title` reaches the executor as `title`. + expect((run.mock.calls[0][0].params as Record).title).toBe('Collected'); + cleanup(); + // Control: no `params` — nothing to collect, the action runs straight away. + collect.mockClear(); + run.mockClear(); + mount(node(type)); + press(); + await waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + expect(collect).not.toHaveBeenCalled(); + }); + + it('`description` is what the parameter dialog is handed under the action', async () => { + const params = [{ name: 'title', type: 'text', label: 'Title' }]; + mount(node(type, { params, description: 'Explains the action' })); + press(); + await waitFor(() => expect(collect).toHaveBeenCalledTimes(1)); + expect(collect.mock.calls[0][1]?.description).toBe('Explains the action'); + cleanup(); + collect.mockClear(); + mount(node(type, { params })); + press(); + await waitFor(() => expect(collect).toHaveBeenCalledTimes(1)); + expect(collect.mock.calls[0][1]?.description).toBeUndefined(); + }); + + it('`openIn` decides the tab of a `url` action', async () => { + mount(node(type, { actionType: 'url', target: '/app/report', openIn: 'new-tab' })); + press(); + await waitFor(() => expect(nav).toHaveBeenCalledTimes(1)); + expect(nav.mock.calls[0]).toEqual(['/app/report', expect.objectContaining({ newTab: true })]); + cleanup(); + nav.mockClear(); + // Control: a relative url with no `openIn` navigates in place. + mount(node(type, { actionType: 'url', target: '/app/report' })); + press(); + await waitFor(() => expect(nav).toHaveBeenCalledTimes(1)); + expect(nav.mock.calls[0]).toEqual(['/app/report', expect.objectContaining({ newTab: false })]); + }); + + it('`method` is the verb of the `api` request', async () => { + mount(node(type, { actionType: 'api', target: '/api/probe', method: 'PUT' }), { handlers: {} }); + press(); + await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); + expect(fetchSpy.mock.calls[0][1]?.method).toBe('PUT'); + cleanup(); + fetchSpy.mockClear(); + mount(node(type, { actionType: 'api', target: '/api/probe' }), { handlers: {} }); + press(); + await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); + expect(fetchSpy.mock.calls[0][1]?.method).toBe('POST'); + }); + + it('`bodyExtra` members are merged into the `api` body LAST, over a static value of the same name', async () => { + mount( + node(type, { + actionType: 'api', + target: '/api/probe', + properties: { params: { status: 'from-params', keep: 'kept' } }, + bodyExtra: { status: 'from-bodyExtra', resend: true }, + }), + { handlers: {} }, + ); + press(); + await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); + expect(sentBody()).toEqual({ status: 'from-bodyExtra', keep: 'kept', resend: true }); + cleanup(); + fetchSpy.mockClear(); + mount( + node(type, { actionType: 'api', target: '/api/probe', properties: { params: { status: 'from-params' } } }), + { handlers: {} }, + ); + press(); + await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); + expect(sentBody()).toEqual({ status: 'from-params' }); + }); + + it('`bodyShape`: `{ wrap }` nests the values under that member, `flat` and absence do not', async () => { + const base = { + actionType: 'api', + target: '/api/probe', + properties: { params: { name: 'Acme' } }, + bodyExtra: { source: 'web' }, + }; + const bodyFor = async (extra: Record) => { + fetchSpy.mockClear(); + mount(node(type, { ...base, ...extra }), { handlers: {} }); + press(); + await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); + const body = sentBody(); + cleanup(); + return body; + }; + // `bodyExtra` rides flat BESIDE the wrap — the member names only the nest. + expect(await bodyFor({ bodyShape: { wrap: 'data' } })).toEqual({ data: { name: 'Acme' }, source: 'web' }); + expect(await bodyFor({ bodyShape: 'flat' })).toEqual({ name: 'Acme', source: 'web' }); + expect(await bodyFor({})).toEqual({ name: 'Acme', source: 'web' }); + }); + + it('`operation: update` sends `patch` to the write route, merged UNDER the supplied values', async () => { + const script = vi.fn(async () => ({ success: true })); + mount( + node(type, { + actionType: undefined, + operation: 'update', + patch: { status: 'closed', stage: 'done' }, + properties: { params: { recordId: 'R1', status: 'user-chosen' } }, + }), + { handlers: { script } }, + ); + press(); + await waitFor(() => expect(script).toHaveBeenCalledTimes(1)); + // `stage` comes from `patch`; `status` is supplied too, and the supplied value wins. + expect((script.mock.calls[0] as unknown[])[0]).toMatchObject({ + params: { recordId: 'R1', status: 'user-chosen', stage: 'done' }, + }); + cleanup(); + script.mockClear(); + // Control: the same `patch` without `operation` is not a write — nothing merges it. + mount( + node(type, { actionType: 'script', patch: { stage: 'done' }, properties: { params: { recordId: 'R1' } } }), + { handlers: { script } }, + ); + press(); + await waitFor(() => expect(script).toHaveBeenCalledTimes(1)); + expect((script.mock.calls[0] as unknown as [ActionDef])[0].params).toEqual({ recordId: 'R1' }); + }); + + it('`confirmText` is asked first, and a refusal stops the action', async () => { + confirm.mockResolvedValueOnce(false); + mount(node(type, { confirmText: 'Really archive?' })); + press(); + await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1)); + expect(confirm.mock.calls[0][0]).toBe('Really archive?'); + expect(run).not.toHaveBeenCalled(); + cleanup(); + // Accepted, it runs; with no `confirmText` nothing is asked. + mount(node(type, { confirmText: 'Really archive?' })); + press(); + await waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + cleanup(); + confirm.mockClear(); + mount(node(type)); + press(); + await waitFor(() => expect(run).toHaveBeenCalledTimes(2)); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('`successMessage` is the success toast; without it the runner says its own default', async () => { + mount(node(type, { successMessage: 'Archived.' })); + press(); + await waitFor(() => expect(toast).toHaveBeenCalledTimes(1)); + expect(toast.mock.calls[0]).toEqual(['Archived.', expect.objectContaining({ type: 'success' })]); + cleanup(); + toast.mockClear(); + mount(node(type)); + press(); + await waitFor(() => expect(toast).toHaveBeenCalledTimes(1)); + expect(toast.mock.calls[0][0]).not.toBe('Archived.'); + }); + + it('`errorMessage` replaces the raw error in the failure toast', async () => { + run.mockResolvedValue({ success: false, error: 'raw backend error' }); + mount(node(type, { errorMessage: 'Could not archive.' })); + press(); + await waitFor(() => expect(toast).toHaveBeenCalledTimes(1)); + expect(toast.mock.calls[0]).toEqual(['Could not archive.', expect.objectContaining({ type: 'error' })]); + cleanup(); + toast.mockClear(); + mount(node(type)); + press(); + await waitFor(() => expect(toast).toHaveBeenCalledTimes(1)); + expect(toast.mock.calls[0][0]).toBe('raw backend error'); + }); + + it('`toast` members: `showOnSuccess: false` silences success, `showOnError: false` silences failure, `duration` is handed on', async () => { + mount(node(type, { toast: { showOnSuccess: false } })); + press(); + await waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + expect(toast).not.toHaveBeenCalled(); + cleanup(); + mount(node(type, { toast: { duration: 1234 } })); + press(); + await waitFor(() => expect(toast).toHaveBeenCalledTimes(1)); + expect(toast.mock.calls[0][1]).toEqual(expect.objectContaining({ type: 'success', duration: 1234 })); + cleanup(); + toast.mockClear(); + run.mockResolvedValue({ success: false, error: 'boom' }); + mount(node(type, { toast: { showOnError: false } })); + press(); + await waitFor(() => expect(run).toHaveBeenCalledTimes(3)); + expect(toast).not.toHaveBeenCalled(); + cleanup(); + // Control: no `toast` — the failure is toasted. + mount(node(type)); + press(); + await waitFor(() => expect(toast).toHaveBeenCalledTimes(1)); + }); + + it('`refreshAfter` marks the result for a reload', async () => { + mount(node(type, { refreshAfter: true })); + press(); + await waitFor(() => expect(currentResult()?.success).toBe(true)); + expect(currentResult()?.reload).toBe(true); + cleanup(); + lastResult = null; + mount(node(type)); + press(); + await waitFor(() => expect(currentResult()?.success).toBe(true)); + expect(currentResult()?.reload).toBeFalsy(); + }); + + it('`resultDialog` hands the response to the reveal dialog, whole, in place of the success toast', async () => { + const spec = { title: 'Your code', fields: [{ path: 'id', label: 'Id', format: 'secret' }] }; + mount(node(type, { resultDialog: spec })); + press(); + await waitFor(() => expect(dialog).toHaveBeenCalledTimes(1)); + expect(dialog.mock.calls[0][0]).toEqual(spec); + expect(dialog.mock.calls[0][1]).toEqual({ id: '42' }); + expect(toast).not.toHaveBeenCalled(); + cleanup(); + mount(node(type)); + press(); + await waitFor(() => expect(toast).toHaveBeenCalledTimes(1)); + expect(dialog).toHaveBeenCalledTimes(1); + }); + + it('`onSuccess` members: `navigate` is interpolated from the result, `openIn: newTab` opens a tab', async () => { + mount(node(type, { onSuccess: { navigate: '/app/records/${result.id}', openIn: 'newTab' } })); + press(); + await waitFor(() => expect(nav).toHaveBeenCalledTimes(1)); + expect(nav.mock.calls[0]).toEqual(['/app/records/42', expect.objectContaining({ newTab: true })]); + cleanup(); + nav.mockClear(); + mount(node(type, { onSuccess: { navigate: '/app/records/${result.id}' } })); + press(); + await waitFor(() => expect(nav).toHaveBeenCalledTimes(1)); + expect(nav.mock.calls[0][0]).toBe('/app/records/42'); + expect(nav.mock.calls[0][1]?.newTab).not.toBe(true); + cleanup(); + nav.mockClear(); + // Control: no `onSuccess` — the action succeeds and nothing navigates. + mount(node(type)); + press(); + await waitFor(() => expect(run).toHaveBeenCalledTimes(3)); + expect(nav).not.toHaveBeenCalled(); + }); + + it('`objectName` retargets the server action route; without it the page object is used', async () => { + mount(node(type, { actionType: 'script', objectName: 'contact' }), { handlers: { script: serverAction() } }); + press(); + await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); + expect(fetchSpy.mock.calls[0][0]).toBe('/api/v1/actions/contact/probe_action'); + cleanup(); + fetchSpy.mockClear(); + mount(node(type, { actionType: 'script' }), { handlers: { script: serverAction() } }); + press(); + await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); + expect(fetchSpy.mock.calls[0][0]).toBe('/api/v1/actions/page_object/probe_action'); + }); + + it('`locations` members: a record-scoped placement refuses to run with no record in scope', async () => { + mount(node(type, { actionType: 'script', locations: ['list_toolbar', 'record_header'] }), { + handlers: { script: serverAction() }, + }); + press(); + // Refused before any request: an error toast, and no call to the route. + await waitFor(() => expect(toast).toHaveBeenCalledTimes(1)); + expect(toast.mock.calls[0][1]).toEqual(expect.objectContaining({ type: 'error' })); + expect(fetchSpy).not.toHaveBeenCalled(); + cleanup(); + // Control: object-level placements only — the same action runs without a record. + mount(node(type, { actionType: 'script', locations: ['list_toolbar'] }), { + handlers: { script: serverAction() }, + }); + press(); + await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); + }); +}); + +describe('action:button — `recordIdField`', () => { + it('names the row field a single-selection `script` action sends as its record id', async () => { + const context = { selectedRecords: [{ id: 'ID-1', code: 'CODE-1' }] }; + mount(node('action:button', { actionType: 'script', recordIdField: 'code' }), { + handlers: { script: serverAction() }, + context, + }); + press(); + await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); + expect(JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)).recordId).toBe('CODE-1'); + cleanup(); + fetchSpy.mockClear(); + // Control: without the key the row's `id` is sent. + mount(node('action:button', { actionType: 'script' }), { handlers: { script: serverAction() }, context }); + press(); + await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); + expect(JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)).recordId).toBe('ID-1'); + }); +}); diff --git a/packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx b/packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx new file mode 100644 index 0000000000..f0fd5620e3 --- /dev/null +++ b/packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx @@ -0,0 +1,360 @@ +/** + * 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#11168 slice 1 — what `action:group` and `action:menu` PUBLISH, and + * each published key honoured through the block path. + * + * `@objectstack/spec` 17.5.0 gave both blocks a `ComponentPropsMap` row, and + * the repo-wide parity guard (`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`) + * booked every difference to this card (objectui#11111 decision 3 = B). The + * ruling's terms: each key is declared by its own measurement — declare what + * the renderer honours, refuse or retire what it does not. This file is the + * measurement kept as a pin. Every row mounts the block the way a page does, + * through the REAL `SchemaRenderer` and the real registry, and asserts on what + * a user sees or what the real `ActionRunner` hands a handler. Every positive + * row carries its control, so a verdict cannot be "the block never rendered". + * + * What moved, per key: + * + * - `action:group.location`, `.visible`; `action:menu.size`, `.visible` — + * spec keys the renderers read, now PUBLISHED. + * - `action:group.name` — RETIRED from the published inputs. Nothing reads a + * group-level `name` (inline mode only leaks it onto the wrapping `
` + * as a DOM attribute), and the spec refuses it on this row. + * - `action:group.actions` / `action:menu.actions` — NARROWED from the + * `object` kind the spec refuses to a list of action objects, and their + * member shape pinned below (the MEMBER-PIN direction of the guard names + * this file for both keys). + * - `action:group.size` — NARROWED from `sm`/`md`/`lg` to the Button + * primitive's four sizes, the set the spec accepts. `md` is refused; the + * renderer's dropdown-mode `md` → `default` mapping stays as a back-compat + * read for stored documents. + */ + +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import { render, screen, fireEvent, waitFor, within, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import type { ActionContext, ActionDef, ActionResult } from '@object-ui/core'; +import type { BaseSchema, DataSource } from '@object-ui/types'; +import { + ActionProvider, + PredicateScopeProvider, + SchemaRenderer, + SchemaRendererProvider, +} from '@object-ui/react'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; +// Module-scope side-effect imports: the registry must hold both renderers when +// `SchemaRenderer` resolves the type, and the light `dom` project does not load +// the components graph. Module scope, not a `beforeAll`, per AGENTS.md 测试纪律. +import '../action-group'; +import '../action-menu'; + +/** The row every predicate below is evaluated against. */ +const DATA = { status: 'draft' }; + +/** A CEL envelope that holds on {@link DATA}, and its twin that fails. */ +const HOLDS = { dialect: 'cel', source: 'has(data.status) && data.status == "draft"' }; +const FAILS = { dialect: 'cel', source: 'has(data.status) && data.status == "published"' }; + +/** The overflow trigger's accessible name, with no i18n bundle loaded. */ +const MORE = 'More actions'; + +let run: Mock<(action: ActionDef, ctx: ActionContext) => Promise>; +let consoleError: ReturnType; + +beforeEach(() => { + run = vi.fn(async () => ({ success: true })); + // Radix warns about a missing dialog description in happy-dom; not the subject. + consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + consoleError.mockRestore(); + cleanup(); +}); + +/** + * Mount one node the way a page does: the real `SchemaRenderer`, a runner + * whose `run` handler records what it is handed, and the predicate scope the + * CEL rows read (`data` bound to {@link DATA}; see + * `action-enablement-cel-envelope.test.tsx` for why both providers). + */ +function mount(schema: Record) { + return render( + + + + + + + , + ); +} + +/** One member action, as an author writes it inside `actions`. */ +const member = (name: string, extra: Record = {}) => ({ + name, + label: name.toUpperCase(), + type: 'run', + ...extra, +}); + +/** The published inputs of one block, by name. */ +const inputsOf = (type: string) => + new Map((ComponentRegistry.getConfig(type)?.inputs ?? []).map((input) => [input.name, input])); + +/** The installed spec row for one block, as a parser. */ +interface SpecIssue { code: string; path: PropertyKey[]; keys?: string[] } +const specRow = (type: string) => + (ComponentPropsMap as unknown as Record { success: boolean; error?: { issues: SpecIssue[] } } }>)[type]; + +/** + * The refusal the spec row gives one value, as `{ code, path }` pairs — so a + * refusal row asserts WHICH key was refused and HOW (by name, by kind, by + * value), never a bare `success: false` a sibling key could have produced. + */ +const refusal = (type: string, value: unknown) => + (specRow(type).safeParse(value).error?.issues ?? []).map((issue) => ({ + code: issue.code, + path: issue.path.join('.'), + ...(issue.keys ? { keys: issue.keys } : {}), + })); + +/** Radix opens a dropdown on `pointerdown` and mounts its content in a portal. */ +async function openMenu(trigger: HTMLElement) { + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + return screen.findByRole('menu'); +} + +// ── action:group ──────────────────────────────────────────────────────────── + +describe('action:group — the published inputs (objectui#11168)', () => { + it('publishes `location` and `visible`, and no longer publishes `name`', () => { + const inputs = inputsOf('action:group'); + expect(inputs.has('location')).toBe(true); + expect(inputs.has('visible')).toBe(true); + expect(inputs.has('name')).toBe(false); + // The two published keys are keys the spec row accepts, `name` is not. + expect(specRow('action:group').safeParse({ location: 'list_toolbar', visible: true }).success).toBe(true); + }); + + it('`name` is RETIRED: the spec refuses it, and nothing the group renders reads it', () => { + // Refused BY NAME — the row's unknown-key refusal, naming exactly `name`. + expect(refusal('action:group', { name: 'grp' })).toEqual([ + { code: 'unrecognized_keys', path: '', keys: ['name'] }, + ]); + + // Same members, with and without a group `name`: identical buttons, in + // both display modes — the key changes nothing a user can see or run. + const actions = [member('alpha'), member('beta')]; + for (const display of ['inline', 'dropdown'] as const) { + mount({ type: 'action:group', display, actions }); + const without = screen.getAllByRole('button').map((b) => b.textContent); + cleanup(); + mount({ type: 'action:group', display, name: 'grp', actions }); + const withName = screen.getAllByRole('button').map((b) => b.textContent); + cleanup(); + expect(withName).toEqual(without); + expect(without.length).toBeGreaterThan(0); + } + }); + + it('`actions` is published as a LIST of objects — the `object` kind the spec refuses is gone', () => { + const actions = inputsOf('action:group').get('actions'); + expect(actions?.type).toBe('array'); + expect(actions?.of).toBe('object'); + // The narrowing is the contract's: an object is refused by kind, a list of + // action objects is accepted. + expect(refusal('action:group', { actions: { a: member('a') } })).toEqual([ + { code: 'invalid_type', path: 'actions' }, + ]); + expect(specRow('action:group').safeParse({ actions: [member('a')] }).success).toBe(true); + }); + + it('`size` publishes exactly the sizes the spec accepts — `md` is refused and no longer offered', () => { + const size = inputsOf('action:group').get('size'); + expect([...(size?.enum ?? [])].sort()).toEqual(['default', 'icon', 'lg', 'sm']); + for (const value of size?.enum ?? []) { + expect(specRow('action:group').safeParse({ size: value }).success, `size ${String(value)}`).toBe(true); + } + expect(refusal('action:group', { size: 'md' })).toEqual([{ code: 'invalid_value', path: 'size' }]); + }); +}); + +describe('action:group — each published key is honoured through `SchemaRenderer`', () => { + it('`location` renders only the members whose `locations` include it', () => { + const actions = [ + member('here', { locations: ['list_toolbar'] }), + member('there', { locations: ['record_header'] }), + ]; + mount({ type: 'action:group', location: 'list_toolbar', actions }); + expect(screen.getByRole('button', { name: 'HERE' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'THERE' })).toBeNull(); + cleanup(); + // Control: the same members with no `location` — both render. + mount({ type: 'action:group', actions }); + expect(screen.getByRole('button', { name: 'HERE' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'THERE' })).toBeInTheDocument(); + }); + + it.each([ + ['a boolean', true, false], + ['a bare CEL expression', 'data.status == "draft"', 'data.status == "published"'], + ['the `{ dialect, source }` envelope', HOLDS, FAILS], + ])('`visible` as %s hides the whole group when it fails', (_arm, holds, fails) => { + const actions = [member('alpha')]; + mount({ type: 'action:group', visible: holds, actions }); + expect(screen.getByRole('button', { name: 'ALPHA' })).toBeInTheDocument(); + cleanup(); + mount({ type: 'action:group', visible: fails, actions }); + expect(screen.queryByRole('button', { name: 'ALPHA' })).toBeNull(); + }); + + it('`size` sizes every inline member that sets none, and the dropdown trigger', () => { + mount({ type: 'action:group', size: 'lg', actions: [member('alpha')] }); + expect(screen.getByRole('button', { name: 'ALPHA' }).className).toContain('h-11'); + cleanup(); + // Control: no group size — the inline member falls back to `sm`. + mount({ type: 'action:group', actions: [member('alpha')] }); + expect(screen.getByRole('button', { name: 'ALPHA' }).className).toContain('h-9'); + cleanup(); + mount({ type: 'action:group', display: 'dropdown', label: 'Group', size: 'icon', actions: [member('alpha')] }); + expect(screen.getByRole('button', { name: /Group/ }).className).toContain('w-10'); + cleanup(); + mount({ type: 'action:group', display: 'dropdown', label: 'Group', actions: [member('alpha')] }); + expect(screen.getByRole('button', { name: /Group/ }).className).not.toContain('w-10'); + }); +}); + +describe('action:group — the member shape of `actions` (the MEMBER-PIN direction)', () => { + it('inline: `label` is the text, `visible` / `disabled` gate THAT member, `variant` / `size` / `className` style it', () => { + mount({ + type: 'action:group', + size: 'sm', + actions: [ + member('shown'), + member('hidden', { visible: false }), + member('greyed', { disabled: true }), + member('loud', { variant: 'destructive', size: 'lg', className: 'member-class' }), + ], + }); + expect(screen.getByRole('button', { name: 'SHOWN' })).toBeEnabled(); + expect(screen.queryByRole('button', { name: 'HIDDEN' })).toBeNull(); + expect(screen.getByRole('button', { name: 'GREYED' })).toBeDisabled(); + const loud = screen.getByRole('button', { name: 'LOUD' }); + expect(loud.className).toContain('bg-destructive'); + // A member's own `size` outranks the group's. + expect(loud.className).toContain('h-11'); + expect(loud.className).toContain('member-class'); + expect(screen.getByRole('button', { name: 'SHOWN' }).className).toContain('h-9'); + }); + + it('a click runs the member through the runner by its OWN `type`, carrying its own keys', async () => { + mount({ + type: 'action:group', + actions: [member('go', { target: 'member-target', objectName: 'account' })], + }); + fireEvent.click(screen.getByRole('button', { name: 'GO' })); + await waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + const def = run.mock.calls[0][0]; + expect(def).toMatchObject({ type: 'run', name: 'go', target: 'member-target', objectName: 'account' }); + }); + + it('dropdown: a member with no `label` falls back to its `name`, and `tags: [separator-before]` draws a divider', async () => { + mount({ + type: 'action:group', + display: 'dropdown', + label: 'Group', + actions: [ + member('first'), + { name: 'nameonly', type: 'run', tags: ['separator-before'] }, + ], + }); + const menu = await openMenu(screen.getByRole('button', { name: /Group/ })); + expect(within(menu).getByRole('menuitem', { name: 'FIRST' })).toBeInTheDocument(); + expect(within(menu).getByRole('menuitem', { name: 'nameonly' })).toBeInTheDocument(); + expect(within(menu).getAllByRole('separator')).toHaveLength(1); + }); +}); + +// ── action:menu ───────────────────────────────────────────────────────────── + +describe('action:menu — the published inputs (objectui#11168)', () => { + it('publishes `size` and `visible` with the arms the spec accepts', () => { + const inputs = inputsOf('action:menu'); + expect([...(inputs.get('size')?.enum ?? [])].sort()).toEqual(['default', 'icon', 'lg', 'sm']); + expect(inputs.get('visible')?.type).toEqual(['boolean', 'string', 'object']); + for (const value of inputs.get('size')?.enum ?? []) { + expect(specRow('action:menu').safeParse({ size: value }).success, `size ${String(value)}`).toBe(true); + } + }); + + it('`actions` is published as a LIST of objects — the `object` kind the spec refuses is gone', () => { + const actions = inputsOf('action:menu').get('actions'); + expect(actions?.type).toBe('array'); + expect(actions?.of).toBe('object'); + expect(refusal('action:menu', { actions: { a: member('a') } })).toEqual([ + { code: 'invalid_type', path: 'actions' }, + ]); + expect(specRow('action:menu').safeParse({ actions: [member('a')] }).success).toBe(true); + }); +}); + +describe('action:menu — each published key is honoured through `SchemaRenderer`', () => { + it('`size` sizes the trigger; without it the trigger is icon-sized', () => { + mount({ type: 'action:menu', size: 'lg', actions: [member('alpha')] }); + expect(screen.getByRole('button', { name: MORE }).className).toContain('h-11'); + cleanup(); + mount({ type: 'action:menu', actions: [member('alpha')] }); + expect(screen.getByRole('button', { name: MORE }).className).toContain('h-8 w-8'); + }); + + it.each([ + ['a boolean', true, false], + ['a bare CEL expression', 'data.status == "draft"', 'data.status == "published"'], + ['the `{ dialect, source }` envelope', HOLDS, FAILS], + ])('`visible` as %s hides the whole menu when it fails', (_arm, holds, fails) => { + mount({ type: 'action:menu', visible: holds, actions: [member('alpha')] }); + expect(screen.getByRole('button', { name: MORE })).toBeInTheDocument(); + cleanup(); + mount({ type: 'action:menu', visible: fails, actions: [member('alpha')] }); + expect(screen.queryByRole('button', { name: MORE })).toBeNull(); + }); +}); + +describe('action:menu — the member shape of `actions` (the MEMBER-PIN direction)', () => { + it('each member is one item: `label` (else `name`), its own `visible` / `disabled`, `tags`, `variant`', async () => { + mount({ + type: 'action:menu', + actions: [ + member('shown'), + member('hidden', { visible: false }), + member('greyed', { disabled: true }), + { name: 'nameonly', type: 'run', tags: ['separator-before'], variant: 'destructive' }, + ], + }); + const menu = await openMenu(screen.getByRole('button', { name: MORE })); + expect(within(menu).getByRole('menuitem', { name: 'SHOWN' })).not.toHaveAttribute('data-disabled'); + expect(within(menu).queryByRole('menuitem', { name: 'HIDDEN' })).toBeNull(); + expect(within(menu).getByRole('menuitem', { name: 'GREYED' })).toHaveAttribute('data-disabled'); + const tail = within(menu).getByRole('menuitem', { name: 'nameonly' }); + expect(tail.className).toContain('text-destructive'); + expect(within(menu).getAllByRole('separator')).toHaveLength(1); + }); + + it('selecting an item runs the member through the runner by its OWN `type`, carrying its own keys', async () => { + mount({ type: 'action:menu', actions: [member('go', { target: 'member-target', objectName: 'account' })] }); + const menu = await openMenu(screen.getByRole('button', { name: MORE })); + fireEvent.click(within(menu).getByRole('menuitem', { name: 'GO' })); + await waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + expect(run.mock.calls[0][0]).toMatchObject({ type: 'run', name: 'go', target: 'member-target', objectName: 'account' }); + }); +}); diff --git a/packages/components/src/renderers/action/action-button.tsx b/packages/components/src/renderers/action/action-button.tsx index d493846288..fce9b14a0a 100644 --- a/packages/components/src/renderers/action/action-button.tsx +++ b/packages/components/src/renderers/action/action-button.tsx @@ -396,6 +396,21 @@ ComponentRegistry.register('button', ActionButtonRenderer, { namespace: 'action', skipFallback: true, label: 'Action Button', + // objectui#11168 slice 1 — the keys after `className` are the spec keys this + // block's `ComponentPropsMap` row declares, each published because it was + // measured HONOURED through the block path: the real `SchemaRenderer` hands + // it to this renderer, which consumes it or forwards it, and the runner (or + // the handler it dispatches to) acts on the forwarded value. The pins live + // in `__tests__/action-button-icon-inputs-11168.test.tsx`. `action:icon` + // publishes the same list minus `recordIdField`, which its spec row does not + // declare (and its renderer does not forward); the two are kept literal so + // the source readers that census registrations can still name every entry. + // + // Two spec keys stay unpublished, each with its measurement on objectui#11168: + // `endpoint` (the runner's built-in `api` executor reads it, but the console + // registers its own `api` handler, which reads `target` and never `endpoint`) + // and `undoable` (the runner's update path offers Undo only with a host row + // stash this block never writes). inputs: [ { name: 'name', type: 'string' }, { name: 'label', type: 'string' }, @@ -417,6 +432,122 @@ ComponentRegistry.register('button', ActionButtonRenderer, { enum: ['sm', 'md', 'lg'], }, { name: 'className', type: 'string' }, + { + name: 'visible', + type: ['boolean', 'string', 'object'], + description: + 'Visibility predicate: `true`/`false`, a bare CEL expression, or the `{ dialect: \'cel\', source }` envelope, evaluated against the row the host binds; the button is not rendered when it is false, nor when the predicate fails to evaluate. Omit for always-visible', + }, + { + name: 'disabled', + type: ['boolean', 'string', 'object'], + description: + 'Disabled predicate, in the same three shapes as `visible`: the button is shown but cannot be pressed while it holds. Omit for never-disabled', + }, + { + name: 'params', + type: 'array', + description: + 'The parameters to collect from the user before the action runs (`ActionParam` objects: `name`, `type`, `label`, …); each collected value reaches the executor under its parameter `name`. Static execution values ride `properties.params` instead', + }, + { + name: 'description', + type: 'string', + description: 'Action description — the parameter dialog shows it under its title', + }, + { + name: 'openIn', + type: 'enum', + enum: ['self', 'new-tab'], + description: 'For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab', + }, + { + name: 'method', + type: 'string', + description: 'HTTP method of an `api` action (`POST` when omitted)', + }, + { + name: 'bodyExtra', + type: 'object', + description: + 'Static request-body fields of an `api` action, merged last, so a constant here overrides a collected value of the same name', + }, + { + name: 'bodyShape', + type: ['enum', 'object'], + enum: ['flat'], + description: + 'How an `api` action shapes its request body: `flat` (the default) or `{ wrap: KEY }` to nest the collected values under that key, with `bodyExtra` beside it', + }, + { + name: 'operation', + type: 'enum', + enum: ['update'], + description: + '`update` declares a single-record field write: the runner dispatches it to the platform action route with `patch` merged under the collected values', + }, + { + name: 'patch', + type: 'object', + description: + 'For `operation: update`: the field values written, merged UNDER the values the user supplies, so a collected value of the same name wins', + }, + { + name: 'confirmText', + type: 'string', + description: 'Confirmation question asked before the action runs; the action runs only if it is confirmed', + }, + { + name: 'successMessage', + type: 'string', + description: 'Toast text when the action succeeds (a message the server returns takes precedence)', + }, + { + name: 'errorMessage', + type: 'string', + description: 'Toast text when the action fails, in place of the raw error', + }, + { + name: 'refreshAfter', + type: 'boolean', + description: 'Refresh the surrounding data after the action succeeds', + }, + { + name: 'locations', + type: 'array', + of: 'string', + description: + 'The placements the action declares (`list_toolbar`, `list_item`, `record_header`, …). A `script` or `flow` action declared record-scoped (`list_item`, `record_header`, `record_more` or `record_section`) refuses to run when no record or single selection is in scope, instead of running without one', + }, + { + name: 'toast', + type: 'object', + description: + 'Toast behaviour: `{ showOnSuccess?, showOnError?, duration? }` — `false` suppresses that toast, `duration` is handed to the toast', + }, + { + name: 'resultDialog', + type: 'object', + description: + 'One-shot result dialog for a value the response shows exactly once (a 2FA code, a fresh secret); it replaces the success toast', + }, + { + name: 'onSuccess', + type: 'object', + description: + 'Post-success navigation `{ navigate, openIn? }`: `navigate` is a route template that can read `${result.*}`, `openIn` is `self` (the default) or `newTab`', + }, + { + name: 'objectName', + type: 'string', + description: "Object the action acts on — dispatch goes to this object instead of the page's. Omit to act on the page's object", + }, + { + name: 'recordIdField', + type: 'string', + description: + 'For a `script` action run against a single selected row: the row field whose value is sent as the record id (default `id`)', + }, ], defaultProps: { label: 'Action', diff --git a/packages/components/src/renderers/action/action-group.tsx b/packages/components/src/renderers/action/action-group.tsx index 8be5c1c222..26b31ec9a2 100644 --- a/packages/components/src/renderers/action/action-group.tsx +++ b/packages/components/src/renderers/action/action-group.tsx @@ -20,7 +20,7 @@ import React, { forwardRef, useCallback, useState } from 'react'; import { ComponentRegistry } from '@object-ui/core'; import type { ActionDef } from '@object-ui/core'; import type { UIActionSchema, ActionLocation } from '@object-ui/types'; -import { actionRendersAt } from '@object-ui/types'; +import { ACTION_LOCATIONS, actionRendersAt } from '@object-ui/types'; import { useAction } from '@object-ui/react'; import { useCondition, toPredicateInput, usePredicateRecordContext, useConfigBagEvaluator } from '@object-ui/react'; import { Button } from '../../ui'; @@ -37,10 +37,11 @@ import { resolveIcon } from './resolve-icon'; import { hasDeclaredVisibilityGate } from './visibility-gate'; import { readActionEntryParamValues, readMemberStaticParamValues } from './static-params'; +// No group-level `name` (objectui#11168): nothing renders, forwards or keys on +// it, and `@objectstack/spec` refuses it on this block, so the registration +// below stopped publishing it. Each MEMBER's own `name` is what identifies it. export interface ActionGroupSchema { type: 'action:group'; - /** Group name */ - name?: string; /** Group label */ label?: string; /** Group icon */ @@ -51,8 +52,8 @@ export interface ActionGroupSchema { display?: 'dropdown' | 'inline'; /** Filter actions by location */ location?: ActionLocation; - /** Group visibility condition */ - visible?: string; + /** Group visibility predicate: a boolean, a CEL string, or a `{ dialect, source }` envelope */ + visible?: boolean | string | { dialect: string; source?: string }; /** Button variant for inline actions */ variant?: string; /** Button size for inline actions */ @@ -411,16 +412,41 @@ ComponentRegistry.register('group', ActionGroupRenderer, { namespace: 'action', skipFallback: true, label: 'Action Group', + // objectui#11168 slice 1 — each key below was decided by measuring what this + // renderer reads through the real `SchemaRenderer`, against the installed + // `ComponentPropsMap['action:group']` row; the pins live in + // `__tests__/action-group-menu-inputs-11168.test.tsx`. + // + // - `name` is NOT published: nothing reads a group-level `name` (inline mode + // only leaks it onto the wrapping `
`), and the spec refuses it here. + // - `actions` is a LIST of action objects: the renderer reads + // `schema.actions || []` and then `.filter` / `.map`, and the spec refuses + // the `object` kind this entry used to declare. + // - `size` publishes the Button primitive's four sizes, the set the spec + // accepts. `md` is not one of them; the renderer still maps a stored `md` + // to `default` in dropdown mode, as a back-compat read. inputs: [ - { name: 'name', type: 'string' }, { name: 'label', type: 'string' }, { name: 'icon', type: 'string' }, - { name: 'actions', type: 'object' }, + { + name: 'actions', + type: 'array', + of: 'object', + description: + 'The actions in this group, in order. Each member is an action object the group draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `locations`, `visible`, `disabled`, …); a member\'s executor is its own `type`', + }, { name: 'display', type: 'enum', enum: ['inline', 'dropdown'], }, + { + name: 'location', + type: 'enum', + enum: [...ACTION_LOCATIONS], + description: + 'Render only the members whose `locations` include this location. Omit to render every member', + }, { name: 'variant', type: 'enum', @@ -429,7 +455,15 @@ ComponentRegistry.register('group', ActionGroupRenderer, { { name: 'size', type: 'enum', - enum: ['sm', 'md', 'lg'], + enum: ['default', 'sm', 'lg', 'icon'], + description: + 'Button size for the dropdown trigger and for every inline member that sets none', + }, + { + name: 'visible', + type: ['boolean', 'string', 'object'], + description: + 'Visibility predicate for the whole group: `true`/`false`, a bare CEL expression, or the `{ dialect: \'cel\', source }` envelope, evaluated against the row the host binds. Omit for always-visible', }, { name: 'className', type: 'string' }, ], diff --git a/packages/components/src/renderers/action/action-icon.tsx b/packages/components/src/renderers/action/action-icon.tsx index 4fe557d05e..8700de8f7d 100644 --- a/packages/components/src/renderers/action/action-icon.tsx +++ b/packages/components/src/renderers/action/action-icon.tsx @@ -304,6 +304,13 @@ ComponentRegistry.register('icon', ActionIconRenderer, { namespace: 'action', skipFallback: true, label: 'Action Icon', + // objectui#11168 slice 1 — the keys after `className` are the spec keys this + // block's `ComponentPropsMap` row declares, each published because it was + // measured HONOURED through the block path, exactly as on `action:button` + // (see its registration). The pins live in + // `__tests__/action-button-icon-inputs-11168.test.tsx`. `endpoint` stays + // unpublished for the reason recorded there: the console's own `api` + // handler reads `target` and never `endpoint`. inputs: [ { name: 'name', type: 'string' }, { name: 'label', type: 'string' }, @@ -320,6 +327,117 @@ ComponentRegistry.register('icon', ActionIconRenderer, { enum: ['default', 'secondary', 'destructive', 'outline', 'ghost'], }, { name: 'className', type: 'string' }, + { + name: 'visible', + type: ['boolean', 'string', 'object'], + description: + 'Visibility predicate: `true`/`false`, a bare CEL expression, or the `{ dialect: \'cel\', source }` envelope, evaluated against the row the host binds; the icon is not rendered when it is false. Omit for always-visible', + }, + { + name: 'disabled', + type: ['boolean', 'string', 'object'], + description: + 'Disabled predicate, in the same three shapes as `visible`: the icon is shown but cannot be pressed while it holds. Omit for never-disabled', + }, + { + name: 'params', + type: 'array', + description: + 'The parameters to collect from the user before the action runs (`ActionParam` objects: `name`, `type`, `label`, …); each collected value reaches the executor under its parameter `name`. Static execution values ride `properties.params` instead', + }, + { + name: 'description', + type: 'string', + description: + 'Action description — the tooltip text when there is no `label`, and what the parameter dialog shows under its title', + }, + { + name: 'openIn', + type: 'enum', + enum: ['self', 'new-tab'], + description: 'For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab', + }, + { + name: 'method', + type: 'string', + description: 'HTTP method of an `api` action (`POST` when omitted)', + }, + { + name: 'bodyExtra', + type: 'object', + description: + 'Static request-body fields of an `api` action, merged last, so a constant here overrides a collected value of the same name', + }, + { + name: 'bodyShape', + type: ['enum', 'object'], + enum: ['flat'], + description: + 'How an `api` action shapes its request body: `flat` (the default) or `{ wrap: KEY }` to nest the collected values under that key, with `bodyExtra` beside it', + }, + { + name: 'operation', + type: 'enum', + enum: ['update'], + description: + '`update` declares a single-record field write: the runner dispatches it to the platform action route with `patch` merged under the collected values', + }, + { + name: 'patch', + type: 'object', + description: + 'For `operation: update`: the field values written, merged UNDER the values the user supplies, so a collected value of the same name wins', + }, + { + name: 'confirmText', + type: 'string', + description: 'Confirmation question asked before the action runs; the action runs only if it is confirmed', + }, + { + name: 'successMessage', + type: 'string', + description: 'Toast text when the action succeeds (a message the server returns takes precedence)', + }, + { + name: 'errorMessage', + type: 'string', + description: 'Toast text when the action fails, in place of the raw error', + }, + { + name: 'refreshAfter', + type: 'boolean', + description: 'Refresh the surrounding data after the action succeeds', + }, + { + name: 'locations', + type: 'array', + of: 'string', + description: + 'The placements the action declares (`list_toolbar`, `list_item`, `record_header`, …). A `script` or `flow` action declared record-scoped (`list_item`, `record_header`, `record_more` or `record_section`) refuses to run when no record or single selection is in scope, instead of running without one', + }, + { + name: 'toast', + type: 'object', + description: + 'Toast behaviour: `{ showOnSuccess?, showOnError?, duration? }` — `false` suppresses that toast, `duration` is handed to the toast', + }, + { + name: 'resultDialog', + type: 'object', + description: + 'One-shot result dialog for a value the response shows exactly once (a 2FA code, a fresh secret); it replaces the success toast', + }, + { + name: 'onSuccess', + type: 'object', + description: + 'Post-success navigation `{ navigate, openIn? }`: `navigate` is a route template that can read `${result.*}`, `openIn` is `self` (the default) or `newTab`', + }, + { + name: 'objectName', + type: 'string', + description: "Object the action acts on — dispatch goes to this object instead of the page's. Omit to act on the page's object", + }, ], defaultProps: { icon: 'play', diff --git a/packages/components/src/renderers/action/action-menu.tsx b/packages/components/src/renderers/action/action-menu.tsx index ed1081f493..c2724e9ef9 100644 --- a/packages/components/src/renderers/action/action-menu.tsx +++ b/packages/components/src/renderers/action/action-menu.tsx @@ -56,8 +56,8 @@ export interface ActionMenuSchema { variant?: string; /** Trigger size */ size?: string; - /** Visibility condition */ - visible?: string; + /** Visibility predicate: a boolean, a CEL string, or a `{ dialect, source }` envelope */ + visible?: boolean | string | { dialect: string; source?: string }; /** Custom CSS class */ className?: string; [key: string]: any; @@ -407,15 +407,39 @@ ComponentRegistry.register('menu', ActionMenuRenderer, { namespace: 'action', skipFallback: true, label: 'Action Menu', + // objectui#11168 slice 1 — each key below was decided by measuring what this + // renderer reads through the real `SchemaRenderer`, against the installed + // `ComponentPropsMap['action:menu']` row; the pins live in + // `__tests__/action-group-menu-inputs-11168.test.tsx`. `actions` is a LIST of + // action objects (the renderer reads `schema.actions || []`, then `.map`), + // and the spec refuses the `object` kind this entry used to declare. inputs: [ { name: 'label', type: 'string' }, { name: 'icon', type: 'string' }, - { name: 'actions', type: 'object' }, + { + name: 'actions', + type: 'array', + of: 'object', + description: + 'The menu\'s actions, in order. Each member is an action object the menu draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `visible`, `disabled`, `tags`, …); a member\'s executor is its own `type`', + }, { name: 'variant', type: 'enum', enum: ['default', 'secondary', 'outline', 'ghost'], }, + { + name: 'size', + type: 'enum', + enum: ['default', 'sm', 'lg', 'icon'], + description: 'Trigger button size (renderer default: `icon`)', + }, + { + name: 'visible', + type: ['boolean', 'string', 'object'], + description: + 'Visibility predicate for the whole menu: `true`/`false`, a bare CEL expression, or the `{ dialect: \'cel\', source }` envelope, evaluated against the row the host binds; a predicate that fails to evaluate hides it. Omit for always-visible', + }, { name: 'className', type: 'string' }, ], defaultProps: {