From 808b1437daeeaa20fd70e488cb1440e72a32d1ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 18:16:33 +0000 Subject: [PATCH] feat(spec): give an inline type:'api' action its own payload key (#5777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `InlineActionSchema.params` is an `ActionParam[]` definition array; the showcase's pure-SDUI submit button authored a request payload map under the same name, and objectui's ActionRunner accepted both, discriminating on `Array.isArray`. One key, two fact-contracts — and the generated reference could only describe the array, so an author following the docs could not write a working `api` submit button. Maintainer ruling 2026-08-06 took direction A (a separate payload key, no same-name union). The separate key is `bodyExtra`, already declared on `ActionSchema` for exactly this and now picked onto the inline shape: `body` is taken by the script hook body and `payload` is already an alias pointing at `bodyExtra` (#5013), so no new name was available or needed. - pick `bodyExtra` onto `InlineActionSchema` - `params` keeps one meaning; the object form is refused with a message that names `bodyExtra` instead of the bare "expected array, received object" - ADR-0087 D2 live-window conversion `inline-action-api-params-to-body-extra` (+ D3 chain step) rewrites the object form at load - migrate the showcase contact form to the new key - regenerate action.mdx / spec-changes.json / upgrade guide / api-surface Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M --- .../inline-action-body-extra-payload-key.md | 50 +++++ content/docs/references/ui/action.mdx | 7 +- docs/protocol-upgrade-guide.md | 1 + .../src/ui/pages/contact-form.page.ts | 20 +- packages/spec/authorable-surface/ui.json | 1 + packages/spec/spec-changes.json | 12 + .../spec/src/conversions/conversions.test.ts | 100 ++++++++- packages/spec/src/conversions/registry.ts | 206 ++++++++++++++++++ packages/spec/src/migrations/registry.ts | 1 + packages/spec/src/ui/action.zod.ts | 74 ++++++- packages/spec/src/ui/inline-action.test.ts | 123 ++++++++++- 11 files changed, 581 insertions(+), 14 deletions(-) create mode 100644 .changeset/inline-action-body-extra-payload-key.md diff --git a/.changeset/inline-action-body-extra-payload-key.md b/.changeset/inline-action-body-extra-payload-key.md new file mode 100644 index 0000000000..11379e9354 --- /dev/null +++ b/.changeset/inline-action-body-extra-payload-key.md @@ -0,0 +1,50 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): 内联 `type:'api'` action 的静态载荷改由 `bodyExtra` 承载 —— `params` 只保留参数定义数组语义(#5777) + +`InlineActionSchema.params` 从 `ActionSchema` pick 而来,类型是 **`ActionParam[]` 参数定义数组** +(执行前弹对话框收集的字段)。而 showcase 的纯 SDUI 联系表单在提交按钮上写的是**请求载荷 map** +(`params: { name: '{{page.inquiryName}}', … }`),objectui 的 `ActionRunner` 两种都接、用 +`Array.isArray` 分流 —— 一个键上并存两套事实契约。生成的参考文档只讲得出数组那一种,照参考写 +`api` 提交按钮的作者(或 AI 作者)写不出能跑的载荷。这正是 Prime Directive #12 +「宽容消费者把错误约定化石化」的形状。 + +维护者 2026-08-06 裁定取**方向 A —— 另立载荷键**,明确不做同名 union。 + +- **`bodyExtra` 就是那个键**,`ActionSchema` 一直为 `type:'api'` 声明着它 + (「static body fragment merged into the outgoing request body」),本次把它 pick 到 + `InlineActionSchema` 上,内联 action 因此第一次有了被授权的载荷写法。**没有新造键名**: + `body` 早已被 `script` action 的 L1/L2 hook body 占用(#4352 的 refinement 会连带拒绝), + `payload` 早已是指向 `bodyExtra` 的别名(#5013),两个候选拼写都不可用 —— 裁决点是「另立键」, + 键名归实施评审。 +- **`params` 只剩一个含义**。对象形态在此被**响亮拒绝**,且拒绝语句直接点名 `bodyExtra`, + 而不是作者无从下手的 `expected array, received object`。 +- **ADR-0087 D2 conversion `inline-action-api-params-to-body-extra`**(protocol 17, + **live window**,18 退出加载路径):加载期把 `element:button` 上 `type:'api'` 内联 action 的 + 对象形态 `params` 改写为 `bodyExtra`,每次改写发一条结构化 `ConversionNotice`。存量 + `sys_metadata` 行经 `applyConversionsToStoredItem` 同样覆盖。 + +FROM → TO: + +- `pages[].regions[].components[]`,`type` 为 `element:button`,且 + `properties.action.type === 'api'`:对象形态的 `properties.action.params` → + `properties.action.bodyExtra` + +三条边界按既有惯例、并有测试钉住: + +- **`Array.isArray` 是全部判别式**:真正的 `ActionParam[]` 定义数组原样不动。 +- **只改 `type:'api'`**:`type:'url'` 上的对象形态 `params` 是第三种含义 + (`ActionRunner.interpolateTarget` 的 `${param.X}` 取值域、`executeUrl` 的 `params.newTab`), + 改写过去会丢信息,故不在本条目范围内。 +- **canonical 优先**:`bodyExtra` 已在场且内容不同时不改写、不发通知,两个键都留着让作者自己收敛(#4923)。 + +未扩展到注册 action:`ActionSchema` 在 `defineAction` 处一直被解析,数组形态的 `params` +从来就在作者门口拒绝对象形态;缺口只存在于内联路径 —— 那里 `PageComponent.properties` +是开放袋,直到 #5068 才有东西去解析 props。 + +**发散窗口(诚实记录)**:本次合并之后、objectui 侧跟进单落地之前,用新键写的页面尚未被今天的 +`element:button` 渲染器读取 —— 它的转发白名单还没有 `bodyExtra`。方向是「spec 先接受,渲染器后跟上」, +绝不反向。runner 与 console `apiHandler` 本身已经会读这个键(并对其中的 `{{page.}}` +做 `resolvePageVarTokens`),缺的只是那一跳转发。 diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 17a3386740..19f042aaef 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -71,7 +71,7 @@ const result = ActionSchema.parse(data); | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only). | | **body** | `{ language: 'expression'; source: string } \| { language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. | | **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 (#3855) — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to rewrite it automatically. | -| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user | +| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. | | **confirmText** | `string \| Record` | optional | Confirmation message before execution | @@ -91,7 +91,7 @@ const result = ActionSchema.parse(data); | **recordIdField** | `string` | optional | Row field whose value seeds recordIdParam. Defaults to "id". | | **bodyShape** | `'flat' \| { wrap: string }` | optional | Body wrapping: flat (default) or `{ wrap: key }` to nest user-collected params under a key. | | **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. | -| **bodyExtra** | `Record` | optional | Constant body fields merged into the API request (applied last; overrides user params). | +| **bodyExtra** | `Record` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. | | **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. | | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | | **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | @@ -275,7 +275,8 @@ const result = ActionSchema.parse(data); | **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only). | | **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. | -| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user | +| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | +| **bodyExtra** | `Record` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. | | **confirmText** | `string \| Record` | optional | Confirmation message before execution | | **successMessage** | `string \| Record` | optional | Success message to show after execution | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 5c996e65b8..61cedca835 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -275,6 +275,7 @@ One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the sh | `record-picker-display-field-to-label-field` | `page.component.element:record_picker.displayField` | record-picker component prop 'displayField' → 'labelField' (#5775 — the required key no renderer read; `labelField ?? 'name'` is what renders the row) | retired — `migrate meta` only | | `record-picker-inert-keys-removed` | `page.component.element:record_picker.searchFields / page.component.element:record_picker.multiple` | record-picker component props 'searchFields'/'multiple' removed (#5775 — the control is a plain single-select with no search box; neither key had a reader) | retired — `migrate meta` only | | `page-card-body-to-children` | `page.component.page:card.body` | page:card component prop 'body' → 'children' (#5775 — one composition key across every container; the card renderer already reads both) | retired — `migrate meta` only | +| `inline-action-api-params-to-body-extra` | `page.component.element:button.action.params` | inline type:'api' action prop 'params' (object form) → 'bodyExtra' (#5777 — the payload gets its own key; `params` stays the ActionParam[] definition array) | live — protocol 17 loader accepts the old shape | ### Semantic (delegated to you, with acceptance criteria) diff --git a/examples/app-showcase/src/ui/pages/contact-form.page.ts b/examples/app-showcase/src/ui/pages/contact-form.page.ts index 4ea4d294f5..ab263f5c9e 100644 --- a/examples/app-showcase/src/ui/pages/contact-form.page.ts +++ b/examples/app-showcase/src/ui/pages/contact-form.page.ts @@ -10,11 +10,11 @@ import { definePage } from '@objectstack/spec/ui'; * input (PageVariableSchema.source = that input's component id). * 2. `element:text_input` writes each keystroke into its bound variable * (objectui components/src/renderers/basic/text-input.tsx). - * 3. The submit `element:button` runs an `api` action whose params reference - * the variables as `{{page.}}`. The console action runtime resolves - * those tokens against the live page-variable snapshot (published by - * PageVariableActionBridge) and POSTs the body to the public web-to-lead - * endpoint, creating a `showcase_inquiry`. + * 3. The submit `element:button` runs an `api` action whose `bodyExtra` + * references the variables as `{{page.}}`. The console action runtime + * resolves those tokens against the live page-variable snapshot (published + * by PageVariableActionBridge) and POSTs the body to the public + * web-to-lead endpoint, creating a `showcase_inquiry`. * * POST /api/v1/forms/contact-us/submit (ADR-0056 public form -> showcase_inquiry) * @@ -110,11 +110,19 @@ export const ContactFormPage = definePage({ icon: 'send', // `api` action -> absolute endpoint. The runtime resolves the // `{{page.}}` tokens against the live snapshot before POSTing. + // + // The payload goes in `bodyExtra`, NOT `params` (#5777, maintainer + // ruling 2026-08-06 direction A). `params` is the ActionParam[] + // DEFINITION array — the dialog fields collected from the user + // before the action runs — and this form collects nothing that way: + // its inputs write page variables, and submit sends them. The two + // are different concepts, so they get different keys instead of one + // key discriminated by `Array.isArray`. action: { type: 'api', target: '/api/v1/forms/contact-us/submit', method: 'POST', - params: { + bodyExtra: { name: '{{page.inquiryName}}', email: '{{page.inquiryEmail}}', company: '{{page.inquiryCompany}}', diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 7c77b4cb6b..f56d598fdf 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -560,6 +560,7 @@ "ui/HttpRequest:method", "ui/HttpRequest:params", "ui/HttpRequest:url", + "ui/InlineAction:bodyExtra", "ui/InlineAction:confirmText", "ui/InlineAction:errorMessage", "ui/InlineAction:label", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index b3a2ca4d7a..2311b4822a 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -361,6 +361,12 @@ "to": "page:card component prop 'body' → 'children' (#5775 — one composition key across every container; the card renderer already reads both)", "conversionId": "page-card-body-to-children", "toMajor": 17 + }, + { + "surface": "page.component.element:button.action.params", + "to": "inline type:'api' action prop 'params' (object form) → 'bodyExtra' (#5777 — the payload gets its own key; `params` stays the ActionParam[] definition array)", + "conversionId": "inline-action-api-params-to-body-extra", + "toMajor": 17 } ], "migrated": [ @@ -1189,6 +1195,12 @@ "to": "page:card component prop 'body' → 'children' (#5775 — one composition key across every container; the card renderer already reads both)", "conversionId": "page-card-body-to-children", "toMajor": 17 + }, + { + "surface": "page.component.element:button.action.params", + "to": "inline type:'api' action prop 'params' (object form) → 'bodyExtra' (#5777 — the payload gets its own key; `params` stays the ActionParam[] definition array)", + "conversionId": "inline-action-api-params-to-body-extra", + "toMajor": 17 } ], "migrated": [ diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index 764b050b54..721072c6ff 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -6,7 +6,7 @@ import { CreateRecordConfigSchema } from '../automation/builtin-node-config.zod. import { FlowSchema } from '../automation/flow.zod.js'; import { ScriptConfigSchema } from '../automation/schemaless-node-config.zod.js'; import { normalizeStackInput } from '../shared/metadata-collection.zod.js'; -import { PageHeaderProps } from '../ui/component.zod.js'; +import { ElementButtonPropsSchema, PageHeaderProps } from '../ui/component.zod.js'; import { PageSchema } from '../ui/page.zod.js'; import { applyConversions, collectConversionNotices } from './apply.js'; import { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from './registry.js'; @@ -951,4 +951,102 @@ describe('conversion layer (ADR-0087 D2)', () => { expect(PageSchema.safeParse(page({ title: 'Leads', subtitle: 'All open leads' })).success).toBe(true); }); }); + + /** + * `inline-action-api-params-to-body-extra` (#5777). + * + * The fixture pair above already pins before → after and the notice count. + * What needs its own cover is the DISCRIMINATOR, because this entry keys off + * a value's shape rather than a key's presence: `Array.isArray` separates the + * definition array from the payload map, and the `type:'api'` guard separates + * a payload from the `${param.X}` interpolation scope a `url` action reads + * out of the same key. Both are ways this rewrite could be lossy, and neither + * is visible in a fixture that only carries the happy path. + */ + describe('inline-action-api-params-to-body-extra (#5777)', () => { + const button = (action: Record) => ({ + pages: [{ + name: 'showcase_contact_form', + regions: [{ name: 'main', components: [{ type: 'element:button', properties: { label: 'Go', action } }] }], + }], + }); + const actionOf = (stack: Record) => + ((stack.pages as { regions: { components: { properties: { action: Record } }[] }[] }[])[0]! + .regions[0]!.components[0]!.properties.action); + + it('rewrites the object form on a type:"api" inline action', () => { + const notices: ConversionNotice[] = []; + const out = applyConversions( + button({ type: 'api', target: '/api/v1/forms/contact-us/submit', params: { name: '{{page.inquiryName}}' } }), + { onNotice: (n) => notices.push(n) }, + ); + expect(actionOf(out)).toEqual({ + type: 'api', + target: '/api/v1/forms/contact-us/submit', + bodyExtra: { name: '{{page.inquiryName}}' }, + }); + expect(notices.map((n) => n.conversionId)).toEqual(['inline-action-api-params-to-body-extra']); + expect(notices[0]!.path).toBe('pages[0].regions[0].components[0].properties.action.bodyExtra'); + }); + + it('leaves an ActionParam[] definition array alone — that meaning of the key survives', () => { + const params = [{ name: 'reason', label: 'Reason', type: 'text' }]; + const stack = button({ type: 'api', target: '/x', params }); + const out = applyConversions(stack); + // Identity, not just equality: nothing converted, so copy-on-write shares. + expect(out).toBe(stack); + }); + + it('leaves a type:"url" action alone — object `params` is the interpolation scope there', () => { + // ActionRunner.interpolateTarget reads a non-array `params` as the + // `${param.X}` scope, and executeUrl reads `params.newTab`. Rewriting + // those into an api request body would be lossy, so the guard is not a + // conservatism — it is the difference between lossless and not. + const stack = button({ type: 'url', target: '/x?id=${param.id}', params: { id: 'abc' } }); + expect(applyConversions(stack)).toBe(stack); + }); + + it('keeps BOTH when `bodyExtra` already says something different (#4923 house rule)', () => { + const stack = button({ type: 'api', target: '/x', params: { a: 1 }, bodyExtra: { b: 2 } }); + const out = applyConversions(stack); + expect(out).toBe(stack); + expect(actionOf(out)).toEqual({ type: 'api', target: '/x', params: { a: 1 }, bodyExtra: { b: 2 } }); + }); + + it('is idempotent — the converted result replays to itself with no second notice', () => { + const once = applyConversions(button({ type: 'api', target: '/x', params: { a: 1 } })); + const notices: ConversionNotice[] = []; + const twice = applyConversions(once, { onNotice: (n) => notices.push(n) }); + expect(twice).toBe(once); + expect(notices).toEqual([]); + }); + + /** + * Reachability, judged by what this rule guards — a VALUE verdict, not a + * key one (#5046's distinction). The key `params` is declared either way; + * what decides is whether its value is a definition array or a payload map. + * So the criterion is full-parse-green on the props schema AFTER, and + * parse-RED before — while `PageSchema` stays green on both, because + * `PageComponent.properties` is an open bag and never judged the value at + * all. That gap is the defect's mechanism: the page published clean and + * only the #5068 props gate could see it. + */ + it('the props schema refuses the before shape and accepts the after shape; PageSchema accepts both', () => { + const props = (action: Record) => ({ label: 'Submit inquiry', action }); + const before = { type: 'api', target: '/api/v1/forms/contact-us/submit', params: { name: '{{page.n}}' } }; + const after = { type: 'api', target: '/api/v1/forms/contact-us/submit', bodyExtra: { name: '{{page.n}}' } }; + + expect(ElementButtonPropsSchema.safeParse(props(before)).success).toBe(false); + expect(ElementButtonPropsSchema.safeParse(props(after)).success).toBe(true); + + const page = (action: Record) => ({ + name: 'showcase_contact_form', + label: 'Contact Form', + type: 'app' as const, + regions: [{ name: 'main', components: [{ type: 'element:button', properties: props(action) }] }], + }); + expect(PageSchema.safeParse(page(before)).success).toBe(true); + expect(PageSchema.safeParse(page(after)).success).toBe(true); + }); + }); }); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 1c3bafc49a..fa4607ee94 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -4948,6 +4948,211 @@ const pageCardBodyToChildren: MetadataConversion = { }, }; +/** + * Inline `type:'api'` action: object-form `params` → `bodyExtra` (protocol 17, + * #5777). + * + * One key carried two fact-contracts. `InlineActionSchema.params` is picked + * from `ActionSchema` and is an `ActionParam[]` **definition array** — the + * fields a dialog collects before the action runs. What the showcase's + * pure-SDUI contact form authored on its submit button is a **request payload + * map** (`params: { name: '{{page.inquiryName}}', … }`), and objectui's + * `ActionRunner` took both: its own comment says "Accept both — when `params` + * is an array, treat it as the input-collection definition", discriminating on + * `Array.isArray`. A tolerant consumer fossilizing a wrong convention is Prime + * Directive #12's exact shape, and here the fossil was load-bearing: the + * generated reference could only describe the array, so an author following the + * docs could not write a working `api` submit button at all. + * + * The maintainer's 2026-08-06 ruling on #5777 took **direction A — a separate + * payload key**, explicitly refusing the same-name union of option B. The + * separate key is `bodyExtra`, which `ActionSchema` has declared all along for + * exactly this ("static body fragment merged into the outgoing request body for + * `type:'api'` actions"); #5777's spec half picks it onto the inline shape, so + * the rewrite target is a key the contract already owns rather than a third + * name. `payload` is already an alias pointing there (#5013) and `body` is + * already the `script` hook body, so those two spellings were never available. + * + * **Why a conversion and not a deletion.** The two shapes are disjoint — + * `Array.isArray` decides, with no value that could be read either way — so the + * rewrite is mechanical and lossless, which is the ADR-0087 D2 precondition. + * Deleting instead would strand every page authored the old way on a bare + * `expected array, received object`. + * + * **Scoped to `type:'api'`, deliberately.** Object-form `params` on an inline + * `type:'url'` action is a THIRD meaning again — `ActionRunner.interpolateTarget` + * reads it as the `${param.X}` interpolation scope, and `executeUrl` reads + * `params.newTab` — so rewriting those into an api request body would be lossy, + * not lossless. Nothing in the reachable corpus authors that shape; it stays + * refused by the array-only field, and this entry does not touch it. + * + * **Not extended to registered actions.** `ActionSchema` is parsed at + * `defineAction`, so its array-only `params` has always refused the object form + * at the authoring door — the defect existed only on the inline path, where + * `PageComponent.properties` is an open bag and nothing parsed the props until + * #5068. Registered actions already reach the payload through `bodyExtra`. + * + * Precedence is {@link renameKey}'s house rule and nothing new: an + * already-present `bodyExtra` WINS and a differing object-form `params` is left + * exactly where it sits, for the author to reconcile (#4923). + * + * Region level is the reach, as for {@link pageHeaderSubtitleAlias} and + * {@link pageCardBodyToChildren}: `PageComponentSchema` declares no children + * key, so a button nested inside another component's free-form `properties` is + * not typed page-component shape. The array-only field is what covers the rest + * — it refuses the object form with a message naming `bodyExtra`, whether or + * not a conversion could reach the site. + * + * **Live window**; retires at 18. + */ +const inlineActionApiParamsToBodyExtra: MetadataConversion = { + id: 'inline-action-api-params-to-body-extra', + toMajor: 17, + surface: 'page.component.element:button.action.params', + summary: + "inline type:'api' action prop 'params' (object form) → 'bodyExtra' (#5777 — the payload gets its own key; `params` stays the ActionParam[] definition array)", + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + if (component.type !== 'element:button') return component; + const properties = component.properties; + if (!isDict(properties)) return component; + const action = properties.action; + if (!isDict(action)) return component; + // Only the payload meaning converts. `Array.isArray` is the whole + // discriminator — a definition array stays put — and the `api` guard + // keeps the url-action interpolation scope out of it. + if (action.type !== 'api') return component; + if (!isDict(action.params)) return component; + const renamed = renameKey(action, 'params', 'bodyExtra'); + if (!renamed) return component; + emit({ from: 'params', to: 'bodyExtra', path: `${path}.properties.action.bodyExtra` }); + return { ...component, properties: { ...properties, action: renamed } }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'showcase_contact_form', + regions: [ + { + name: 'main', + components: [ + // The measured site: a pure-SDUI form submit posting page vars. + { + type: 'element:button', + properties: { + label: 'Submit inquiry', + action: { + type: 'api', + target: '/api/v1/forms/contact-us/submit', + method: 'POST', + params: { name: '{{page.inquiryName}}', email: '{{page.inquiryEmail}}' }, + }, + }, + }, + // A real ActionParam[] definition array — the other meaning of + // the same key, untouched. + { + type: 'element:button', + properties: { + label: 'Close order', + action: { + type: 'api', + target: '/api/v1/sales_order/close', + params: [{ name: 'reason', label: 'Reason', type: 'text' }], + }, + }, + }, + // Both spellings, DIFFERENT payloads: kept, so the author + // reconciles the two bodies rather than the loader picking. + { + type: 'element:button', + properties: { + label: 'Both', + action: { + type: 'api', + target: '/api/v1/x', + params: { a: 1 }, + bodyExtra: { b: 2 }, + }, + }, + }, + // `type:'url'` — object-form `params` is the `${param.X}` + // interpolation scope there, a third meaning. Not this entry's. + { + type: 'element:button', + properties: { + label: 'Open', + action: { type: 'url', target: '/x?id=${param.id}', params: { id: 'abc' } }, + }, + }, + ], + }, + ], + }, + ], + }, + after: { + pages: [ + { + name: 'showcase_contact_form', + regions: [ + { + name: 'main', + components: [ + { + type: 'element:button', + properties: { + label: 'Submit inquiry', + action: { + type: 'api', + target: '/api/v1/forms/contact-us/submit', + method: 'POST', + bodyExtra: { name: '{{page.inquiryName}}', email: '{{page.inquiryEmail}}' }, + }, + }, + }, + { + type: 'element:button', + properties: { + label: 'Close order', + action: { + type: 'api', + target: '/api/v1/sales_order/close', + params: [{ name: 'reason', label: 'Reason', type: 'text' }], + }, + }, + }, + { + type: 'element:button', + properties: { + label: 'Both', + action: { + type: 'api', + target: '/api/v1/x', + params: { a: 1 }, + bodyExtra: { b: 2 }, + }, + }, + }, + { + type: 'element:button', + properties: { + label: 'Open', + action: { type: 'url', target: '/x?id=${param.id}', params: { id: 'abc' } }, + }, + }, + ], + }, + ], + }, + ], + }, + expectedNotices: 1, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -5005,6 +5210,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly strictObject({ 'Run `os migrate meta --from 16` to rewrite it automatically.', ), - /** User Input Requirements */ - params: z.array(ActionParamSchema).optional().describe('Input parameters required from user'), + /** + * User Input Requirements — the **parameter DEFINITION array** rendered as a + * dialog before the action runs. `ActionParam[]`, never a values map. + * + * The distinction is load-bearing and was, until #5777, only implicit. A + * `type:'api'` author reaches for `params` expecting the REQUEST PAYLOAD — + * `params: { name: '{{page.inquiryName}}' }` — because "params" reads like + * "what I send". That is a different concept living under the same name: + * definitions describe fields to COLLECT, a payload is data to SEND. The + * static payload key is {@link bodyExtra}, and the two shapes are disjoint + * (array vs object), which is exactly why the confusion survived — every + * consumer could tell them apart with `Array.isArray`, so nobody had to. + * + * The maintainer's 2026-08-06 ruling on #5777 took direction A (a separate + * key, no same-name union), so this key keeps ONE meaning and the object form + * is REFUSED here — with a message that names `bodyExtra` rather than the + * bare "expected array, received object" an author cannot act on. Sources + * still carrying the object form are rewritten at load by the + * `inline-action-api-params-to-body-extra` conversion (ADR-0087 D2). + */ + params: z.array(ActionParamSchema, { + error: (iss) => ( + iss.code === 'invalid_type' + && iss.input !== null + && typeof iss.input === 'object' + && !Array.isArray(iss.input) + ? "`params` is the parameter DEFINITION array (fields collected from the user before the action runs), not the request payload. " + + "For a `type:'api'` action's static request body — including `{{page.}}` tokens — use `bodyExtra: { … }` instead (#5777). " + + 'Expected an array of ActionParam, received an object.' + : undefined + ), + }).optional().describe('Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`).'), /** Visual Style */ variant: z.enum(['primary', 'secondary', 'danger', 'ghost', 'link']).optional().describe('Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent)'), @@ -1018,8 +1048,26 @@ const actionObject = () => strictObject({ * `bodyExtra: { resend: true }` on a resend-invitation action that reuses * better-auth's `invite-member` endpoint. Applied after user-collected * params and `recordIdParam` so constants always win. + * + * **This is the static-payload key for `type:'api'`, on registered AND inline + * actions alike** (#5777). `params` is the parameter DEFINITION array and + * carries no payload; the ruled direction A gave the payload its own key + * rather than unioning two meanings onto one name. The name is not new and is + * deliberately not new: `body` is already taken on this schema (the `script` + * action's L1/L2 hook body, and the #4352 refinement rejects it alongside any + * other `type`, so it could never carry an api payload), and `payload` is + * already an ALIAS pointing here — written in the table above by #5013 for + * exactly that reason. So an author who writes `payload: {…}` is renamed onto + * this key, and one who writes `body: {…}` on a `type:'api'` action is + * rejected by the refinement that owns that name. + * + * Values are not required to be literals: objectui's console action runtime + * runs `resolvePageVarTokens` over this record, so `{{page.}}` tokens + * resolve against the live page-variable snapshot the same way they do for a + * collected-params body. That is what makes it the correct home for a + * pure-SDUI form submit (`examples/app-showcase/.../contact-form.page.ts`). */ - bodyExtra: z.record(z.string(), z.unknown()).optional().describe('Constant body fields merged into the API request (applied last; overrides user params).'), + bodyExtra: z.record(z.string(), z.unknown()).optional().describe('Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes.'), /** * Semantic mode hint — UI / runtime can use this to pick confirm copy, * default variants, success messaging. Pure metadata; no runtime branching. @@ -1189,6 +1237,25 @@ export function normalizeInlineAction(value: unknown): unknown { * page button running an inline sandboxed script is a separate decision. Widen * this when a renderer widens, not before — a declared field no renderer reads * is the failure this schema exists to stop. + * + * **`bodyExtra` is the one field picked AHEAD of its renderer, knowingly** + * (#5777, maintainer ruling 2026-08-06). It is the only authorized way for an + * inline `type:'api'` action to carry a static request payload: `params` is the + * parameter DEFINITION array and the ruling refused a same-name union, so + * without this pick the inline shape has no payload key at all — the cost the + * issue's option C names. The pick is therefore what makes the ruled direction + * A reachable from a page, and it is deliberately taken before objectui's half. + * + * The divergence that buys, stated rather than hidden: objectui's + * `element:button` renderer builds an explicit forward list + * (`packages/components/src/renderers/basic/elements.tsx`) that does not yet + * include `bodyExtra`, so between this landing and that follow-up an inline + * `bodyExtra` validates, publishes, and is dropped one hop before the runner. + * The runner and the console `apiHandler` below it already read the key — the + * missing hop is the forward list alone. Direction of the window: spec accepts + * ahead of the renderer, never the reverse. Tracked as the objectui card the + * ruling splits out under `Blocked-by:` this change; the general rule above is + * unchanged for every other field. */ export const InlineActionSchema = lazySchema(() => z.preprocess( normalizeInlineAction, @@ -1200,6 +1267,7 @@ export const InlineActionSchema = lazySchema(() => z.preprocess( openIn: true, method: true, params: true, + bodyExtra: true, confirmText: true, successMessage: true, errorMessage: true, diff --git a/packages/spec/src/ui/inline-action.test.ts b/packages/spec/src/ui/inline-action.test.ts index 5f4908bc05..5e7d5e7836 100644 --- a/packages/spec/src/ui/inline-action.test.ts +++ b/packages/spec/src/ui/inline-action.test.ts @@ -27,7 +27,7 @@ import { ElementButtonPropsSchema } from './component.zod'; /** The fields an inline action admits, in the order they are picked. */ const INLINE_FIELDS = [ - 'type', 'name', 'label', 'target', 'openIn', 'method', 'params', + 'type', 'name', 'label', 'target', 'openIn', 'method', 'params', 'bodyExtra', 'confirmText', 'successMessage', 'errorMessage', 'refreshAfter', 'opensInNewTab', ] as const; @@ -62,6 +62,7 @@ describe('InlineActionSchema is derived from ActionSchema, not restated', () => openIn: 'new-tab' as const, method: 'POST' as const, params: [], + bodyExtra: { resend: true }, confirmText: 'Sure?', successMessage: 'Done', errorMessage: 'Failed', @@ -116,6 +117,94 @@ describe('InlineActionSchema — identity is optional, unlike a registered actio }); }); +/** + * #5777 — the payload key. `params` carried two fact-contracts (an + * `ActionParam[]` definition array in the spec, a static payload map in the + * live objectui runner, discriminated by `Array.isArray`); the maintainer's + * 2026-08-06 ruling took direction A, a SEPARATE key, and refused the + * same-name union. `bodyExtra` is that key — already declared on `ActionSchema` + * for `type:'api'` bodies, now picked onto the inline shape. + */ +describe('InlineActionSchema — `bodyExtra` is the payload key, `params` is not (#5777)', () => { + it('accepts the showcase submit button verbatim, `{{page.}}` tokens included', () => { + // examples/app-showcase/src/ui/pages/contact-form.page.ts — the site the + // #5068 gate reported. Values are template tokens, not literals: the + // console action runtime resolves them via resolvePageVarTokens. + const r = InlineActionSchema.safeParse({ + type: 'api', + target: '/api/v1/forms/contact-us/submit', + method: 'POST', + bodyExtra: { + name: '{{page.inquiryName}}', + email: '{{page.inquiryEmail}}', + }, + successMessage: 'Thanks! We received your inquiry.', + refreshAfter: false, + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + expect((r.data as { bodyExtra: Record }).bodyExtra) + .toEqual({ name: '{{page.inquiryName}}', email: '{{page.inquiryEmail}}' }); + }); + + it('REFUSES the object form of `params`, and the refusal names `bodyExtra`', () => { + // The rejection assertion is the envelope this surface HAS: a schema parse + // reports a coded issue at a path, not an HTTP status (no route is involved + // in a `.safeParse`). `toThrow()`-shaped "it failed somehow" would stay + // green if the message regressed to the bare "expected array, received + // object" an author cannot act on — which is the whole defect #5777 fixes. + const r = InlineActionSchema.safeParse({ + type: 'api', + target: '/api/v1/forms/contact-us/submit', + params: { name: '{{page.inquiryName}}' }, + }); + expect(r.success).toBe(false); + const issues = (r as { error: z.ZodError }).error.issues; + const paramsIssue = issues.find(i => i.path.join('.') === 'params'); + expect(paramsIssue, JSON.stringify(issues)).toBeDefined(); + expect(paramsIssue!.code).toBe('invalid_type'); + expect((paramsIssue as unknown as { expected: string }).expected).toBe('array'); + expect(paramsIssue!.message).toContain('bodyExtra'); + expect(paramsIssue!.message).toContain('#5777'); + // And it says what `params` IS, not only what it is not. + expect(paramsIssue!.message).toContain('DEFINITION array'); + }); + + it('keeps the definition-array meaning of `params` intact', () => { + const r = InlineActionSchema.safeParse({ + type: 'api', + target: '/api/v1/sales_order/close', + params: [{ name: 'reason', label: 'Reason', type: 'text' }], + bodyExtra: { closed_by_ui: true }, + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + // Both keys coexist — they are different concepts, which is the ruling. + const data = r.data as { params: unknown[]; bodyExtra: Record }; + expect(Array.isArray(data.params)).toBe(true); + expect(data.bodyExtra).toEqual({ closed_by_ui: true }); + }); + + it('renames `payload` onto `bodyExtra` rather than accepting a third spelling', () => { + // `payload: 'bodyExtra'` has been in the alias table since #5013. The pick + // must not lose it — an alias that survives on ActionSchema and dies on the + // inline shape is exactly the second-dialect drift this schema prevents. + const r = InlineActionSchema.safeParse({ type: 'api', target: '/x', payload: { a: 1 } }); + expect(r.success).toBe(false); + const issue = (r as { error: z.ZodError }).error.issues[0]!; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toContain('`payload` → `bodyExtra`'); + }); + + it('leaves `body` unreachable — it is the script hook body, not a payload', () => { + // Why the ruling's suggested name could not be taken: `body` is declared on + // ActionSchema and #4352's refinement rejects it alongside any non-script + // type. It is not picked here at all, so it is an unknown key inline. + const shape = inlineObject().shape; + expect(shape).not.toHaveProperty('body'); + const r = InlineActionSchema.safeParse({ type: 'api', target: '/x', body: { a: 1 } }); + expect(r.success).toBe(false); + }); +}); + describe('normalizeInlineAction — the legacy spellings cloud actually writes', () => { it('folds the exact shape in cloud service-tenant pages', () => { // packages/service-tenant/src/pages/{pricing,welcome,billing-cancel,billing-success} @@ -183,4 +272,36 @@ describe('element:button declares its action', () => { }); expect(r.success).toBe(false); }); + + it('carries an api payload through to parse output (#5777, end to end)', () => { + // The #5068 gate parses `properties` through this schema, so this is the + // exact call whose `component-props-invalid` finding the showcase page + // produced before the ruling. + const r = ElementButtonPropsSchema.safeParse({ + label: 'Submit inquiry', + variant: 'primary', + action: { + type: 'api', + target: '/api/v1/forms/contact-us/submit', + method: 'POST', + bodyExtra: { name: '{{page.inquiryName}}' }, + }, + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + expect((r.data as { action: { bodyExtra: unknown } }).action.bodyExtra) + .toEqual({ name: '{{page.inquiryName}}' }); + }); + + it('still reports the object form of `params` as invalid, at the props path', () => { + const r = ElementButtonPropsSchema.safeParse({ + label: 'Submit inquiry', + action: { type: 'api', target: '/x', params: { name: '{{page.n}}' } }, + }); + expect(r.success).toBe(false); + const issue = (r as { error: z.ZodError }).error.issues + .find(i => i.path.join('.') === 'action.params'); + expect(issue, JSON.stringify((r as { error: z.ZodError }).error.issues)).toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.message).toContain('bodyExtra'); + }); });