Skip to content

Commit f3a52d3

Browse files
fix(spec)!: action:icon and the action:group / action:menu members refuse confirmText beside a non-empty array params, as action:button does (#30624)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuV5cRafodM2YQHxeDkdiWJM)_ domain:spec#2 · os-sales · session_01L6bhgVugtCCwBs98FeR27P Fixes #30017 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — no `skills/**` file teaches an `action:icon`, or an `action:group` / `action:menu` member, carrying `confirmText` beside an array `params` (`git grep` of `skills/` for `action:icon|action:group|action:menu`: 0 hits). The one rule line about the pair (`skills/objectstack-ui/rules/actions.md` L22, "A registered action is refused it beside a non-empty `params` (put the question on `description`)") stays true and names the remedy this refusal prescribes. The generated `content/docs/references/ui/component.mdx` rows that render the three `confirmText` describes are regenerated here. **BREAKING** accept-set narrowing at `ActionIconPropsSchema` and the `action:group` / `action:menu` member rows (`@objectstack/spec`), served where a page's component `properties` are judged: the component-props gate (`component-props-invalid`, `@objectstack/lint`) at every CLI command and at the runtime `page` save door. ## 维护者速读 - 改了什么:页面上的 `action:icon`,以及 `action:group` / `action:menu` 里的每个成员,同时写 `confirmText` 和非空数组 `params` 时,现在和 `action:button`、已注册动作、内联动作一样被拒收,提示把这句话挪到 `description`、删掉 `confirmText`。 - 为什么改:您在「Discuss pending spec rulings」线程对本卡裁定 A(「以上同意」)。这两类块把两键都交给同一个执行器,先弹确认框、再弹参数框,一个决定两个对话框;五道门现在用同一条判据、同一句提示。 - 风险与回滚:仓内普查 0 处已发运的载体,唯一命中是 spec 自己的一个测试夹具,本 PR 已改;不带 `params`、`params: []`、对象形 `params`(图标的静态值;`type: 'api'` 成员的请求体,都不弹参数框)照常通过。已存页面照常加载,下次保存才被拒。回滚即 revert 本 PR。 - 先看:`packages/spec/src/ui/component.zod.ts`(`ActionIconPropsSchema` 链尾与 `buildActionGroupMember` / `buildActionMenuMember` 链尾新增的 `.refine`),`packages/spec/src/ui/action-confirm-params-guard.test.ts` 末尾新的 describe 组,`packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts` 改过的夹具。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/ui/action-confirm-params-guard.test.ts`,预期 29 个测试全过,其中新组 8 条(5 条拒收、3 条对照)。 - 需要你做的:无,席位落地。 ## What changes - **One predicate, one refusal, five doors.** `ActionIconPropsSchema` gains `.refine((data) => !declaresConfirmBesideParams(data), CONFIRM_BESIDE_PARAMS_REFUSAL)` at the end of its chain, exactly as `ActionButtonPropsSchema` carries it. The `action:group` and `action:menu` members each gain the same refine at the end of their builder's chain (`buildActionGroupMember`, `buildActionMenuMember`, the two consumers of `actionContainerMemberShape()`), so the issue lands on the member, at `actions.N.confirmText`. No second predicate or message: both are imported from `packages/spec/src/ui/action-confirm-beside-params.ts`, whose predicate and message are unchanged (the shared message needed no member-path variant: its remedy key, `description`, is declared on the icon and on both members); only its docblock now counts the five doors that read it. - **The shared predicate already fits the ruling.** Its `Array.isArray(params) && params.length > 0` read refuses a non-empty array and leaves `params: []` and an object `params` alone: on `action:icon` an object is the static parameter values, on a member it is a `type: 'api'` member's request payload (a non-`api` member's object `params` stays refused by `actionContainerMemberParamsFitType`, unchanged). - **The `confirmText` describes** of `action:icon` and of the container member shape now append the sentence `action:button`'s carries: refused beside a non-empty array `params`, correct with no `params`, beside `params: []` and beside an object `params` (the member's wording names the `type: 'api'` payload). `content/docs/references/ui/component.mdx` is regenerated from them (`check:generated --fix`, which proved only `check:docs` stale). - **Census carrier repaired.** `component-report-items-action-members-typed.pin.test.ts` §1 "a member with every forwarded scalar" (`action:menu`) carried `confirmText` beside an array `params`; its `params` moves to a new byte-identical case, "a member collecting params", which carries the question on `description`. - **Changeset** `.changeset/30017-action-icon-members-confirm-beside-params-refused.md`: `@objectstack/spec` minor, both declaration lines, the ADR-0087 disposition marker (`registered`), the narrowing banner, before / now / unchanged / who, and the one-line fix naming the D3 entry. - **Step-18 D3 entry** `packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-confirm-text-beside-params-refused.ts` (added in the patch round, head `9d69f747cd`): one `SemanticMigration` naming all four page action rows (`properties.confirmText` beside a non-empty array `properties.params` on an `action:button` / `action:icon`, and `confirmText` beside a non-empty array `params` on each `action:group` / `action:menu` member), replacement on `description`, NOT mechanically convertible, acceptance as `os validate` reporting no `component-props-invalid` at those paths. It also covers the `action:button` refusal PR #29869 shipped without an entry. ### ADR-0087 disposition The changeset body carries the ADR-0087 marker comment with the disposition `adr-0087: registered ui-page-action-row-confirm-text-beside-params-refused` (patch round, after contract review 6666209629: the refusal prescribes a hand rewrite, so ADR-0087 D3 owes an entry rather than an exemption). `node scripts/check-adr-0087-registration.mjs --base origin/main` exits 0 and reads it as `registered`, the id new in this diff. No `docs/adr/**` path is touched. The first head carried `not-required (no-migration-prescription)`; that marker is gone. ## Verification All readings at head `0446795a12` (the branch merged with `origin/main` `5b748dfede`) unless a line says otherwise; the patch-round readings at head `9d69f747cd` are in the os-dev-report patch-round comment on the card; the implementation commits are `0c6f39767c`, `73fb6b963e` (regenerated reference), `34eb15a15d` (envelope pin). ### Census at the base (`origin/main` `2ffdb649`) - **AST pass** over every tracked `*.ts` / `*.tsx` / `*.js` / `*.mjs` / `*.cjs` / `*.mts` (no `dist/`, no `.d.ts`): 213 files contain `confirmText`; every object literal carrying both `confirmText` and a `params` that is not an empty array or an object literal was listed with the nearest enclosing `type` / `actionType` and the nearest enclosing array or call naming an `action:icon` / `action:group` / `action:menu` literal (the tuple-shaped fixtures). 48 such literals; 4 inside an action-block context, each read by hand: - `packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts` L217: an `action:menu` member (`type: 'api'`) with `confirmText` beside `params: [{ name: 'reason', … }]`. **The one carrier**; a test fixture, repaired here (above). - `packages/lint/src/validate-action-copy-templates.test.ts` L131: an `action:button` fixture (the `action:group` literal is a later array element); it feeds the copy-template rule, which does not parse these rows; the lint suite runs it green. - `packages/spec/src/ui/action.zod.ts` L1156 and `component.zod.ts` L4138: schema declarations, not carriers. - **Non-code pass** over tracked `*.json` / `*.yaml` / `*.yml` / `*.md` / `*.mdx` naming both `confirmText` and one of the three blocks (`CHANGELOG.md`, generated references, `json-schema/`, `api-surface`, `sdui.manifest.json` excluded): `content/docs/frontend/layout.mdx`, `content/docs/protocol/objectui/actions.mdx`, `content/docs/ui/components/action.mdx`, `packages/spec/liveness/action.json`; each read by hand — prose and key tables, no example carrying the pair. - Cloud fixtures are not in this repository and were not read. **Disposition: 0 shipped carriers, 1 test fixture (repaired).** ### Before / after at the authoring door `validateComponentProps` (`@objectstack/lint` src against this branch's spec `dist`), one page per case: | case | findings | |:---|:---| | `action:icon` with `confirmText` + array `params` | 1 · `component-props-invalid` · `error` · `…components[0].properties.confirmText` · "confirmText: An action that declares `confirmText` beside a non-empty `params` shows the u…" | | `action:group` member, same pair | 1 · `component-props-invalid` · `error` · `…components[0].properties.actions.0.confirmText` | | `action:menu` member, same pair | 1 · `component-props-invalid` · `error` · `…components[0].properties.actions.0.confirmText` | | menu member, array `params` + `description` (the remedy) | 0 | | menu member, `confirmText` + `params: []` | 0 | | `type: 'api'` menu member, `confirmText` + object `params` | 0 | | `action:icon`, `confirmText`, no `params` | 0 | Before: the ablation below is the base's behaviour for these three rows (the refine is the only change on them), and it parses the pair clean. ### Pins (`packages/spec/src/ui/action-confirm-params-guard.test.ts`, new top-level describe) - Refusals (5): `action:icon` — one issue, `code: 'custom'`, `path: ['confirmText']`, message `toBe` `action:button`'s issue for the same pair, and contains `TWO dialogs`, `` `description` `` and `param-LESS`; `action:group` and `action:menu` members — one issue, `code: 'custom'`, `path: ['actions', 1, 'confirmText']` (the second member), message `toBe` `action:button`'s; and each member on the template-envelope `confirmText` form, `[['custom', 'actions.1.confirmText']]`. - Preservation (3): `action:icon` and each container member accept `confirmText` with no `params`, beside `params: []`, beside an object `params` (`type: 'api'` on the members), and the remedy (array `params` + `description`), each a full `safeParse` success. ### Ablation (committed state, `node scripts/ablation-replace.mjs`, test source read from `src/`) <details><summary>three legs, each red then restored</summary> | leg (refine removed) | on-disk proof | result | restore | |:---|:---|:---|:---| | `ActionIconPropsSchema` | anchor 1 → 0, blob `3eec6db7ac20` → `ff6c2420ddbb` | 1 failed / 28 passed: the `action:icon` refusal | blob == HEAD, `git diff HEAD` empty | | `buildActionGroupMember` | anchor 1 → 0, blob → `e77714c1e61d` | 2 failed / 27 passed: the group refusal and its envelope pin | blob == HEAD, `git diff HEAD` empty | | `buildActionMenuMember` | anchor 1 → 0, blob → `aeb719fd841f` | 2 failed / 27 passed: the menu refusal and its envelope pin | blob == HEAD, `git diff HEAD` empty | After the three legs, from the committed state: 29 / 29 passed. A first member run whose replacement text was a substring of its anchor was refused by the tool before running (replacement count 1 → 1), restored, and rerun with a distinct replacement; it is not counted above. </details> ### Tests | command | result | |:---|:---| | `pnpm --filter @objectstack/spec test` (merged head) | 816 files passed · 25669 tests passed, 1 todo | | `pnpm --filter @objectstack/spec typecheck` (merged head) | exit 0 | | `pnpm --filter @objectstack/lint exec vitest run` (head `34eb15a15d`, lint closure built) | 195 files · 8087 passed, 5 skipped | | `pnpm --filter @object-ui/types test` (head `34eb15a15d`) | 387 files · 10464 passed | | `@object-ui/components` `spec-symbol-action-renderer-props-11073.test.ts` + `src/renderers/action/` (head `34eb15a15d`) | 38 files · 616 passed | | `pnpm --filter @objectstack/dogfood test` (head `34eb15a15d`, closure built) | 420 files passed, 3 skipped · 3496 passed, 36 skipped | | `npx eslint --no-inline-config` on the three changed `.ts` files | exit 0 | ### Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives the same 102 families at `34eb15a15d` and at `0446795a12`. Exit codes are captured before any pipe. <details><summary>gate table, head 0446795 (102 families, exit codes)</summary> | family | exit | |:---|:---| | `node scripts/check-adr-0087-registration.mjs --base origin/main` | 0 | | `node scripts/check-adr-0087-registration.mjs --self-test` | 0 | | `node scripts/check-adr-links.mjs` | 0 | | `node scripts/check-adr-links.mjs --self-test` | 0 | | `node scripts/check-changeset-no-major.mjs --base origin/main` | 0 | | `node scripts/check-closing-keyword-parity.mjs` | 0 | | `node scripts/check-closing-keyword-parity.mjs --self-test` | 0 | | `node scripts/check-comment-mask-adoption.mjs` | 0 | | `node scripts/check-comment-mask-adoption.mjs --self-test` | 0 | | `node scripts/check-comment-mask-corpus.mjs` | 0 | | `node scripts/check-dev-prereqs.mjs --self-test` | 0 | | `node scripts/check-doc-route-spelling.mjs --advisory` | 0 | | `node scripts/check-doc-route-spelling.mjs --self-test` | 0 | | `node scripts/check-docs-section-name.mjs` | 0 | | `node scripts/check-docs-section-name.mjs --self-test` | 0 | | `node scripts/check-dts-emitted.mjs --self-test` | 0 | | `node scripts/check-empty-changeset.mjs --base origin/main` | 0 | | `node scripts/check-empty-changeset.mjs --self-test` | 0 | | `node scripts/check-issue-citations.mjs` | 0 | | `node scripts/check-plugin-teardown-shape.mjs` | 0 | | `node scripts/check-plugin-teardown-shape.mjs --self-test` | 0 | | `node scripts/check-section-landing-index.mjs` | 0 | | `node scripts/check-section-landing-index.mjs --self-test` | 0 | | `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` | 0 | | `node scripts/ci/plan-test-core-shards.mjs --self-test` | 0 | | `node scripts/ci/prune-turbo-cache.mjs --self-test` | 0 | | `node scripts/docs-audit/check-affected-docs.mjs` | 0 | | `node scripts/docs-audit/check-drift-comment.mjs` | 0 | | `node scripts/pm/release-rehearsal-clone.mjs --self-test` | 0 | | `node scripts/release-pending-publish.mjs --self-test` | 0 | | `pnpm --filter @objectstack/lint run check:doc-formula-expressions` | 0 | | `pnpm --filter @objectstack/lint run check:doc-security-posture` | 0 | | `pnpm --filter @objectstack/spec run check:api-surface` | 0 | | `pnpm --filter @objectstack/spec run check:authorable-surface` | 0 | | `pnpm --filter @objectstack/spec run check:browser-reachable-entries` | 0 | | `pnpm --filter @objectstack/spec run check:docs` | 0 | | `pnpm --filter @objectstack/spec run check:dual-source-exports` | 0 | | `pnpm --filter @objectstack/spec run check:duration-unit-keys` | 0 | | `pnpm --filter @objectstack/spec run check:empty-state` | 0 | | `pnpm --filter @objectstack/spec run check:entry-nameability` | 0 | | `pnpm --filter @objectstack/spec run check:export-origins` | 0 | | `pnpm --filter @objectstack/spec run check:exported-any` | 0 | | `pnpm --filter @objectstack/spec run check:generated` | 0 | | `pnpm --filter @objectstack/spec run check:liveness` | 0 | | `pnpm --filter @objectstack/spec run check:llms-txt` | 0 | | `pnpm --filter @objectstack/spec run check:skill-examples` | 0 | | `pnpm --filter @objectstack/spec run check:skill-refs` | 0 | | `pnpm --filter @objectstack/spec run check:strictness-ledger` | 0 | | `pnpm --filter @objectstack/spec run check:variant-docs` | 0 | | `pnpm --filter @objectstack/spec run check:yaml-examples` | 0 | | `pnpm check:changeset-gate-self-tests` | 0 | | `pnpm check:ci-filter-parity` | 0 | | `pnpm check:closed-ceremony-text` | 0 | | `pnpm check:corpus-claim-drift` | 0 | | `pnpm check:cross-package-test-inputs` | 0 | | `pnpm check:dispatcher-error-vocabulary` | 0 | | `pnpm check:doc-anchors` | 0 | | `pnpm check:doc-authoring` | 0 | | `pnpm check:doc-frontmatter` | 0 | | `pnpm check:docs-audit-scope` | 0 | | `pnpm check:docs-redirects` | 0 | | `pnpm check:docs-single-h1` | 0 | | `pnpm check:docs-spec-enumerations` | 0 | | `pnpm check:docs-transcript-drift` | 0 | | `pnpm check:driver-memory-census` | 0 | | `pnpm check:dts-closure` | 0 | | `pnpm check:dual-build-cjs-loads` | 0 | | `pnpm check:engine-double-contract` | 0 | | `pnpm check:gitlink-declared` | 0 | | `pnpm check:issue-citations` | 0 | | `pnpm check:keyed-text-bounds` | 0 | | `pnpm check:lean-entry-closure` | 0 | | `pnpm check:logger-receiver-detach` | 0 | | `pnpm check:merge-driver` | 0 | | `pnpm check:nul-bytes` | 0 | | `pnpm check:objectql-double-limit` | 0 | | `pnpm check:org-identifier` | 0 | | `pnpm check:page-declaration-shape` | 0 | | `pnpm check:platform-object-tenancy-census` | 0 | | `pnpm check:pm-prior-rulings` | 0 | | `pnpm check:published-files` | 0 | | `pnpm check:published-readme-links` | 0 | | `pnpm check:query-options-erasure` | 0 | | `pnpm check:react-page-adapter-contract` | 0 | | `pnpm check:refd-timer-probe` | 0 | | `pnpm check:registry-log-declared` | 0 | | `pnpm check:rest-log-spy-declared` | 0 | | `pnpm check:role-word` | 0 | | `pnpm check:skill-identifier-liveness` | 0 | | `pnpm check:slot-lookup` | 0 | | `pnpm check:sourcemap-no-sources-content` | 0 | | `pnpm check:spec-docblock-symbol-anchors` | 0 | | `pnpm check:spec-parsed-alias` | 0 | | `pnpm check:system-context-census` | 0 | | `pnpm check:test-source-alias` | 0 | | `pnpm check:tier-file-adoption` | 0 | | `pnpm check:type-check-coverage` | 0 | | `pnpm check:type-check-debt` | 0 | | `pnpm check:undeclared-dep-imports` | 0 | | `pnpm check:vendor-version-stamps` | 0 | | `pnpm check:watch-hint-literal` | 0 | | `pnpm check:where-matcher` | 0 | `node scripts/pm/dispatch-gates.mjs --ran` on this record: **102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN** (a derived zero: all 102 lines carry an exit code). Also run: `pnpm check:adr-0087-registration` 0, `pnpm check:doc-authoring` 0, `pnpm check:skill-frame-freshness` 0, `pnpm check:dev-prereqs` 1 (see Acceptance notes). </details> ## Acceptance notes - **The predicate module's docblock** now counts the five doors that read it (patch round, surface widened by amendment 6666273551); the predicate and the message are unchanged. - **The `description` remedy on an `action:icon`.** With no `label`, an icon's `description` is also its tooltip text, so moving the confirm sentence there changes the tooltip too. The changeset's remedy says so; the shared message is unchanged. - **`check:dev-prereqs`** (local-only family) exits 1 in this worktree: 35 `ui/` workspace packages have no `dist/` because no full `pnpm build` ran here. It judges whether this checkout can boot a dev server, not this diff. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgVugtCCwBs98FeR27P <!-- signed: domain:spec#2 · os-sales · session_01L6bhgVugtCCwBs98FeR27P --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent af8aab1 commit f3a52d3

7 files changed

Lines changed: 234 additions & 19 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec)!: a page `action:icon` (`ActionIconPropsSchema`) and each `action:group` / `action:menu` member refuse `confirmText` beside a non-empty array `params`, as `action:button`, a registered action (`ActionSchema`) and an inline action (`InlineActionSchema`) already do, instead of accepting a pair that opens two dialogs for one decision
6+
7+
The `action:icon` block forwards its row's `confirmText` and an array `params` (as `actionParams`) to the console's action runner, and both containers do the same for each member. The runner awaits the confirm dialog and only then opens the param dialog, so the user saw a confirm that already read as "the action ran" while nothing had been sent, then a second dialog. The other three action doors refused that pair; these two accepted it. They now refuse it with the same predicate and the same message, at the `confirmText` path: `confirmText` on the icon's properties, `actions.N.confirmText` on a container member.
8+
9+
Clause-②: no (narrowing) — `ActionIconPropsSchema` and the `action:group` / `action:menu` member rows refuse `confirmText` beside a non-empty array `params`, which they accepted before. Nothing is widened.
10+
11+
Metadata-protocol: narrowed (a page `action:icon`, and an `action:group` or `action:menu` member, may no longer declare `confirmText` beside a non-empty array `params`)
12+
13+
<!-- adr-0087: registered ui-page-action-row-confirm-text-beside-params-refused -->
14+
15+
**BREAKING** accept-set narrowing at `ActionIconPropsSchema` and the `action:group` / `action:menu` member rows (`@objectstack/spec`), served where a page's component `properties` are judged: the component-props gate (`component-props-invalid`, `@objectstack/lint`) at every CLI command (`os validate`, `os compile`, the publish gate) and at the runtime `page` save door. `defineStack` does not parse a component's `properties` bag, so it does not refuse the pair.
16+
17+
- **Before.** `{ type: 'action:menu', properties: { actions: [{ name: 'reject', label: 'Reject', type: 'api', target: '/api/v1/approvals/reject', confirmText: 'Reject this request?', params: [{ name: 'reason', type: 'textarea' }] }] } }` parsed clean, and the console showed a confirm dialog and then the param dialog. The same held for an `action:icon` with both keys.
18+
- **Now.** The same `properties` are refused at `actions.0.confirmText` (at `confirmText` on an `action:icon`) with the action doors' message: the pair shows TWO dialogs for one decision; carry the question in `description` and drop `confirmText`.
19+
- **Unchanged.** `confirmText` with no `params`, beside `params: []`, or beside an object `params` (an icon's static parameter values; a `type: 'api'` member's request payload — neither opens a dialog) parses as before; so does an array `params` with `description`.
20+
- **Who sees it.** An author whose page declares an `action:icon`, or an `action:group` / `action:menu` member, with both keys. A stored page keeps loading and serving; its next save is refused, with the remedy in the finding.
21+
22+
**The one-line fix: move the confirm sentence to the action's `description`, which the param dialog draws under its title, and drop `confirmText`.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-confirm-text-beside-params-refused`, which names all four page action rows (`action:button`, `action:icon`, and each `action:group` / `action:menu` member) — the `action:button` refusal shipped earlier in 18.x is covered by the same entry. The rewrite is by hand, because whether the sentence reads as the dialog's description line is the author's call. On an `action:icon` with no `label`, `description` is also the tooltip text; give the icon a `label` to keep them apart.

‎content/docs/references/ui/component.mdx‎

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'ui-page-action-row-confirm-text-beside-params-refused',
7+
surface: 'the `confirmText` of a page action row beside a non-empty array `params`: `properties.confirmText` '
8+
+ 'beside a non-empty array `properties.params` on an `action:button` or `action:icon`, and `confirmText` '
9+
+ 'beside a non-empty array `params` on each `action:group` / `action:menu` member of `properties.actions`. '
10+
+ 'Not `params: []`, not an object `params` (an `action:button` / `action:icon` row\'s static parameter '
11+
+ 'values, a `type: \'api\'` member\'s request payload), and not a row with no `params`',
12+
replacement: 'the confirm question on the row\'s `description`, which the param dialog draws under its title, '
13+
+ 'and no `confirmText`: one dialog that asks the question and collects the inputs. A row that collects '
14+
+ 'nothing keeps its `confirmText` and drops the `params`. ⛔ NOT mechanically convertible: whether the '
15+
+ 'confirm sentence reads as the param dialog\'s description line, or should be reworded, is the '
16+
+ 'author\'s call.',
17+
reason:
18+
'The console\'s action runner awaits the confirm dialog and only then opens the param dialog for an array '
19+
+ '`params`, and all four rows forward both keys to it (an array `params` as `actionParams`), so a row '
20+
+ 'carrying both showed the user TWO dialogs for one decision: a confirm that already read as "the action '
21+
+ 'ran" while nothing had been sent, then the param prompt. `ActionSchema` and `InlineActionSchema` already '
22+
+ 'refuse the pair; the four page rows now carry that rule with the same predicate and the same refusal '
23+
+ 'text. On an `action:icon` with no `label`, `description` is also the tooltip text, so moving the '
24+
+ 'question there changes the hover text too; give the icon a `label` to keep them apart. A page\'s '
25+
+ '`properties` is not parsed on the metadata load path, so a stored page keeps loading; `os validate`\'s '
26+
+ 'props gate, which the metadata save door also runs on the next save of the page, parses it against '
27+
+ 'these same rows and reports the refusal at the row\'s `confirmText`. Measured producers: none shipped '
28+
+ 'in this repository; the one carrier was a spec test fixture, respelled.',
29+
acceptanceCriteria:
30+
'No `action:button` / `action:icon` `properties` and no `action:group` / `action:menu` member declares '
31+
+ '`confirmText` beside a non-empty array `params`; `os validate` reports no `component-props-invalid` '
32+
+ 'finding at `properties.confirmText` or `properties.actions.N.confirmText` for the pair, and every row '
33+
+ 'with no `params`, `params: []` or an object `params` parses as before.',
34+
};

‎packages/spec/src/ui/action-confirm-beside-params.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,28 @@
22

33
/**
44
* `confirmText` beside a non-empty `params`: TWO sequential dialogs for ONE
5-
* decision. ONE predicate and ONE refusal, read by the three doors that refuse
5+
* decision. ONE predicate and ONE refusal, read by the five doors that refuse
66
* the pair: `ActionSchema` and `InlineActionSchema` (`action.zod.ts`), and the
7-
* page `action:button` row, `ActionButtonPropsSchema` (`component.zod.ts`).
8-
* Pinned in `action-confirm-params-guard.test.ts`, which asserts the three
7+
* page action rows in `component.zod.ts` — `action:button`
8+
* (`ActionButtonPropsSchema`), `action:icon` (`ActionIconPropsSchema`) and each
9+
* `action:group` / `action:menu` member. Pinned in
10+
* `action-confirm-params-guard.test.ts`, which asserts the five doors'
911
* messages equal.
1012
*
1113
* The console action runner awaits the confirm, THEN the param prompt (objectui
1214
* `packages/core/src/actions/ActionRunner.ts`), so the first dialog already
1315
* reads as "the action ran" while nothing has been sent. The `element:button`
1416
* renderer hands an inline action to that same runner, and the `action:button`
15-
* block forwards its row's `confirmText` and an array `params` (as
16-
* `actionParams`) to it too, so the pair opens the same two dialogs on all
17-
* three doors.
17+
* and `action:icon` blocks forward their row's `confirmText` and an array
18+
* `params` (as `actionParams`) to it too, as the `action:group` /
19+
* `action:menu` containers do for each member, so the pair opens the same two
20+
* dialogs on all five doors.
1821
*
1922
* An empty `params` collects nothing and opens no second dialog, so it is not
20-
* the pair. Nor is an object-form `params`: on an `action:button` row an object
21-
* is the static parameter values, which open no dialog, and the
23+
* the pair. Nor is an object-form `params`: on an `action:button` /
24+
* `action:icon` row an object is the static parameter values, and on a
25+
* `type: 'api'` container member the request payload; neither opens a
26+
* dialog, and the
2227
* `Array.isArray` read below leaves it alone.
2328
*
2429
* Lives in its own module, not in `action.zod.ts`, so `component.zod.ts` can

‎packages/spec/src/ui/action-confirm-params-guard.test.ts‎

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
2323
* two dialogs open, and its pick carries the `description` remedy. So does the
2424
* page `action:button` row, `ActionButtonPropsSchema` (#29844, ruling record
2525
* 6597621758 letter A), for an ARRAY `params` only: an object `params` there is
26-
* the static values and opens no dialog. The three doors share one predicate
27-
* and one message (`./action-confirm-beside-params.ts`), pinned equal below.
26+
* the static values and opens no dialog. So do `action:icon` and the
27+
* `action:group` / `action:menu` members (#30017, ruling record 6624208841
28+
* letter A), on the same terms. The doors share one predicate and one message
29+
* (`./action-confirm-beside-params.ts`), pinned equal below.
2830
*
2931
* The rejection tests assert the issue PATH and the MESSAGE SUBSTANCE rather
3032
* than a bare `success === false`. One condition, one wording: a refusal whose
@@ -35,7 +37,12 @@
3537
import { describe, expect, it } from 'vitest';
3638
import { ActionSchema, InlineActionSchema, defineAction } from './action.zod';
3739
import { BulkActionDefSchema } from './bulk-action.zod';
38-
import { ActionButtonPropsSchema } from './component.zod';
40+
import {
41+
ActionButtonPropsSchema,
42+
ActionGroupPropsSchema,
43+
ActionIconPropsSchema,
44+
ActionMenuPropsSchema,
45+
} from './component.zod';
3946

4047
/**
4148
* Minimum legal registered action. `type` defaults to `script`, whose own
@@ -378,3 +385,116 @@ describe('`confirmText` + non-empty ARRAY `params` is refused on the `action:but
378385
expect(result.success, parseErrorOf(result)).toBe(true);
379386
});
380387
});
388+
389+
describe('`confirmText` + non-empty ARRAY `params` is refused on `action:icon` and the `action:group` / `action:menu` members too', () => {
390+
// #30017 (ruling record 6624208841, letter A). `action:icon` forwards its
391+
// row's `confirmText` and an array `params` (as `actionParams`) to the same
392+
// `ActionRunner`, and so do both containers for each member: the same two
393+
// dialogs. Same predicate, same refusal: asserted against `action:button`'s
394+
// own issue, so a wording drifting onto one door goes red. An object `params`
395+
// (the icon's static values; a `type: 'api'` member's request payload) opens
396+
// no dialog, so it keeps `confirmText`.
397+
const pairParams = [{ name: 'reason', label: 'Reason', type: 'textarea' }];
398+
const iconReject = {
399+
label: 'Reject',
400+
icon: 'x',
401+
actionType: 'api',
402+
target: '/api/v1/approvals/reject',
403+
params: pairParams,
404+
} as const;
405+
const memberReject = {
406+
name: 'reject',
407+
label: 'Reject',
408+
type: 'api',
409+
target: '/api/v1/approvals/reject',
410+
params: pairParams,
411+
} as const;
412+
const asContainer = (member: Record<string, unknown>) => ({
413+
actions: [{ name: 'approve', label: 'Approve', type: 'script', target: 'approve' }, member],
414+
});
415+
const CONTAINERS = [
416+
['action:group', ActionGroupPropsSchema],
417+
['action:menu', ActionMenuPropsSchema],
418+
] as const;
419+
420+
const parseErrorOf = (result: { success: boolean; error?: unknown }) => JSON.stringify(result.error);
421+
/** `action:button`'s issue for the pair: the message every other door must equal. */
422+
const buttonMessage = (): string | undefined => {
423+
const r = ActionButtonPropsSchema.safeParse({ ...iconReject, confirmText: 'Reject this request?' });
424+
return r.success ? undefined : r.error.issues.find((i) => i.path.join('.') === 'confirmText')?.message;
425+
};
426+
427+
it('`action:icon` refuses the pair at the `confirmText` path, with `action:button`\'s message', () => {
428+
const result = ActionIconPropsSchema.safeParse({ ...iconReject, confirmText: 'Reject this request?' });
429+
430+
expect(result.success).toBe(false);
431+
const issues = result.success ? [] : result.error.issues;
432+
expect(issues).toHaveLength(1);
433+
expect(issues[0]?.code).toBe('custom');
434+
expect(issues[0]?.path).toEqual(['confirmText']);
435+
expect(buttonMessage()).toBeDefined();
436+
expect(issues[0]?.message).toBe(buttonMessage());
437+
// The prescription: the remedy key, and the param-less case that keeps `confirmText` live.
438+
expect(issues[0]?.message).toContain('TWO dialogs');
439+
expect(issues[0]?.message).toContain('`description`');
440+
expect(issues[0]?.message).toContain('param-LESS');
441+
});
442+
443+
for (const [row, schema] of CONTAINERS) {
444+
it(`an \`${row}\` member refuses the pair at \`actions.N.confirmText\`, with \`action:button\`'s message`, () => {
445+
const result = schema.safeParse(asContainer({ ...memberReject, confirmText: 'Reject this request?' }));
446+
447+
expect(result.success).toBe(false);
448+
const issues = result.success ? [] : result.error.issues;
449+
expect(issues).toHaveLength(1);
450+
expect(issues[0]?.code).toBe('custom');
451+
// The member's own index, so the editor underlines the offending member, not the list.
452+
expect(issues[0]?.path).toEqual(['actions', 1, 'confirmText']);
453+
expect(issues[0]?.message).toBe(buttonMessage());
454+
expect(issues[0]?.message).toContain('TWO dialogs');
455+
expect(issues[0]?.message).toContain('`description`');
456+
});
457+
458+
it(`an \`${row}\` member refuses the template-envelope form of \`confirmText\` too`, () => {
459+
// A row's `confirmText` is a template slot: the bare string and the
460+
// `{ dialect: 'template', source }` envelope are its two spellings (a
461+
// localized map is refused by the slot itself, for its own reason).
462+
const result = schema.safeParse(asContainer({
463+
...memberReject,
464+
confirmText: { dialect: 'template', source: 'Reject this request?' },
465+
}));
466+
467+
expect(result.success).toBe(false);
468+
expect(result.success ? [] : result.error.issues.map((i) => [i.code, i.path.join('.')]))
469+
.toEqual([['custom', 'actions.1.confirmText']]);
470+
});
471+
}
472+
473+
it('`action:icon` accepts `confirmText` with no `params`, beside `params: []`, and beside an OBJECT `params`', () => {
474+
const { params: _params, ...paramless } = iconReject;
475+
for (const props of [
476+
{ ...paramless, confirmText: 'Reject this request?' },
477+
{ ...iconReject, params: [], confirmText: 'Reject this request?' },
478+
{ ...iconReject, params: { reason: 'duplicate' }, confirmText: 'Reject this request?' },
479+
{ ...iconReject, description: 'Reject this request? Say why — the requester sees it.' },
480+
]) {
481+
const result = ActionIconPropsSchema.safeParse(props);
482+
expect(result.success, parseErrorOf(result)).toBe(true);
483+
}
484+
});
485+
486+
for (const [row, schema] of CONTAINERS) {
487+
it(`an \`${row}\` member accepts \`confirmText\` with no \`params\`, beside \`params: []\`, and beside a \`type: 'api'\` member's OBJECT \`params\``, () => {
488+
const { params: _params, ...paramless } = memberReject;
489+
for (const member of [
490+
{ ...paramless, confirmText: 'Reject this request?' },
491+
{ ...memberReject, params: [], confirmText: 'Reject this request?' },
492+
{ ...memberReject, params: { reason: 'duplicate' }, confirmText: 'Reject this request?' },
493+
{ ...memberReject, description: 'Reject this request? Say why — the requester sees it.' },
494+
]) {
495+
const result = schema.safeParse(asContainer(member));
496+
expect(result.success, parseErrorOf(result)).toBe(true);
497+
}
498+
});
499+
}
500+
});

‎packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,15 @@ describe('§1 each member accepts every shape a measured writer authors', () =>
221221
successMessage: { dialect: 'template', source: 'Closed' },
222222
errorMessage: { dialect: 'template', source: 'Could not close' },
223223
refreshAfter: true, locations: ['record_header'], objectName: 'case',
224+
}],
225+
}],
226+
// #30017: an array `params` beside `confirmText` is refused (two dialogs for
227+
// one decision), so the param-collecting member carries its question on
228+
// `description` instead.
229+
['a member collecting params', 'action:menu', {
230+
actions: [{
231+
name: 'close_case', label: 'Close', type: 'api', target: '/api/close',
232+
description: 'Close this case? Say why.',
224233
params: [{ name: 'reason', label: 'Reason', type: 'text' }],
225234
}],
226235
}],

0 commit comments

Comments
 (0)