Skip to content

Commit d68d129

Browse files
feat(spec): InlineActionSchema picks description, so an inline action's param dialog can say what it does (#29369)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9X2jU4uniTTvc92oRkTrTKD)_ Fixes #29248 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: none — no `skills/**` file and no hand-written `content/docs` page lists the inline action's key set; the only lists are the generated `content/docs/references/ui/{action,component}.mdx` tables, regenerated here. ## 维护者速读 - 改了什么:页面上内联声明的动作(`element:button` 的 `properties.action`)现在可以写 `description`,即参数对话框标题下那行说明;同时 `os validate` / 发布门对它的检查与已注册动作一致。 - 为什么改:您在 cloud#3281 裁定 A,先放宽上游契约。「邀请成员」对话框现在只显示通用提示,说不清邀请会发生什么;没有这个键,云端无从写这句话。 - 风险与回滚:纯放宽,旧元数据全部照常通过;只多接受一个键,其余未选的键(`icon`、`variant`、`body`、`cancelText`、`submitText`)仍被拒。已知缺口:控制台尚未把内联的多语言 map 折成当前语言,等 UI 半张卡落地。回滚即 revert 本 PR。 - 先看:`packages/spec/src/ui/action.zod.ts`(`InlineActionSchema` 的 pick 与上方注释)、`packages/lint/src/validate-action-copy-templates.ts`(`INLINE_KEYS`)、`packages/spec/src/ui/inline-action.test.ts`(新用例与对照组)。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/ui/inline-action.test.ts`,预期全部通过,含「`description` says what the action does」一组 10 条。 - 需要你做的:无,席位落地。 ## What changes - `packages/spec/src/ui/action.zod.ts`: `InlineActionSchema`'s `.pick({…})` gains `description: true`. It is `ActionSchema`'s own field (the `template` slot with the inline locale-map arm), so the describe text, the parse and the refusals are shared, not restated. The derived `InlineAction` / `InlineActionParsed` types follow from the pick. - The schema's docblock records why: the `element:button` renderer's forward list (`ui/packages/components/src/renderers/basic/elements.tsx`) already passes `action.description` to the runner, and the console's param-collection handler (`ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx`) draws it under the dialog title. It also states the one window that remains: the inline copy localizer (`ui/packages/react/src/hooks/useActionTextLocalizer.ts`) collapses only `confirmText`, `successMessage` and `errorMessage` (its `DECLARED_COPY_KEYS`), so an inline locale map on `description` reaches the dialog unresolved until #29249 lands. Spec accepts ahead of the renderer, never the reverse. - Same docblock, in place: the `bodyExtra` paragraph said the forward list "does not yet include `bodyExtra`". Measured false on main `27c1c555` (`elements.tsx` forwards `bodyExtra: action.bodyExtra`), so it now says that window closed. Comment only. - `packages/lint/src/validate-action-copy-templates.ts` (own commit; the seat added it to the claim's file surface): `INLINE_KEYS` gains `description`, and the two comments that said the inline pick has no `description` are corrected. Without this, an inline `description` with a `${…}` token, a hole over an unbound root or a blank text parses (the slot keeps its blank arm, as on a registered action) and would pass `objectstack validate`, `compile` and the publish gate, while the same text on a registered action is an error there. The describe text that renders into the InlineAction reference table says those doors name it, so leaving lint alone would ship declared ≠ enforced. - Regenerated: `content/docs/references/ui/action.mdx` and `content/docs/references/ui/component.mdx` (one `description` row each in the InlineAction tables), `packages/spec/authorable-surface/ui.json` (`ui/InlineAction:description`). - `.changeset/29248-inline-action-description.md`: `@objectstack/spec` and `@objectstack/lint`, minor. - Not touched, per the card: `ui/**` (#29249) and cloud. `cancelText` and `submitText` are not picked (see Acceptance notes). ## Tests Forced edits (they pinned the old pick): - `packages/spec/src/ui/inline-action.test.ts` `INLINE_FIELDS` gains `'description'` ("admits exactly the documented field set"), and the "admits only fields ActionSchema itself declares" body gains a `description` so the round-trip covers it. - `packages/spec/src/ui/action-description.test.ts` "an INLINE action still refuses it" pinned the refusal this card removes. It now asserts the inline parse keeps the description as its template envelope; the file header no longer lists "widen the INLINE surface" among what the key's change must not do. New: - `inline-action.test.ts` "InlineActionSchema — `description` says what the action does" (10 cases): a plain string parses and is kept as the `template` envelope; a locale map is kept verbatim; no `description` still parses and adds none; a non-string value is refused inline at the same path with the same code as on `ActionSchema`; `ElementButtonPropsSchema` carries it to parse output; CONTROL: `icon`, `variant`, `body`, `cancelText`, `submitText` are each still `unrecognized_keys`, naming exactly that key. - `inline-action-type.test.ts`: two bodies typed through `InlineAction` carry `description` (locale map, plain string), compiled by the package typecheck and parsed by the door. - `packages/lint/src/validate-action-copy-templates.test.ts`: an inline `properties.action` `description` is judged like a registered one (a `${…}` token, a `${…}` inside a locale-map value at `description.zh-CN`, a blank sentence and a `{{ result.* }}` hole are each one error; a bound `{{ record.* }}` hole and a clean locale map draw nothing); and the page publish gate answers an inline `${…}` description with one error and no advisory. ## Acceptance notes - Premise, measured on main `27c1c555` with the spec dist built from it: an inline `description` was REFUSED, not stripped. `InlineActionSchema.safeParse({ type: 'api', target: 'invite_user', description: 'Sends an e-mail invitation.' })` answered `unrecognized_keys` with `keys: ["description"]`, for the plain string and the locale map alike. So nothing an author wrote was silently lost; a page that declared it failed validation. On the head the same calls parse and keep it (string → `{ dialect: 'template', source }`, map verbatim). - The console's param dialog also draws `submitText` and `cancelText` (`useConsoleActionRuntime.tsx`). Neither is picked by `InlineActionSchema` nor forwarded by `element:button`, so an inline action cannot set the dialog's button labels. Nothing is declared that is not read, so this is no defect; noted for whoever widens the inline dialog copy next. - The inline refusal of `body` prints "Did you mean `body` → `mode`?": the suggestion list is computed from `ActionSchema`'s full shape, and `mode` is not on the inline pick either, so following it draws a second refusal. Loud, not silent; noted only. - `ui/scripts/check-action-forward-parity.mjs` reads the inline owed set from the built spec: `element:button` now owes 13 (was 12) and still forwards all of them, exit 0. ## Verification Final head `7b596699`. The full suites ran at `89985489`; the only commit after it moves one spec test into its own `describe` (test file only), so the three touched spec test files, the spec typecheck and all gates were re-run on `7b596699`. <details><summary>Spec, lint and ui consumers</summary> - `pnpm --filter @objectstack/spec test` at `89985489`: Test Files 790 passed (790), Tests 23392 passed, 1 todo. At `7b596699`: `inline-action.test.ts`, `action-description.test.ts`, `inline-action-type.test.ts` 3/3 files, 60/60 tests. - `pnpm --filter @objectstack/spec typecheck` exit 0 at `89985489` and at `7b596699` (it compiles `inline-action-type.test.ts`, so the two `description` bodies typed as `InlineAction` are judged by tsc). - `pnpm --filter @objectstack/lint test` at `89985489`: Test Files 189 passed (189), Tests 7827 passed, 5 skipped. `pnpm --filter @objectstack/lint typecheck` exit 0. - ui consumers that parse or forward an inline action, run from `ui/` against the rebuilt spec dist: `elementButtonActionMembers-8071`, `action-bodyShape-forward`, `action-onSuccess-forward`, `action-outcomeMessages-forward-11344`, `action-undeclared-keys-8648`, `block-config`, `block-config-i18n`, `PageBlockInspector.i18n`: 8/8 files, 126 tests passed. (The first run of `PageBlockInspector.i18n` could not resolve `@objectstack/lint` before lint's own dist was built; re-run after the build, 22/22.) The full ui suites are left to CI's Test UI. - `node ui/scripts/check-action-forward-parity.mjs` exit 0: `element:button owes 13, forwards 18` (was 12 on main); `check-component-surface-parity.mjs` report-only, exit 0. - `packages/metadata*`: no fixture pins the inline key set (`git grep` for `InlineAction` and the pick's key list outside spec, lint and ui finds none), so nothing to run there. </details> <details><summary>Reverse verification</summary> Both through `scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob restored to HEAD, `git diff HEAD` empty after each): - Drop `description: true,` from the pick → `inline-action.test.ts`, `action-description.test.ts`, `inline-action-type.test.ts`: 8 failed, 52 passed. Red: the field-set pin, the plain-string, locale-map, same-refusal and `element:button` cases, the flipped inline pin, and both typed bodies' runtime half. The five CONTROL rows and "still parses without one" stay green, as they should. - Drop `'description'` from lint's `INLINE_KEYS` → `validate-action-copy-templates.test.ts`: 2 failed, 23 passed (exactly the two new inline-description cases). </details> <details><summary>Gates and generated artifacts</summary> - `pnpm --filter @objectstack/spec build` + `check:generated` at `89985489`: 1 of 14 stale (`check:docs`), regenerated with `--fix` and committed; then 14/14 up to date. At `7b596699`, after a rebuild: `check:generated` exit 0, no tracked file moved. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 103 derived, the same list at both heads. On `7b596699`: 103 run, every exit 0, `--ran` reconciliation "103 run, 0 NOT-MEASURED (a DERIVED zero)". Seven dist-reading spec gates first answered exit 3 (PREREQUISITE NOT MET: the dist predated the moved test file) and were re-run after the spec rebuild, exit 0 each. On `89985489` the same 103 also all exited 0. - `check-changeset-no-major --event` on this body: exit 0, LEVEL AXIS reads `yes (widening)` + `added`, no package graded `patch`. `check-partof-closing-keyword` on this body: exit 0. - Local-only: `check:skill-frame-freshness` exit 0. `check:dev-prereqs` exit 1: 35 `ui/` workspace packages have no `dist/` in this container (only `packages/**` was built); the spec dist is fresh. A local state reading, not a finding about this diff. - Dogfood: not owed (no `!` in the title, no `(narrowing)` arm). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmT9dnPvF2SeYafuvmH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f72bc9e commit d68d129

10 files changed

Lines changed: 208 additions & 32 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
feat(spec): an inline action can carry `description`, the line its param dialog shows under the title
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
10+
11+
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.
12+
13+
- **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.
14+
- **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).
15+
- **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.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
354354
| **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. |
355355
| **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. |
356356
| **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. |
357+
| **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. |
357358
| **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. |
358359
| **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. |
359360
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ const result = ActionButtonPropsSchema.parse(data);
233233
| **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. |
234234
| **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. |
235235
| **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. |
236+
| **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. |
236237
| **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. |
237238
| **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. |
238239
| **errorMessage** | `string \| Record<string, string>` | optional | Error message to show when the action fails (overrides the raw error). |

‎packages/lint/src/validate-action-copy-templates.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,47 @@ describe('validateStackExpressions — description, params[].helpText and outcom
317317
expect(onObjectWrite.map((i) => i.where)).toEqual(["object 'crm_account' · action 'invite' description"]);
318318
});
319319

320+
// cloud#3281 (ruling A): `InlineActionSchema` picks `description`, so an
321+
// inline sentence is judged by the same slot rules as a registered one —
322+
// otherwise a text the registered door refuses would publish from a page.
323+
it('judges an inline `properties.action` description like a registered one, locale map included', () => {
324+
const button = (description: unknown) => ({
325+
type: 'element:button',
326+
properties: { label: 'Invite', action: { type: 'api', target: '/api/v1/invite', description } },
327+
});
328+
const page = pageWith([
329+
button('Invite ${record.name}?'),
330+
button({ en: 'Invite {{ record.name }}?', 'zh-CN': '邀请 ${record.name}?' }),
331+
button(' '),
332+
button('Sent to {{ result.email }}'),
333+
button('Invite {{ record.name }} to this account.'),
334+
button({ en: 'Sends an invitation e-mail.', 'zh-CN': '发送邀请邮件。' }),
335+
]);
336+
const issues = runStackExpressionPasses({ objects, pages: [page] }, { runtimeWriteType: 'page' })
337+
.filter((i) => /description/.test(i.where));
338+
expect(issues.map((i) => [i.where, i.severity])).toEqual([
339+
["page 'account_home'.regions[0].components[0] (element:button) · action description", 'error'],
340+
["page 'account_home'.regions[0].components[1] (element:button) · action description.zh-CN", 'error'],
341+
["page 'account_home'.regions[0].components[2] (element:button) · action description", 'error'],
342+
["page 'account_home'.regions[0].components[3] (element:button) · action description", 'error'],
343+
]);
344+
expect(issues[0]!.message).toContain("An action's `description`");
345+
expect(issues[0]!.message).toContain('as `Invite {{ record.name }}?`');
346+
expect(issues[2]!.message).toMatch(/^An action's `description` is blank/);
347+
});
348+
349+
it('the publish gate answers an inline `${…}` description on a page write with one error and no advisory', () => {
350+
const page = pageWith([{
351+
type: 'element:button',
352+
properties: { label: 'Invite', action: { type: 'api', target: '/api/v1/invite', description: 'Invite ${record.name}?' } },
353+
}]);
354+
const { errors, advisories } = runRuntimeAuthoringRules({ type: 'page', item: page, context: { objects } });
355+
const copy = (f: { where: string }) => /description/.test(f.where);
356+
expect(errors.filter(copy)).toHaveLength(1);
357+
expect(errors.filter(copy)[0]!.where).toContain('(element:button) · action description');
358+
expect(advisories.filter(copy)).toEqual([]);
359+
});
360+
320361
it('the publish gate answers each `${…}` with one error and no advisory', () => {
321362
const gate = (item: unknown) => {
322363
const { errors, advisories } = runRuntimeAuthoringRules({ type: 'action', item, context: { objects } });

‎packages/lint/src/validate-action-copy-templates.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
* all three keys;
1818
* - a page component's inline `properties.action` (`InlineActionSchema`, the
1919
* pick of `ActionSchema` an `element:button` carries): `confirmText`,
20-
* `successMessage` and each `params[].helpText`.
20+
* `successMessage`, `description` (each with the locale map) and each
21+
* `params[].helpText`.
2122
*
2223
* The same two steps the bulk `confirmText` pass takes, through the same
2324
* judge: `closedScopeTemplateRefusal` names a `${…}` or single-brace token
@@ -60,16 +61,21 @@ type CopyKey = 'confirmText' | 'successMessage' | 'errorMessage' | 'description'
6061
const ACTION_KEYS: readonly CopyKey[] = ['confirmText', 'successMessage'];
6162

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

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

7480
/** The keys a page action row carries as `template` slots. */
7581
const ROW_KEYS: readonly CopyKey[] = ['confirmText', 'successMessage', 'errorMessage'];

‎packages/spec/authorable-surface/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,7 @@
666666
"ui/HttpRequest:url",
667667
"ui/InlineAction:bodyExtra",
668668
"ui/InlineAction:confirmText",
669+
"ui/InlineAction:description",
669670
"ui/InlineAction:errorMessage",
670671
"ui/InlineAction:label",
671672
"ui/InlineAction:method",

0 commit comments

Comments
 (0)