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
15 changes: 15 additions & 0 deletions .changeset/29248-inline-action-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
---

feat(spec): an inline action can carry `description`, the line its param dialog shows under the title

Clause-②: yes (widening)
Metadata-protocol: added

An action declared inline on a page (`element:button`'s `properties.action`, typed `InlineAction`) can now say what it does. Before, `description` on it was refused as an unknown key (`unrecognized_keys`), so a page action that collects params opened its dialog with the generic line and nothing about what confirming would do.

- **The key.** `InlineActionSchema` picks `ActionSchema`'s own `description`: a `template` slot (a bare string is shorthand for `{ dialect: 'template', source }`, with `{{ record.* }}` holes) or an inline locale map (`{ en, "zh-CN" }`), each value such a sentence. The `InlineAction` type gains it too. Nothing else is picked: `icon`, `variant`, `body`, `cancelText` and `submitText` stay unknown keys on an inline action.
- **Judged like a registered action's.** `objectstack validate`, `compile` and the publish gate now judge an inline `description` with the same rules as a registered one: a `${…}` or single-brace token, a hole over a root the runner does not bind, a hole holding logic or an unknown formatter, and a blank sentence are each an error, named at `… (element:button) · action description` (or `description.<locale>` inside a locale map).
- **Where it shows.** The console's `element:button` already forwards `description` to the action runner, and the param dialog draws it under its title. One gap remains on the console side: its inline copy localizer collapses `confirmText`, `successMessage` and `errorMessage` to the user's language but not yet `description`, so until it does, an inline locale map on `description` reaches the dialog unresolved.
1 change: 1 addition & 0 deletions content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
| **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 — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). A param name (its `name`, or the `field` a field-backed param reads) may repeat only for per-gate variants of ONE input (declarations that differ in `visible` / `requiresFeature`, e.g. one optional and one required); the params check treats the name as `required` only when EVERY declaration of it requires it, and judges its value by the FIRST declaration's shape (`type`, `multiple`, `options`), so keep that shape identical across them. |
| **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. |
| **description** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record<string, string>` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`. It is a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record); the values the dialog collects are not bound, since the line shows before they exist. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Choose the new role for `{{ record.name }}`.'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. |
| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record<string, string>` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. |
| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record<string, string>` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. |
| **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 content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ const result = ActionButtonPropsSchema.parse(data);
| **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 — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). A param name (its `name`, or the `field` a field-backed param reads) may repeat only for per-gate variants of ONE input (declarations that differ in `visible` / `requiresFeature`, e.g. one optional and one required); the params check treats the name as `required` only when EVERY declaration of it requires it, and judges its value by the FIRST declaration's shape (`type`, `multiple`, `options`), so keep that shape identical across them. |
| **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. |
| **description** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record<string, string>` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`. It is a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record); the values the dialog collects are not bound, since the line shows before they exist. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Choose the new role for `{{ record.name }}`.'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. |
| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record<string, string>` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. |
| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record<string, string>` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. |
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |
Expand Down
41 changes: 41 additions & 0 deletions packages/lint/src/validate-action-copy-templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,47 @@ describe('validateStackExpressions — description, params[].helpText and outcom
expect(onObjectWrite.map((i) => i.where)).toEqual(["object 'crm_account' · action 'invite' description"]);
});

// cloud#3281 (ruling A): `InlineActionSchema` picks `description`, so an
// inline sentence is judged by the same slot rules as a registered one —
// otherwise a text the registered door refuses would publish from a page.
it('judges an inline `properties.action` description like a registered one, locale map included', () => {
const button = (description: unknown) => ({
type: 'element:button',
properties: { label: 'Invite', action: { type: 'api', target: '/api/v1/invite', description } },
});
const page = pageWith([
button('Invite ${record.name}?'),
button({ en: 'Invite {{ record.name }}?', 'zh-CN': '邀请 ${record.name}?' }),
button(' '),
button('Sent to {{ result.email }}'),
button('Invite {{ record.name }} to this account.'),
button({ en: 'Sends an invitation e-mail.', 'zh-CN': '发送邀请邮件。' }),
]);
const issues = runStackExpressionPasses({ objects, pages: [page] }, { runtimeWriteType: 'page' })
.filter((i) => /description/.test(i.where));
expect(issues.map((i) => [i.where, i.severity])).toEqual([
["page 'account_home'.regions[0].components[0] (element:button) · action description", 'error'],
["page 'account_home'.regions[0].components[1] (element:button) · action description.zh-CN", 'error'],
["page 'account_home'.regions[0].components[2] (element:button) · action description", 'error'],
["page 'account_home'.regions[0].components[3] (element:button) · action description", 'error'],
]);
expect(issues[0]!.message).toContain("An action's `description`");
expect(issues[0]!.message).toContain('as `Invite {{ record.name }}?`');
expect(issues[2]!.message).toMatch(/^An action's `description` is blank/);
});

it('the publish gate answers an inline `${…}` description on a page write with one error and no advisory', () => {
const page = pageWith([{
type: 'element:button',
properties: { label: 'Invite', action: { type: 'api', target: '/api/v1/invite', description: 'Invite ${record.name}?' } },
}]);
const { errors, advisories } = runRuntimeAuthoringRules({ type: 'page', item: page, context: { objects } });
const copy = (f: { where: string }) => /description/.test(f.where);
expect(errors.filter(copy)).toHaveLength(1);
expect(errors.filter(copy)[0]!.where).toContain('(element:button) · action description');
expect(advisories.filter(copy)).toEqual([]);
});

it('the publish gate answers each `${…}` with one error and no advisory', () => {
const gate = (item: unknown) => {
const { errors, advisories } = runRuntimeAuthoringRules({ type: 'action', item, context: { objects } });
Expand Down
18 changes: 12 additions & 6 deletions packages/lint/src/validate-action-copy-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
* all three keys;
* - a page component's inline `properties.action` (`InlineActionSchema`, the
* pick of `ActionSchema` an `element:button` carries): `confirmText`,
* `successMessage` and each `params[].helpText`.
* `successMessage`, `description` (each with the locale map) and each
* `params[].helpText`.
*
* The same two steps the bulk `confirmText` pass takes, through the same
* judge: `closedScopeTemplateRefusal` names a `${…}` or single-brace token
Expand Down Expand Up @@ -60,16 +61,21 @@ type CopyKey = 'confirmText' | 'successMessage' | 'errorMessage' | 'description'
const ACTION_KEYS: readonly CopyKey[] = ['confirmText', 'successMessage'];

/**
* [#27948] The keys only a REGISTERED action carries as `template` slots, past
* [#27948] The keys a REGISTERED action carries as `template` slots past
* {@link ACTION_KEYS}: the param dialog's `description`, each
* `outcomeMessages` entry, each `params[].helpText`. An inline action's pick
* has `params` but neither of the other two, so it is judged on `helpText`
* alone ({@link INLINE_KEYS}).
* has `params` and `description` but not `outcomeMessages`, so it is judged on
* `description` and `helpText` ({@link INLINE_KEYS}).
*/
const REGISTERED_KEYS: readonly CopyKey[] = [...ACTION_KEYS, 'description', 'outcomeMessages', 'helpText'];

/** The keys an inline `properties.action` carries as `template` slots. */
const INLINE_KEYS: readonly CopyKey[] = [...ACTION_KEYS, 'helpText'];
/**
* The keys an inline `properties.action` carries as `template` slots.
* `description` joined when `InlineActionSchema` picked it (cloud#3281, ruling
* A): the same slot, so the same judge, or an inline sentence the registered
* door refuses would publish.
*/
const INLINE_KEYS: readonly CopyKey[] = [...ACTION_KEYS, 'description', 'helpText'];

/** The keys a page action row carries as `template` slots. */
const ROW_KEYS: readonly CopyKey[] = ['confirmText', 'successMessage', 'errorMessage'];
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,7 @@
"ui/HttpRequest:url",
"ui/InlineAction:bodyExtra",
"ui/InlineAction:confirmText",
"ui/InlineAction:description",
"ui/InlineAction:errorMessage",
"ui/InlineAction:label",
"ui/InlineAction:method",
Expand Down
Loading
Loading