Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/inline-action-body-extra-payload-key.md
Original file line numberDiff line numberDiff line change
@@ -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.<var>}}`
做 `resolvePageVarTokens`),缺的只是那一跳转发。
7 changes: 4 additions & 3 deletions content/docs/references/ui/action.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, string>; … }[]` | optional | Input parameters required from user |
| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record<string, string>; … }[]` | 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<string, string>` | optional | Confirmation message before execution |
Expand All@@ -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<string, any>` | optional | Constant body fields merged into the API request (applied last; overrides user params). |
| **bodyExtra** | `Record<string, any>` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.<var>}}` 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. |
Expand DownExpand Up@@ -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<string, string>; … }[]` | optional | Input parameters required from user |
| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record<string, string>; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). |
| **bodyExtra** | `Record<string, any>` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.<var>}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. |
| **confirmText** | `string \| Record<string, string>` | optional | Confirmation message before execution |
| **successMessage** | `string \| Record<string, string>` | optional | Success message to show after execution |
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
Expand Down
1 change: 1 addition & 0 deletions docs/protocol-upgrade-guide.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)

Expand Down
20 changes: 14 additions & 6 deletions examples/app-showcase/src/ui/pages/contact-form.page.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<var>}}`. 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.<var>}}`. 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)
*
Expand DownExpand Up@@ -110,11 +110,19 @@ export const ContactFormPage = definePage({
icon: 'send',
// `api` action -> absolute endpoint. The runtime resolves the
// `{{page.<var>}}` 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}}',
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/ui.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/spec/spec-changes.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": [
Expand DownExpand Up@@ -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": [
Expand Down
100 changes: 99 additions & 1 deletion packages/spec/src/conversions/conversions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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<string, unknown>) => ({
pages: [{
name: 'showcase_contact_form',
regions: [{ name: 'main', components: [{ type: 'element:button', properties: { label: 'Go', action } }] }],
}],
});
const actionOf = (stack: Record<string, unknown>) =>
((stack.pages as { regions: { components: { properties: { action: Record<string, unknown> } }[] }[] }[])[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<string, unknown>) => ({ 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<string, unknown>) => ({
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);
});
});
});
Loading
Loading