diff --git a/.changeset/24814-action-copy-template-slots.md b/.changeset/24814-action-copy-template-slots.md new file mode 100644 index 00000000000..0bbbe212b0a --- /dev/null +++ b/.changeset/24814-action-copy-template-slots.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@objectstack/cli': minor +'@objectstack/platform-objects': minor +'@objectstack/plugin-approvals': minor +--- + +feat(spec,lint): an action's `confirmText`, `successMessage` and `errorMessage` are `template` slots over the roots the console's runner binds + +Clause-②: yes (widening) +Metadata-protocol: semantic + +- **Before.** On the page action rows (`action:button`, `action:icon`, an `action:group` / `action:menu` member) the three copy keys were plain `z.string()`s, and on a registered action `confirmText` / `successMessage` were `I18nLabel`s. None declared a hole. The console filled `${…}` in them (`${record.name}` in a confirm, `${result.email}` in a success toast), and the platform's own sentences were written that way. +- **Now.** Each is an ADR-0032 `template` slot (Decisions 2–3), the same mechanism as a bulk action's `confirmText`. A bare string is shorthand for `{ dialect: 'template', source }`, and the envelope is accepted too. Holes are `{{ path }}` and `{{ path | formatter[:arg] }}`, with formatters from the template engine's whitelist and no logic inside a hole. They read a closed scope, the one the console's action runner binds, declared in `@objectstack/spec/ui`: + - `confirmText`: `record`, the record the action runs against, and `params` only when the action's `params` is an object of static values (`actionConfirmTemplateRoots(action)`). An input list binds nothing, because its values are collected after the confirmation. + - `successMessage`: `result`, the payload the handler answered, then `record` (`ACTION_SUCCESS_TEMPLATE_ROOTS`). + - `errorMessage`, on the rows only: no root (`ACTION_ERROR_TEMPLATE_ROOTS`). The runner shows it as written. +- **Parsed shape.** A parsed copy key is now the `{ dialect: 'template', source }` envelope rather than a string. A reader of the parsed or served definition takes the text from `source` (`templateSlotSource` in `@objectstack/spec/automation` reads both forms). The console pinned by this release reads both. A registered action keeps its inline locale map, and a blank string still parses as itself. +- **Judged at the doors, as warnings.** `objectstack validate`, `objectstack compile` and the publish gate (on `action`, `object` and now `page` writes) judge every sentence through `closedScopeTemplateRefusal`, the template judge's closed-scope entry, then compile it as a template. A hole over a root the runner does not bind, a `${…}` or single-brace `{x}` token, a hole holding logic or an unknown formatter, and a blank sentence are each named. Each locale of an inline map is judged on its own. Every finding is a **warning**, an advisory at the publish gate and never a refusal, so no command's exit code changes: each of these texts parsed while the keys were plain strings. +- **`${…}` is prescribed its hole.** The judge reads a `${…}` token with its `$`, so `Delete ${record.name}?` is told to become `Delete {{ record.name }}?`. It no longer says `Delete ${{ record.name }}?`. A slot with no root names every hole and token in it as literal text. +- **The platform's sentences.** The `invite_user` success toast on `sys_invitation`, `sys_member`, `sys_organization` and `sys_user` (`Invitation sent to {{ result.email }}`) and the nine `sys_approval_request` decision toasts (`Approved "{{ result.request.record_title }}"`, …) now use `{{ }}` holes, in every shipped locale. The console fills them the same way. +- **Translation and extraction.** `translateAction` answers the authored envelope when no translation applies, and a translated sentence as a string. `os i18n extract` seeds an envelope-form `confirmText` / `successMessage` exactly like the bare string. +- **Unchanged.** `ActionSchema.errorMessage` and `outcomeMessages` are untouched (`outcomeMessages` still reads `${result.*}`), and so are `emptyText` and every other key. Producers measured at `e649c2d91e`: 13 authored copy sentences carried `${…}` (the 13 above) and none carried `{{`. After this change none carries `${…}`, and none in `examples/**`. diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index e25e4399bf5..b6d62e750fe 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -492,8 +492,8 @@ const result = ApiMethod.parse(data); | **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. 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 \| Record` | optional | Success message shown after the action succeeds. On a `type: 'api'` or `type: 'script'` action it may interpolate $`{result.*}` — the server response payload, the same scope `onSuccess.navigate` declares (e.g. $`{result.id}`). On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. 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` | 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. 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. | | **outcomeMessages** | `Record>` | optional | Success copy per handler outcome, for type:'api' and type:'script' actions: keys are the snake_case `outcome` values the handler returns in its success payload (e.g. archived, already_archived), values the message shown for that outcome. Each message may interpolate $`{result.*}`, the scope `onSuccess.navigate` declares. An outcome with no entry here falls back to `successMessage`, then to the default text. Not allowed beside `resultDialog` (which suppresses the success toast) or `operation: 'update'` (no handler, so no outcome). | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 05d7ac99680..98e9a531950 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -332,8 +332,8 @@ const result = MetadataBulkResultSchema.parse(data); | **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. 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 \| Record` | optional | Success message shown after the action succeeds. On a `type: 'api'` or `type: 'script'` action it may interpolate $`{result.*}` — the server response payload, the same scope `onSuccess.navigate` declares (e.g. $`{result.id}`). On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. 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` | 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. 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. | | **outcomeMessages** | `Record>` | optional | Success copy per handler outcome, for type:'api' and type:'script' actions: keys are the snake_case `outcome` values the handler returns in its success payload (e.g. archived, already_archived), values the message shown for that outcome. Each message may interpolate $`{result.*}`, the scope `onSuccess.navigate` declares. An outcome with no entry here falls back to `successMessage`, then to the default text. Not allowed beside `resultDialog` (which suppresses the success toast) or `operation: 'update'` (no handler, so no outcome). | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 2759310b2e5..aa48a2c13d3 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -46,8 +46,8 @@ const result = ActionSchema.parse(data); | **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. 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 \| Record` | optional | Success message shown after the action succeeds. On a `type: 'api'` or `type: 'script'` action it may interpolate $`{result.*}` — the server response payload, the same scope `onSuccess.navigate` declares (e.g. $`{result.id}`). On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. 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` | 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. 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. | | **outcomeMessages** | `Record>` | optional | Success copy per handler outcome, for type:'api' and type:'script' actions: keys are the snake_case `outcome` values the handler returns in its success payload (e.g. archived, already_archived), values the message shown for that outcome. Each message may interpolate $`{result.*}`, the scope `onSuccess.navigate` declares. An outcome with no entry here falls back to `successMessage`, then to the default text. Not allowed beside `resultDialog` (which suppresses the success toast) or `operation: 'update'` (no handler, so no outcome). | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | @@ -345,8 +345,8 @@ 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; … }[]` | 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. 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 \| Record` | optional | Success message shown after the action succeeds. On a `type: 'api'` or `type: 'script'` action it may interpolate $`{result.*}` — the server response payload, the same scope `onSuccess.navigate` declares (e.g. $`{result.id}`). On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. 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` | 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. 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` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | | **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. | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 0b629cd2a3f..c89cd9c6990 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -45,9 +45,9 @@ const result = ActionButtonPropsSchema.parse(data); | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | | **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape`, `recordIdField` and `onSuccess` are refused, and `list_toolbar` is not a location | | **patch** | `never` | optional | [REMOVED] An `action:button`'s `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **confirmText** | `string` | optional | Confirmation question asked before the action runs | -| **successMessage** | `string` | optional | Toast shown when the action succeeds | -| **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | +| **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | | **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | | **undoable** | `boolean` | optional | Offer an Undo affordance after an update action | | **recordIdField** | `string` | optional | Row field whose value identifies the record the action acts on | @@ -103,9 +103,9 @@ const result = ActionButtonPropsSchema.parse(data); | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | | **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location | | **patch** | `never` | optional | [REMOVED] An `action:group` or `action:menu` member's `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **confirmText** | `string` | optional | Confirmation question asked before the action runs | -| **successMessage** | `string` | optional | Toast shown when the action succeeds | -| **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | +| **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | | **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | @@ -139,9 +139,9 @@ const result = ActionButtonPropsSchema.parse(data); | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | | **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location | | **patch** | `never` | optional | [REMOVED] An `action:icon`'s `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **confirmText** | `string` | optional | Confirmation question asked before the action runs | -| **successMessage** | `string` | optional | Toast shown when the action succeeds | -| **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | +| **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | | **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | @@ -193,9 +193,9 @@ const result = ActionButtonPropsSchema.parse(data); | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | | **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location | | **patch** | `never` | optional | [REMOVED] An `action:group` or `action:menu` member's `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **confirmText** | `string` | optional | Confirmation question asked before the action runs | -| **successMessage** | `string` | optional | Toast shown when the action succeeds | -| **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Confirmation question asked before the action runs — 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | +| **errorMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Toast shown when the action fails, in place of the raw error — a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of those, and a blank sentence, is named at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. | | **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | @@ -233,8 +233,8 @@ 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; … }[]` | 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. 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 \| Record` | optional | Success message shown after the action succeeds. On a `type: 'api'` or `type: 'script'` action it may interpolate $`{result.*}` — the server response payload, the same scope `onSuccess.navigate` declares (e.g. $`{result.id}`). On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | +| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. 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` | 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 at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves. 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` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | | **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. | diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index eb0cfc8d0b0..ff99879fa3d 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -341,7 +341,7 @@ export const SCHEMAS: Record = { optional: [ { name: 'icon', type: 'string', description: 'Icon name' }, { name: 'objectName', type: 'string (snake_case)', description: 'Target object this action belongs to; when set, defineStack() merges it into that object\'s actions' }, - { name: 'confirmText', type: 'string (i18n)', description: 'Confirmation message before execution. Correct on a param-LESS action; pairing it with a non-empty `params` is refused — put the question on `description` instead.' }, + { name: 'confirmText', type: 'template (i18n)', description: 'Confirmation message before execution — a `{{ }}` template over `record`, the record the action runs against (`Delete {{ record.name }}?`). Correct on a param-LESS action; pairing it with a non-empty `params` is refused — put the question on `description` instead.' }, { name: 'locations', type: 'ActionLocation[]', description: 'Where the action is offered (record header, list toolbar, row, …)' }, { name: 'params', type: 'ActionParam[]', description: 'User-input collection for the action dialog' }, { name: 'variant', type: '"primary" | "secondary" | "danger" | "ghost" | "link"', description: 'Button visual variant' }, diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index d72f1cb8391..8b64f0abc66 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -664,6 +664,18 @@ function pushDerived( }); } +/** + * [#24814] An action's `confirmText` / `successMessage` is a `template` slot + * that also takes an inline locale map: a bare string, the parsed + * `{ dialect: 'template', source }` envelope, or the map. The envelope and the + * string are the same authored sentence, so the envelope is read through the + * template judge's one reader and seeded like the string; anything else (the + * map) is handed on as written, for {@link authoredEvidence} to file. + */ +function actionCopy(value: unknown): unknown { + return templateSlotSource(value) ?? value; +} + /** * Record an optional authored string: seeds the skeleton when present, and * still records the key when absent so the coverage gate notices a bundle that @@ -1346,8 +1358,8 @@ export function collectExpectedEntries( const aroot = ['objects', objectName, '_actions', aname]; pushDerived(out, [...aroot, 'label'], inlineText(action.label) ?? aname, action.label, 'action', { objectName }); pushOptional(out, [...aroot, 'description'], action.description, 'action', { objectName }); - pushOptional(out, [...aroot, 'confirmText'], action.confirmText, 'action', { objectName }); - pushOptional(out, [...aroot, 'successMessage'], action.successMessage, 'action', { objectName }); + pushOptional(out, [...aroot, 'confirmText'], actionCopy(action.confirmText), 'action', { objectName }); + pushOptional(out, [...aroot, 'successMessage'], actionCopy(action.successMessage), 'action', { objectName }); pushActionOutcomeMessages(out, aroot, action, 'action', objectName); pushActionParams(out, ['objects', objectName, '_actions', aname], action, 'action', objectName); pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName); @@ -1439,8 +1451,8 @@ export function collectExpectedEntries( // not an i18n gap to seed (`pushDerived` would invent an English source // string nothing authored). #7367. pushOptional(out, [...root, 'description'], action.description, kind, { objectName }); - pushOptional(out, [...root, 'confirmText'], action.confirmText, kind, { objectName }); - pushOptional(out, [...root, 'successMessage'], action.successMessage, kind, { objectName }); + pushOptional(out, [...root, 'confirmText'], actionCopy(action.confirmText), kind, { objectName }); + pushOptional(out, [...root, 'successMessage'], actionCopy(action.successMessage), kind, { objectName }); pushActionOutcomeMessages(out, root, action, kind, objectName); pushActionParams(out, root, action, kind, objectName); pushActionResultDialog(out, root, action, kind, objectName); diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index ed66d4da33b..3d69e6f50cb 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -750,8 +750,19 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // `project.view.ts`), none with a brace → 0 findings, against an error for // each of an unbound hole, a single-brace token, a hole holding logic and // a blank sentence in `validate-expressions.bulk-confirm-text.test.ts`. + // + // [#24814, ruling A] `page` joins, for ONE pass: an action's copy — + // `confirmText`, `successMessage`, `errorMessage` — is an ADR-0032 + // `template` slot over the roots the console's runner binds + // (`validate-action-copy-templates.ts`). On a page write + // `runStackExpressionPasses` runs that pass alone, over the written page's + // action rows and inline actions; an action or object write judges the + // written action's own `confirmText` / `successMessage` in the same pass. + // Every finding is a `warning` (an advisory, never a refusal), because + // every such sentence parsed while the keys were plain strings; the + // refusal is its own card. surfaces: CLI_AND_RUNTIME, - runtimeTypes: ['flow', 'action', 'hook', 'object', 'view'], + runtimeTypes: ['flow', 'action', 'hook', 'object', 'view', 'page'], run: (stack, ctx) => runStackExpressionPasses(stack, { runtimeWriteType: ctx.runtimeWriteType }).map((i) => ({ severity: i.severity ?? 'error', diff --git a/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts index 52f80ea4fbe..92c422fa313 100644 --- a/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts +++ b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts @@ -127,6 +127,9 @@ const dump = (r: unknown) => JSON.stringify(r, null, 2); describe('#24491 — the page door gives the build\'s visibility-predicate verdict', () => { it('dispatches `page` writes to both family rules, after the component rules', () => { expect(runtimeAuthoringRulesFor('page').map((r) => r.name)).toEqual([ + // #24814 — the expression rule's one page pass, the action copy template + // slots (warnings only); first because it is the registry's first rule. + 'validateStackExpressions', 'validatePresetComparands', 'validateComponentProps', 'validateComponentTypes', diff --git a/packages/lint/src/validate-action-copy-templates.test.ts b/packages/lint/src/validate-action-copy-templates.test.ts new file mode 100644 index 00000000000..d93c1fc1151 --- /dev/null +++ b/packages/lint/src/validate-action-copy-templates.test.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #24814 (ruling A) — an action's person-facing copy is an ADR-0032 `template` +// slot over the closed scope the console's runner binds: `confirmText` over +// `record` (and `params` beside static values), `successMessage` over `result` +// and `record`, `errorMessage` over nothing. `validateStackExpressions` — the +// rule `objectstack validate`, `objectstack compile` and the runtime publish +// gate all run — judges every sentence through the template judge's +// closed-scope entry, then compiles it as a template. Every finding is a +// WARNING: each of these texts parsed while the keys were plain strings, and +// refusing them is its own card. So a pin here asserts `severity: 'warning'` +// and, at the publish gate, an advisory with no error. +// +// The gate half drives the REAL dispatch (`runRuntimeAuthoringRules`) with a +// page write and an action write, because a `runtimeTypes` widening whose +// walk reads nothing is a no-op that reads as coverage. + +import { describe, expect, it } from 'vitest'; + +import { runStackExpressionPasses, validateStackExpressions } from './validate-expressions.js'; +import { runRuntimeAuthoringRules } from './runtime-gate.js'; + +const objects = [{ name: 'crm_account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } }]; + +const invite = (patch: Record) => ({ + name: 'invite', + label: 'Invite', + type: 'api', + target: '/api/v1/invite', + objectName: 'crm_account', + ...patch, +}); + +const copyIssues = (stack: Record) => + validateStackExpressions(stack).filter((i) => /confirmText|successMessage|errorMessage/.test(i.where)); + +const pageWith = (components: unknown[]) => ({ + name: 'account_home', + type: 'record', + object: 'crm_account', + regions: [{ name: 'main', components }], +}); + +describe('validateStackExpressions — a registered action\'s copy is judged as the template slot it is (#24814)', () => { + it('passes plain text and holes over bound roots — bare, as the envelope, or in a locale map', () => { + for (const patch of [ + { confirmText: 'Invite {{ record.name }}?' }, + { successMessage: 'Invitation sent to {{ result.email }}' }, + { successMessage: 'Sent to {{ result.email }} for {{ record.name | upper }}' }, + { successMessage: { dialect: 'template', source: 'Sent to {{ result.email }}' } }, + { successMessage: { en: 'Sent to {{ result.email }}', 'zh-CN': '已发送至 {{ result.email }}' } }, + { confirmText: 'Invite this contact?', successMessage: 'Invitation sent' }, + ]) { + expect(copyIssues({ objects, actions: [invite(patch)] }), JSON.stringify(patch)).toEqual([]); + } + }); + + it('names a `${…}` token with a WARNING prescribing its `{{ }}` hole, never `${{ … }}`', () => { + const [issue, ...rest] = copyIssues({ objects, actions: [invite({ successMessage: 'Invitation sent to ${result.email}' })] }); + expect(rest).toEqual([]); + expect(issue!.severity).toBe('warning'); + expect(issue!.where).toBe("stack · action 'invite' successMessage"); + expect(issue!.message).toContain('Write `Invitation sent to ${result.email}` as `Invitation sent to {{ result.email }}`.'); + expect(issue!.message).not.toContain('${{'); + expect(issue!.message).toMatch(/A warning, not a refusal/); + }); + + it('names a hole over a root the runner does not bind — `result` in a confirmText', () => { + const [issue] = copyIssues({ objects, actions: [invite({ confirmText: 'Send to {{ result.email }}?' })] }); + expect(issue!.severity).toBe('warning'); + expect(issue!.message).toContain("An action's `confirmText` binds only `record`"); + expect(issue!.message).toContain('`{{ result.email }}`'); + }); + + it('judges each value of a locale map at its own location', () => { + const issues = copyIssues({ objects, actions: [invite({ successMessage: { en: 'Sent', 'zh-CN': '已发送 {result.email}' } })] }); + expect(issues.map((i) => i.where)).toEqual(["stack · action 'invite' successMessage.zh-CN"]); + }); + + it('names logic in a hole, an unknown formatter and a blank sentence — all warnings', () => { + for (const successMessage of ['{{ result.count * 2 }} sent', '{{ result.email | shout }}', ' ']) { + const issues = copyIssues({ objects, actions: [invite({ successMessage })] }); + expect(issues.length, successMessage).toBeGreaterThan(0); + expect(issues.every((i) => i.severity === 'warning'), successMessage).toBe(true); + } + }); + + it('judges an object\'s own actions on an object write, once when also declared top-level', () => { + const action = invite({ confirmText: 'Invite ${record.name}?' }); + const stack = { objects: [{ ...objects[0], actions: [action] }], actions: [action] }; + expect(copyIssues(stack)).toHaveLength(1); + const onObjectWrite = runStackExpressionPasses({ objects: stack.objects }, { runtimeWriteType: 'object' }) + .filter((i) => i.where.includes('confirmText')); + expect(onObjectWrite.map((i) => i.where)).toEqual(["object 'crm_account' · action 'invite' confirmText"]); + }); + + it('control: ActionSchema\'s errorMessage is not in the family — a `${…}` there draws nothing', () => { + expect(copyIssues({ objects, actions: [invite({ errorMessage: 'Failed for ${record.name}' })] })).toEqual([]); + }); +}); + +describe('validateStackExpressions — a page action row\'s copy (#24814)', () => { + it('judges an action:button\'s three keys, binding `params` only beside static values', () => { + const button = (properties: Record) => ({ type: 'action:button', properties: { label: 'Run', actionType: 'script', target: 'run', ...properties } }); + const issues = copyIssues({ + objects, + pages: [pageWith([ + button({ confirmText: 'Run for {{ params.tier }}?', params: { tier: 'gold' } }), + button({ confirmText: 'Run for {{ params.tier }}?', params: [{ name: 'tier', type: 'text' }] }), + button({ errorMessage: 'Failed for {{ record.name }}' }), + button({ successMessage: 'Done', errorMessage: 'Could not run' }), + ])], + }); + expect(issues.map((i) => i.where)).toEqual([ + "page 'account_home'.regions[0].components[1] (action:button) · confirmText", + "page 'account_home'.regions[0].components[2] (action:button) · errorMessage", + ]); + expect(issues[0]!.message).toContain("binds only `record`, so a hole over any other root"); + expect(issues[1]!.message).toContain('binds no root, so it fills no hole'); + expect(issues.every((i) => i.severity === 'warning')).toBe(true); + }); + + it('judges each action:group / action:menu member, an action:icon, and an inline `properties.action`', () => { + const issues = copyIssues({ + objects, + pages: [pageWith([ + { type: 'action:group', properties: { actions: [{ name: 'a', type: 'script', target: 'a', confirmText: 'Run ${record.name}?' }] } }, + { type: 'action:menu', properties: { actions: [{ name: 'b', type: 'script', target: 'b', successMessage: '{count} done' }] } }, + { type: 'action:icon', properties: { label: 'X', actionType: 'script', target: 'x', confirmText: '' } }, + { type: 'element:button', properties: { label: 'Go', action: { type: 'script', target: 'go', successMessage: 'Went to ${result.url}' } } }, + ])], + }); + expect(issues.map((i) => i.where)).toEqual([ + "page 'account_home'.regions[0].components[0] (action:group) · actions[0] confirmText", + "page 'account_home'.regions[0].components[1] (action:menu) · actions[0] successMessage", + "page 'account_home'.regions[0].components[2] (action:icon) · confirmText", + "page 'account_home'.regions[0].components[3] (element:button) · action successMessage", + ]); + expect(issues[0]!.message).toContain('as `Run {{ record.name }}?`'); + expect(issues[2]!.message).toMatch(/^An action's `confirmText` is blank/); + }); + + it('on a page write runs that pass alone', () => { + const stack = { + objects, + pages: [pageWith([{ type: 'action:button', properties: { label: 'Run', actionType: 'script', target: 'run', confirmText: '{{ x }}' } }])], + flows: [{ name: 'f', nodes: [{ id: 'n', type: 'notify', config: { title: '{bad}' } }] }], + }; + const issues = runStackExpressionPasses(stack, { runtimeWriteType: 'page' }); + expect(issues).toHaveLength(1); + expect(issues[0]!.where).toContain('confirmText'); + }); +}); + +describe('the runtime publish gate — an advisory, never a refusal (#24814)', () => { + const findings = (type: string, item: unknown) => { + const { errors, advisories } = runRuntimeAuthoringRules({ type, item, context: { objects } }); + const copy = (f: { where: string }) => /confirmText|successMessage|errorMessage/.test(f.where); + return { errors: errors.filter(copy), advisories: advisories.filter(copy) }; + }; + + it('a page write carrying a `${…}` confirmText answers one advisory and no error', () => { + const page = pageWith([{ type: 'action:button', properties: { label: 'Run', actionType: 'script', target: 'run', confirmText: 'Delete ${record.name}?' } }]); + const { errors, advisories } = findings('page', page); + expect(errors).toEqual([]); + expect(advisories).toHaveLength(1); + expect(advisories[0]!.message).toContain('as `Delete {{ record.name }}?`'); + }); + + it('an action write carrying a `${result.*}` successMessage answers one advisory and no error', () => { + const { errors, advisories } = findings('action', invite({ successMessage: 'Invitation sent to ${result.email}' })); + expect(errors).toEqual([]); + expect(advisories).toHaveLength(1); + }); + + it('control: the converted platform sentence draws nothing', () => { + expect(findings('action', invite({ successMessage: 'Invitation sent to {{ result.email }}' }))).toEqual({ errors: [], advisories: [] }); + }); +}); diff --git a/packages/lint/src/validate-action-copy-templates.ts b/packages/lint/src/validate-action-copy-templates.ts new file mode 100644 index 00000000000..20e6b0cb04a --- /dev/null +++ b/packages/lint/src/validate-action-copy-templates.ts @@ -0,0 +1,190 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24814, ruling A] Judge an action's person-facing copy — the ADR-0032 + * `template` slots `confirmText`, `successMessage` and `errorMessage` + * (`@objectstack/spec`'s `ui/action-copy-template.ts`) — wherever an action is + * authored: + * + * - a registered action (`stack.actions[]`, and an object's own `actions[]`): + * `confirmText` and `successMessage`, each a bare string, the parsed + * envelope, or an inline locale map whose every value is such a sentence; + * - a page action row — `action:button` / `action:icon` `properties`, and + * each `properties.actions[]` member of an `action:group` / `action:menu` — + * all three keys; + * - a page component's inline `properties.action` (`InlineActionSchema`, the + * pick of `ActionSchema` an `element:button` carries): `confirmText` and + * `successMessage`. + * + * The same two steps the bulk `confirmText` pass takes, through the same + * judge: `closedScopeTemplateRefusal` names a `${…}` or single-brace token + * (with its `{{ }}` spelling where a bound root spells it) and a hole over a + * root the runner does not bind; a text carrying neither is compiled as the + * `template` it is (`validateExpression('template', …)`), which names a hole + * holding logic or an unknown formatter. A blank sentence is named too. Each + * key's roots are the spec's, measured at the console's runner: + * `actionConfirmTemplateRoots(action)`, `ACTION_SUCCESS_TEMPLATE_ROOTS`, + * `ACTION_ERROR_TEMPLATE_ROOTS` (none — the runner shows an `errorMessage` as + * written). + * + * ⚠️ Every finding is a WARNING: an advisory at the publish gate, never a + * refusal, so no command's exit code changes. Each of these texts parsed while + * the keys were plain strings; refusing them is a narrowing no ADR-0087 + * conversion carries, and it ships as its own card, as the bulk slot's did. + */ + +import { validateExpression } from '@objectstack/formula'; +import { closedScopeTemplateRefusal, templateSlotSource } from '@objectstack/spec/automation'; +import { + ACTION_ERROR_TEMPLATE_ROOTS, + ACTION_SUCCESS_TEMPLATE_ROOTS, + actionConfirmTemplateRoots, +} from '@objectstack/spec/ui'; +import type { ExprIssue } from './validate-expressions.js'; +import { recordsOf } from './object-graph.js'; +import { walkPageComponents } from './page-walk.js'; + +type AnyRec = Record; + +type CopyKey = 'confirmText' | 'successMessage' | 'errorMessage'; + +/** The keys a registered or inline action carries as `template` slots (`ActionSchema`'s `errorMessage` is not one). */ +const ACTION_KEYS: readonly CopyKey[] = ['confirmText', 'successMessage']; + +/** The keys a page action row carries as `template` slots. */ +const ROW_KEYS: readonly CopyKey[] = ['confirmText', 'successMessage', 'errorMessage']; + +/** The page components whose own `properties` are an action row. */ +const ROW_COMPONENTS: ReadonlySet = new Set(['action:button', 'action:icon']); + +/** The page components whose `properties.actions[]` members are action rows. */ +const CONTAINER_COMPONENTS: ReadonlySet = new Set(['action:group', 'action:menu']); + +/** A blank sentence — `trim()`'s whitespace set is exactly `\s`. */ +const BLANK_TEXT = /^\s*$/; + +/** What every finding ends with: it is advice, and why. */ +const ADVISORY = ' A warning, not a refusal: this text parsed while the key was a plain string.'; + +/** What a blank sentence costs, per key, at the console's runner. */ +const BLANK_COST: Record = { + confirmText: 'so no question is asked (an empty sentence declares no confirmation, and a whitespace one shows an ' + + 'empty dialog): write the question, or remove the key.', + successMessage: 'so the success toast shows the runner\'s default text: write the sentence, or remove the key.', + errorMessage: 'so the failure toast shows the raw error (or an empty toast, for a whitespace sentence): write the ' + + 'sentence, or remove the key.', +}; + +/** The roots one key's holes may name, for the action that carries it. */ +function rootsOf(key: CopyKey, action: AnyRec): readonly string[] { + if (key === 'confirmText') return actionConfirmTemplateRoots(action); + return key === 'successMessage' ? ACTION_SUCCESS_TEMPLATE_ROOTS : ACTION_ERROR_TEMPLATE_ROOTS; +} + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +/** + * The sentences one copy value carries, each with the location suffix it is + * named at: the value itself (a string or the `template` envelope), or — where + * the key takes an inline locale map — each string value of the map, at + * `.`. Anything else carries no sentence: the parse refuses its shape. + */ +function sentencesOf(value: unknown, localeMap: boolean): Array<{ at: string; text: string }> { + const text = templateSlotSource(value); + if (text !== undefined) return [{ at: '', text }]; + if (!localeMap || !isRec(value) || 'dialect' in value || 'source' in value) return []; + const out: Array<{ at: string; text: string }> = []; + for (const [locale, entry] of Object.entries(value)) { + if (typeof entry === 'string') out.push({ at: `.${locale}`, text: entry }); + } + return out; +} + +/** Every finding one action's copy keys draw, at `where`. */ +function judgeAction( + issues: ExprIssue[], + action: AnyRec, + where: string, + keys: readonly CopyKey[], + localeMap: boolean, +): void { + for (const key of keys) { + for (const { at, text } of sentencesOf(action[key], localeMap)) { + const located = `${where} ${key}${at}`; + const slot = `An action's \`${key}\``; + const warn = (message: string, quoted: string): void => { + issues.push({ where: located, message: `${message}${ADVISORY}`, source: quoted, severity: 'warning' }); + }; + if (BLANK_TEXT.test(text)) { + warn(`${slot} is blank, ${BLANK_COST[key]}`, text); + continue; + } + const refusal = closedScopeTemplateRefusal(text, { slot, roots: rootsOf(key, action) }); + if (refusal !== undefined) { + warn(refusal, text); + continue; + } + for (const e of validateExpression('template', text).errors) warn(e.message, e.source); + } + } +} + +/** Which collections one run judges — the publish gate narrows them per write type. */ +export interface ActionCopyTemplateScope { + /** The stack's top-level `actions[]`. */ + readonly actions: boolean; + /** Each object's own `actions[]`. */ + readonly objects: boolean; + /** Each page's action rows and inline actions. */ + readonly pages: boolean; +} + +/** + * Every warning an action's copy draws across the collections `scope` names + * (see the module note). An action declared both top-level and on its object + * (`defineStack` merges one onto the other) is judged once, at the first place + * it is met — the de-duplication the expression pass's `checkAction` applies. + */ +export function actionCopyTemplateIssues(stack: AnyRec, scope: ActionCopyTemplateScope): ExprIssue[] { + const issues: ExprIssue[] = []; + const seen = new Set(); + const registered = (owner: string, action: AnyRec, objectName?: string): void => { + const name = typeof action.name === 'string' ? action.name : '?'; + const bound = objectName ?? (typeof action.objectName === 'string' ? action.objectName : ''); + const key = `${bound}:${name}`; + if (seen.has(key)) return; + seen.add(key); + judgeAction(issues, action, `${owner} · action '${name}'`, ACTION_KEYS, true); + }; + if (scope.actions) { + for (const action of recordsOf(stack.actions)) registered('stack', action); + } + if (scope.objects) { + for (const object of recordsOf(stack.objects)) { + const objectName = typeof object.name === 'string' ? object.name : undefined; + for (const action of recordsOf(object.actions)) registered(`object '${objectName ?? '?'}'`, action, objectName); + } + } + if (scope.pages) { + let pi = -1; + for (const page of recordsOf(stack.pages)) { + pi += 1; + const pageName = typeof page.name === 'string' ? page.name : `#${pi}`; + for (const { component, path } of walkPageComponents(page, `page '${pageName}'`)) { + const type = typeof component.type === 'string' ? component.type : ''; + const props = isRec(component.properties) ? component.properties : undefined; + if (!props) continue; + if (ROW_COMPONENTS.has(type)) judgeAction(issues, props, `${path} (${type}) ·`, ROW_KEYS, false); + if (CONTAINER_COMPONENTS.has(type) && Array.isArray(props.actions)) { + props.actions.forEach((member: unknown, mi: number) => { + if (isRec(member)) judgeAction(issues, member, `${path} (${type}) · actions[${mi}]`, ROW_KEYS, false); + }); + } + if (isRec(props.action)) judgeAction(issues, props.action, `${path} (${type}) · action`, ACTION_KEYS, true); + } + } + } + return issues; +} diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index d1e247f4c77..f799e332758 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -3533,6 +3533,11 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t // this file is a local named `scope`; `graph.scope` is a KEY read off the // tabled `graph` receiver, so the metadata guard loses no coverage here. 'scope', + // [#24814] NOT a receiver either — the tail of the + // `'./validate-action-copy-templates.js'` import specifier, the same + // artefact as `scope` above. The action copy pass reads its metadata in + // that module, not here. + 'templates', // [#19939] The spec's value-slot template judge, one refusal at a time. // Its keys are that helper's own `{ path, label, message, source }` — // never metadata keys — and it is named to stay clear of the `message` / diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index b86ef1862f5..588c2fea5d1 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -109,6 +109,9 @@ import { // [#24798] A bulk action's `confirmText` is a `template` slot over the closed // scope the spec declares for it — judged below by the same template judge. import { bulkConfirmTemplateRoots } from '@objectstack/spec/ui'; +// [#24814] An action's copy — `confirmText`, `successMessage`, `errorMessage` — +// is a `template` slot too, judged in its own module by the same judge. +import { actionCopyTemplateIssues } from './validate-action-copy-templates.js'; // [#15137] The `value`-role half. Same two published primitives the engine // composes at `registerFlow` (`AutomationEngine.valueEnvelopeRefusals`), in the // same order: the SHAPE rule lives in the spec's `FlowValueSlotSchema` (the @@ -2005,6 +2008,10 @@ export interface StackExpressionOptions { * written view's `list` and `listViews` — every other pass reads a * collection a view write does not carry. * + * [#24814] An action's copy pass (`actionCopyTemplateIssues`, warnings only) + * judges an object write's own `actions[]`; on a `page` write it is the + * ONLY pass, over the written page's action rows and inline actions. + * * The passes over the stack's OTHER collections — flows, the top-level * `actions`, sharing rules and hooks — read an empty list on an object * write. None of them is the object's own body, the object write's snapshot @@ -2046,6 +2053,12 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // every other pass reads a collection a view write does not carry, so it // returns before them by construction rather than by snapshot shape. if (options.runtimeWriteType === 'view') return bulkConfirmTextIssues(stack, { views: true, objects: false }); + // [#24814] On a `page` write exactly ONE pass judges too — the action copy + // pass over the written page's action rows and inline actions + // (`validate-action-copy-templates.ts`), whose findings are warnings. + if (options.runtimeWriteType === 'page') { + return actionCopyTemplateIssues(stack, { actions: false, objects: false, pages: true }); + } const objects = recordsOf(stack.objects); const fieldIndex = buildFieldIndex(objects); const attachedOnReadIndex = buildAttachedOnReadIndex(objects); @@ -3467,6 +3480,10 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // `listViews` on every run (an object write included — they are the // object's own body), a view's list containers off an object write. issues.push(...bulkConfirmTextIssues(stack, { views: !objectWrite, objects: true })); + // [#24814] The action copy template slots: an object's own `actions[]` on + // every run (an object write included), the top-level `actions` and the + // pages' action rows off an object write. Warnings only. + issues.push(...actionCopyTemplateIssues(stack, { actions: !objectWrite, objects: true, pages: !objectWrite })); return issues; } diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index bfbce4b4e01..ee94b1a6b88 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -139,7 +139,7 @@ export const enObjects: NonNullable = { invite_user: { label: "Invite User", description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.", - successMessage: "Invitation sent to ${result.email}", + successMessage: "Invitation sent to {{ result.email }}", params: { role: { options: { @@ -576,7 +576,7 @@ export const enObjects: NonNullable = { invite_user: { label: "Invite User", description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.", - successMessage: "Invitation sent to ${result.email}", + successMessage: "Invitation sent to {{ result.email }}", params: { role: { options: { @@ -663,7 +663,7 @@ export const enObjects: NonNullable = { invite_user: { label: "Invite User", description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.", - successMessage: "Invitation sent to ${result.email}", + successMessage: "Invitation sent to {{ result.email }}", params: { role: { options: { @@ -793,7 +793,7 @@ export const enObjects: NonNullable = { invite_user: { label: "Invite User", description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.", - successMessage: "Invitation sent to ${result.email}", + successMessage: "Invitation sent to {{ result.email }}", params: { role: { options: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 49f8eb07ed0..97f79ffd849 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -139,7 +139,7 @@ export const esESObjects: NonNullable = { invite_user: { label: "Invitar usuario", description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.", - successMessage: "Invitación enviada a ${result.email}", + successMessage: "Invitación enviada a {{ result.email }}", params: { role: { options: { @@ -576,7 +576,7 @@ export const esESObjects: NonNullable = { invite_user: { label: "Invitar usuario", description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.", - successMessage: "Invitación enviada a ${result.email}", + successMessage: "Invitación enviada a {{ result.email }}", params: { role: { options: { @@ -663,7 +663,7 @@ export const esESObjects: NonNullable = { invite_user: { label: "Invitar usuario", description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.", - successMessage: "Invitación enviada a ${result.email}", + successMessage: "Invitación enviada a {{ result.email }}", params: { role: { options: { @@ -793,7 +793,7 @@ export const esESObjects: NonNullable = { invite_user: { label: "Invitar usuario", description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.", - successMessage: "Invitación enviada a ${result.email}", + successMessage: "Invitación enviada a {{ result.email }}", params: { role: { options: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 46616a89ce1..d789accffce 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -139,7 +139,7 @@ export const jaJPObjects: NonNullable = { invite_user: { label: "ユーザーを招待", description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", - successMessage: "${result.email} に招待を送信しました", + successMessage: "{{ result.email }} に招待を送信しました", params: { role: { options: { @@ -576,7 +576,7 @@ export const jaJPObjects: NonNullable = { invite_user: { label: "ユーザーを招待", description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", - successMessage: "${result.email} に招待を送信しました", + successMessage: "{{ result.email }} に招待を送信しました", params: { role: { options: { @@ -663,7 +663,7 @@ export const jaJPObjects: NonNullable = { invite_user: { label: "ユーザーを招待", description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", - successMessage: "${result.email} に招待を送信しました", + successMessage: "{{ result.email }} に招待を送信しました", params: { role: { options: { @@ -793,7 +793,7 @@ export const jaJPObjects: NonNullable = { invite_user: { label: "ユーザーを招待", description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", - successMessage: "${result.email} に招待を送信しました", + successMessage: "{{ result.email }} に招待を送信しました", params: { role: { options: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index b8febdf3fd8..d8f0c29febc 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -139,7 +139,7 @@ export const zhCNObjects: NonNullable = { invite_user: { label: "邀请用户", description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", - successMessage: "已向 ${result.email} 发送邀请", + successMessage: "已向 {{ result.email }} 发送邀请", params: { role: { options: { @@ -576,7 +576,7 @@ export const zhCNObjects: NonNullable = { invite_user: { label: "邀请用户", description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", - successMessage: "已向 ${result.email} 发送邀请", + successMessage: "已向 {{ result.email }} 发送邀请", params: { role: { options: { @@ -663,7 +663,7 @@ export const zhCNObjects: NonNullable = { invite_user: { label: "邀请用户", description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", - successMessage: "已向 ${result.email} 发送邀请", + successMessage: "已向 {{ result.email }} 发送邀请", params: { role: { options: { @@ -793,7 +793,7 @@ export const zhCNObjects: NonNullable = { invite_user: { label: "邀请用户", description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", - successMessage: "已向 ${result.email} 发送邀请", + successMessage: "已向 {{ result.email }} 发送邀请", params: { role: { options: { diff --git a/packages/platform-objects/src/identity/invite-user-action-copy.test.ts b/packages/platform-objects/src/identity/invite-user-action-copy.test.ts index 47cd105ac73..0df4692be07 100644 --- a/packages/platform-objects/src/identity/invite-user-action-copy.test.ts +++ b/packages/platform-objects/src/identity/invite-user-action-copy.test.ts @@ -9,11 +9,12 @@ // - `description` is the parameter dialog's subtitle. With none declared the // dialog falls back to a generic "Please provide the required information // to continue.", which says nothing about what Confirm will do. -// - `successMessage` is the toast after Confirm. On a `type: 'api'` action the -// runner fills `${result.*}` from the answer (ActionSchema.successMessage). -// `/api/v1/auth/organization/invite-member` answers the bare invitation row, -// whose top-level `email` is the invitee's address, so `${result.email}` -// names the person invited. A copy without the token names nobody. +// - `successMessage` is the toast after Confirm, a `template` slot (#24814): +// the runner fills its `{{ result.* }}` holes from the answer +// (ActionSchema.successMessage). `/api/v1/auth/organization/invite-member` +// answers the bare invitation row, whose top-level `email` is the invitee's +// address, so `{{ result.email }}` names the person invited. A copy without +// the hole names nobody. // // The translated bundles are pinned as well, because the extractor cannot keep // them honest by itself: `--fill=default` seeds a NEW key (the description) @@ -22,6 +23,7 @@ // the old, invitee-less sentence. Both states are "in sync" to `check:i18n`. import { describe, expect, it } from 'vitest'; import { translateMetadataDocument } from '@objectstack/spec/system'; +import { templateSlotSource } from '@objectstack/spec/automation'; import { SetupAppTranslations } from '../apps/translations/setup.translation.js'; import { enObjects } from '../apps/translations/en.objects.generated.js'; import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js'; @@ -38,8 +40,8 @@ interface CopyAction { successMessage?: unknown; } -/** The token the console runner fills from the invite-member answer. */ -const INVITEE = '${result.email}'; +/** The hole the console runner fills from the invite-member answer. */ +const INVITEE = '{{ result.email }}'; const MIRRORS: Array<[string, unknown]> = [ ['sys_user', SysUser], @@ -54,12 +56,15 @@ const TRANSLATED = [ ['es-ES', esESObjects], ] as const; +// [#24814] `successMessage` is a `template` slot: the parsed +// object carries the `{ dialect: 'template', source }` envelope, so the copy is +// read through the template judge's one reader, `templateSlotSource`. function invite(object: unknown): CopyAction { const found = ((object as { actions?: CopyAction[] }).actions ?? []).find( (a) => a.name === 'invite_user', ); expect(found, 'invite_user is declared').toBeDefined(); - return found as CopyAction; + return { ...found, successMessage: templateSlotSource(found?.successMessage) } as CopyAction; } const bundleNode = (bundle: unknown, object: string): CopyAction | undefined => diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index 6dff380f717..1f7ce92850a 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -73,9 +73,9 @@ export const SysInvitation = ObjectSchema.create({ visible: 'features.cloudManaged != true', requiresFeature: 'organization', requiresMembershipReach: 'invite_member', - // `${result.email}`: the invitee's address from the invitation row + // `{{ result.email }}`: the invitee's address from the invitation row // invite-member answers (see sys_user's mirror). - successMessage: 'Invitation sent to ${result.email}', + successMessage: 'Invitation sent to {{ result.email }}', refreshAfter: true, params: [ { field: 'email', required: true }, diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index bc04c4adf83..7b5b0c78e27 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -111,9 +111,9 @@ export const SysMember = ObjectSchema.create({ // ...and the grades that reach the endpoint (ADR-0108 D1): owner, // admin and delegated_admin — a plain member is not offered it. requiresMembershipReach: 'invite_member', - // `${result.email}`: the invitee's address from the invitation row + // `{{ result.email }}`: the invitee's address from the invitation row // invite-member answers (see sys_user's mirror). - successMessage: 'Invitation sent to ${result.email}', + successMessage: 'Invitation sent to {{ result.email }}', refreshAfter: true, params: [ { field: 'email', objectOverride: 'sys_invitation', required: true }, diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 358dfbfa731..7efb6117798 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -177,9 +177,9 @@ export const SysOrganization = ObjectSchema.create({ visible: 'features.cloudManaged != true', requiresFeature: 'organization', requiresMembershipReach: 'invite_member', - // `${result.email}`: the invitee's address from the invitation row + // `{{ result.email }}`: the invitee's address from the invitation row // invite-member answers (see sys_user's mirror). - successMessage: 'Invitation sent to ${result.email}', + successMessage: 'Invitation sent to {{ result.email }}', refreshAfter: true, params: [ // ⚠️ Neither `email` nor `role` is a field of sys_organization, so diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 1f522f9d8d0..616eb62d078 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -106,9 +106,9 @@ export const SysUser = ObjectSchema.create({ visible: 'features.cloudManaged != true', requiresFeature: 'organization', requiresMembershipReach: 'invite_member', - // `${result.email}`: invite-member answers the bare invitation row, + // `{{ result.email }}`: invite-member answers the bare invitation row, // whose top-level `email` is the invitee's address as stored (lowercased). - successMessage: 'Invitation sent to ${result.email}', + successMessage: 'Invitation sent to {{ result.email }}', refreshAfter: true, params: [ { field: 'email', required: true }, diff --git a/packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts b/packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts index 2e3e56c0c1b..0827576fd1a 100644 --- a/packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts +++ b/packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts @@ -16,6 +16,7 @@ // translated value, so a revised English source leaves the old promise served // in zh-CN / ja-JP / es-ES while `check:i18n` reads "in sync". import { describe, expect, it } from 'vitest'; +import { templateSlotSource } from '@objectstack/spec/automation'; import { enObjects } from '../apps/translations/en.objects.generated.js'; import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js'; import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js'; @@ -34,10 +35,17 @@ interface CopyAction { visible?: unknown; } +// [#24814] `confirmText` / `successMessage` are `template` slots: the parsed +// object carries the `{ dialect: 'template', source }` envelope, so the copy is +// read through the template judge's one reader, `templateSlotSource`. function declared(name: string): CopyAction { const found = ((SysMember as { actions?: CopyAction[] }).actions ?? []).find((a) => a.name === name); expect(found, `${name} is declared`).toBeDefined(); - return found as CopyAction; + return { + ...found, + confirmText: templateSlotSource(found?.confirmText), + successMessage: templateSlotSource(found?.successMessage), + } as CopyAction; } const bundled = (bundle: unknown, name: string): CopyAction | undefined => diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index 2506b6096e5..68b56bebc08 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -10,7 +10,7 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { APPROVAL_REVISE_NODE_TYPE } from '@objectstack/spec/automation'; +import { APPROVAL_REVISE_NODE_TYPE, templateSlotSource } from '@objectstack/spec/automation'; import { ApprovalService, REMIND_COOLDOWN_MS, ESCALATION_ENABLED_FLIP_CUTOFF_MS, SLA_ACTOR_ID } from './approval-service.js'; import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js'; import { celEngine } from '@objectstack/formula'; @@ -1338,16 +1338,18 @@ describe('ApprovalService (node era)', () => { // [#23157] Every request action is `type: 'api'` against a route that // answers its service method's result verbatim, and each of those results // carries `request`, the read-back row with its display enrichment. Each - // action's success copy interpolates `${result.*}` over that answer (the - // scope `ActionSchema.successMessage` declares), so the toast names the - // request only while the answer carries the title the copy reads. + // action's success copy fills `{{ result.* }}` holes over that answer (the + // scope `ActionSchema.successMessage` declares, a `template` slot since + // #24814), so the toast names the request only while the answer carries the + // title the copy reads. describe('each request action\'s success copy, filled from its route\'s answer', () => { + // [#24814] The parsed object carries the `template` envelope; the copy is its `source`. const copyOf = (name: string): string => - ((SysApprovalRequest as any).actions as any[]).find((a) => a.name === name).successMessage; - /** The copy's `${result.}` tokens, each filled from the answer; an unresolved path reads as ''. */ + templateSlotSource(((SysApprovalRequest as any).actions as any[]).find((a) => a.name === name).successMessage)!; + /** The copy's `{{ result. }}` holes, each filled from the answer; an unresolved path reads as ''. */ const fill = (copy: string, result: unknown): { text: string; unresolved: string[] } => { const unresolved: string[] = []; - const text = copy.replace(/\$\{result\.([\w.]+)\}/g, (_m, path: string) => { + const text = copy.replace(/\{\{\s*result\.([\w.]+)\s*\}\}/g, (_m, path: string) => { const v = path.split('.').reduce((acc, k) => (acc == null ? undefined : acc[k]), result); if (v == null || String(v) === '') unresolved.push(path); return v == null ? '' : String(v); @@ -1381,9 +1383,9 @@ describe('ApprovalService (node era)', () => { } as any); }); - it('reads the answer: every action\'s copy carries a `${result.*}` token', () => { + it('reads the answer: every action\'s copy carries a `{{ result.* }}` hole', () => { for (const a of (SysApprovalRequest as any).actions as any[]) { - expect(a.successMessage, `${a.name}.successMessage`).toMatch(/\$\{result\.[\w.]+\}/); + expect(templateSlotSource(a.successMessage), `${a.name}.successMessage`).toMatch(/\{\{ result\.[\w.]+ \}\}/); } }); diff --git a/packages/plugins/plugin-approvals/src/subject-title-reader-fls.integration.test.ts b/packages/plugins/plugin-approvals/src/subject-title-reader-fls.integration.test.ts index f8eb1ec1a3b..753e0d6051c 100644 --- a/packages/plugins/plugin-approvals/src/subject-title-reader-fls.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/subject-title-reader-fls.integration.test.ts @@ -31,7 +31,7 @@ * (b) the same reader, with the subject record deleted and the snapshot * carrying the title, still gets none; * (c) the decision result row — what the success toast's - * `${result.request.record_title}` token reads — carries none for that + * `{{ result.request.record_title }}` hole reads — carries none for that * reader; * (d) the action-link page, reached by a token and read under the system * context, names the record by its id for EVERY bearer; @@ -239,7 +239,7 @@ describe('[#23594] the subject title is served from what its reader may read', ( const { requestId } = await openDeal(); const out: any = await svc.decide(requestId, { decision: 'approve', actorId: RESTRICTED }, asUser(RESTRICTED)); expect(out.request.status).toBe('approved'); - // `Approved "${result.request.record_title}"` reads nothing here. + // `Approved "{{ result.request.record_title }}"` reads nothing here. expect(out.request).not.toHaveProperty('record_title'); expect(JSON.stringify(out.request)).not.toContain(LIVE_TITLE); }); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts index 7848701513b..1d31e362689 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts @@ -597,11 +597,11 @@ export const SysApprovalRequest = ObjectSchema.create({ // decide route answers `ApprovalDecisionResult`, whose `request` is the // read-back row with its display enrichment, so `record_title` is the // same title the inbox row shows (the target record's display field, - // else its payload snapshot). `${result.*}` is the success copy's + // else its payload snapshot). `{{ result.* }}` is the success copy's // declared scope on a `type: 'api'` action: the route's own response. // Every action below names the request the same way: each of their // routes answers `{ request, … }` with that same read-back row. - successMessage: 'Approved "${result.request.record_title}"', + successMessage: 'Approved "{{ result.request.record_title }}"', refreshAfter: true, }, { @@ -631,7 +631,7 @@ export const SysApprovalRequest = ObjectSchema.create({ 'has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true' + ' || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true', locations: ['record_section', 'list_item'], - successMessage: 'Rejected "${result.request.record_title}"', + successMessage: 'Rejected "{{ result.request.record_title }}"', refreshAfter: true, }, { @@ -654,7 +654,7 @@ export const SysApprovalRequest = ObjectSchema.create({ 'has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true' + ' || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true', locations: ['record_section'], - successMessage: 'Reassigned "${result.request.record_title}"', + successMessage: 'Reassigned "{{ result.request.record_title }}"', refreshAfter: true, }, @@ -674,7 +674,7 @@ export const SysApprovalRequest = ObjectSchema.create({ ], visible: 'has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true', locations: ['record_section'], - successMessage: 'Sent "${result.request.record_title}" back for revision', + successMessage: 'Sent "{{ result.request.record_title }}" back for revision', refreshAfter: true, }, { @@ -689,7 +689,7 @@ export const SysApprovalRequest = ObjectSchema.create({ ], visible: 'has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true', locations: ['record_section'], - successMessage: 'Information requested on "${result.request.record_title}"', + successMessage: 'Information requested on "{{ result.request.record_title }}"', refreshAfter: true, }, @@ -716,7 +716,7 @@ export const SysApprovalRequest = ObjectSchema.create({ ], visible: 'has(record.status) && record.status == "pending" && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true', locations: ['record_section'], - successMessage: 'Reminder sent for "${result.request.record_title}"', + successMessage: 'Reminder sent for "{{ result.request.record_title }}"', refreshAfter: true, }, { @@ -758,7 +758,7 @@ export const SysApprovalRequest = ObjectSchema.create({ ' && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true' + ' || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true', locations: ['record_section'], - successMessage: 'Recalled "${result.request.record_title}"', + successMessage: 'Recalled "{{ result.request.record_title }}"', refreshAfter: true, }, { @@ -773,7 +773,7 @@ export const SysApprovalRequest = ObjectSchema.create({ ], visible: 'has(record.status) && record.status == "returned" && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true', locations: ['record_section'], - successMessage: 'Resubmitted "${result.request.record_title}"', + successMessage: 'Resubmitted "{{ result.request.record_title }}"', refreshAfter: true, }, @@ -821,7 +821,7 @@ export const SysApprovalRequest = ObjectSchema.create({ ' || has(record.status) && record.status == "pending"' + ' && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true', locations: ['record_section'], - successMessage: 'Reply posted on "${result.request.record_title}"', + successMessage: 'Reply posted on "{{ result.request.record_title }}"', refreshAfter: true, }, ], diff --git a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts index bc99ce332a4..d3d917f0bbd 100644 --- a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts @@ -137,7 +137,7 @@ export const enObjects: NonNullable = { approval_approve: { label: "Approve", description: "Approve this request? Your approval is recorded, and the request moves on once this step has the approvals it requires.", - successMessage: "Approved \"${result.request.record_title}\"", + successMessage: "Approved \"{{ result.request.record_title }}\"", params: { comment: { label: "Comment" @@ -150,7 +150,7 @@ export const enObjects: NonNullable = { approval_reject: { label: "Reject", description: "Reject this request? A rejection is final for every approver.", - successMessage: "Rejected \"${result.request.record_title}\"", + successMessage: "Rejected \"{{ result.request.record_title }}\"", params: { comment: { label: "Comment" @@ -162,7 +162,7 @@ export const enObjects: NonNullable = { }, approval_reassign: { label: "Reassign", - successMessage: "Reassigned \"${result.request.record_title}\"", + successMessage: "Reassigned \"{{ result.request.record_title }}\"", params: { to: { label: "New approver", @@ -175,7 +175,7 @@ export const enObjects: NonNullable = { }, approval_send_back: { label: "Send back", - successMessage: "Sent \"${result.request.record_title}\" back for revision", + successMessage: "Sent \"{{ result.request.record_title }}\" back for revision", params: { comment: { label: "Reason" @@ -184,7 +184,7 @@ export const enObjects: NonNullable = { }, approval_request_info: { label: "Request info", - successMessage: "Information requested on \"${result.request.record_title}\"", + successMessage: "Information requested on \"{{ result.request.record_title }}\"", params: { comment: { label: "What do you need?" @@ -193,7 +193,7 @@ export const enObjects: NonNullable = { }, approval_remind: { label: "Send reminder", - successMessage: "Reminder sent for \"${result.request.record_title}\"", + successMessage: "Reminder sent for \"{{ result.request.record_title }}\"", params: { comment: { label: "Note" @@ -203,7 +203,7 @@ export const enObjects: NonNullable = { approval_recall: { label: "Recall", description: "Recall this request? Approvers can no longer act on it and the record is unlocked.", - successMessage: "Recalled \"${result.request.record_title}\"", + successMessage: "Recalled \"{{ result.request.record_title }}\"", params: { comment: { label: "Comment" @@ -212,7 +212,7 @@ export const enObjects: NonNullable = { }, approval_resubmit: { label: "Resubmit", - successMessage: "Resubmitted \"${result.request.record_title}\"", + successMessage: "Resubmitted \"{{ result.request.record_title }}\"", params: { comment: { label: "What changed?" @@ -221,7 +221,7 @@ export const enObjects: NonNullable = { }, approval_comment: { label: "Reply", - successMessage: "Reply posted on \"${result.request.record_title}\"", + successMessage: "Reply posted on \"{{ result.request.record_title }}\"", params: { comment: { label: "Comment" diff --git a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts index d2579dc31d9..89f30f0ee76 100644 --- a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts @@ -137,7 +137,7 @@ export const esESObjects: NonNullable = { approval_approve: { label: "Aprobar", description: "¿Aprobar esta solicitud? Su aprobación queda registrada y la solicitud avanza cuando este paso reúne las aprobaciones que requiere.", - successMessage: "\"${result.request.record_title}\" aprobada", + successMessage: "\"{{ result.request.record_title }}\" aprobada", params: { comment: { label: "Comentario" @@ -150,7 +150,7 @@ export const esESObjects: NonNullable = { approval_reject: { label: "Rechazar", description: "¿Rechazar esta solicitud? Un rechazo es definitivo para todos los aprobadores.", - successMessage: "\"${result.request.record_title}\" rechazada", + successMessage: "\"{{ result.request.record_title }}\" rechazada", params: { comment: { label: "Comentario" @@ -162,7 +162,7 @@ export const esESObjects: NonNullable = { }, approval_reassign: { label: "Reasignar", - successMessage: "\"${result.request.record_title}\" reasignada", + successMessage: "\"{{ result.request.record_title }}\" reasignada", params: { to: { label: "Nuevo aprobador", @@ -175,7 +175,7 @@ export const esESObjects: NonNullable = { }, approval_send_back: { label: "Devolver", - successMessage: "\"${result.request.record_title}\" devuelta para revisión", + successMessage: "\"{{ result.request.record_title }}\" devuelta para revisión", params: { comment: { label: "Motivo" @@ -184,7 +184,7 @@ export const esESObjects: NonNullable = { }, approval_request_info: { label: "Pedir información", - successMessage: "Información solicitada sobre \"${result.request.record_title}\"", + successMessage: "Información solicitada sobre \"{{ result.request.record_title }}\"", params: { comment: { label: "¿Qué necesitas?" @@ -193,7 +193,7 @@ export const esESObjects: NonNullable = { }, approval_remind: { label: "Enviar recordatorio", - successMessage: "Recordatorio enviado para \"${result.request.record_title}\"", + successMessage: "Recordatorio enviado para \"{{ result.request.record_title }}\"", params: { comment: { label: "Nota" @@ -203,7 +203,7 @@ export const esESObjects: NonNullable = { approval_recall: { label: "Retirar", description: "¿Retirar esta solicitud? Los aprobadores ya no podrán actuar sobre ella y el registro se desbloqueará.", - successMessage: "\"${result.request.record_title}\" retirada", + successMessage: "\"{{ result.request.record_title }}\" retirada", params: { comment: { label: "Comentario" @@ -212,7 +212,7 @@ export const esESObjects: NonNullable = { }, approval_resubmit: { label: "Reenviar", - successMessage: "\"${result.request.record_title}\" reenviada", + successMessage: "\"{{ result.request.record_title }}\" reenviada", params: { comment: { label: "¿Qué ha cambiado?" @@ -221,7 +221,7 @@ export const esESObjects: NonNullable = { }, approval_comment: { label: "Responder", - successMessage: "Respuesta publicada en \"${result.request.record_title}\"", + successMessage: "Respuesta publicada en \"{{ result.request.record_title }}\"", params: { comment: { label: "Comentario" diff --git a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts index 6cd8f977d2d..e9975840956 100644 --- a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts @@ -137,7 +137,7 @@ export const jaJPObjects: NonNullable = { approval_approve: { label: "承認", description: "このリクエストを承認しますか?あなたの承認が記録され、このステップに必要な承認がそろうとリクエストは次に進みます。", - successMessage: "「${result.request.record_title}」を承認しました", + successMessage: "「{{ result.request.record_title }}」を承認しました", params: { comment: { label: "コメント" @@ -150,7 +150,7 @@ export const jaJPObjects: NonNullable = { approval_reject: { label: "却下", description: "このリクエストを却下しますか?却下はすべての承認者に対して最終決定になります。", - successMessage: "「${result.request.record_title}」を却下しました", + successMessage: "「{{ result.request.record_title }}」を却下しました", params: { comment: { label: "コメント" @@ -162,7 +162,7 @@ export const jaJPObjects: NonNullable = { }, approval_reassign: { label: "再割り当て", - successMessage: "「${result.request.record_title}」を再割り当てしました", + successMessage: "「{{ result.request.record_title }}」を再割り当てしました", params: { to: { label: "新しい承認者", @@ -175,7 +175,7 @@ export const jaJPObjects: NonNullable = { }, approval_send_back: { label: "差し戻し", - successMessage: "「${result.request.record_title}」を修正のため差し戻しました", + successMessage: "「{{ result.request.record_title }}」を修正のため差し戻しました", params: { comment: { label: "理由" @@ -184,7 +184,7 @@ export const jaJPObjects: NonNullable = { }, approval_request_info: { label: "追加情報を依頼", - successMessage: "「${result.request.record_title}」について追加情報を依頼しました", + successMessage: "「{{ result.request.record_title }}」について追加情報を依頼しました", params: { comment: { label: "何が必要ですか?" @@ -193,7 +193,7 @@ export const jaJPObjects: NonNullable = { }, approval_remind: { label: "リマインダー送信", - successMessage: "「${result.request.record_title}」のリマインダーを送信しました", + successMessage: "「{{ result.request.record_title }}」のリマインダーを送信しました", params: { comment: { label: "メモ" @@ -203,7 +203,7 @@ export const jaJPObjects: NonNullable = { approval_recall: { label: "取り下げ", description: "このリクエストを取り下げますか?承認者は操作できなくなり、レコードのロックが解除されます。", - successMessage: "「${result.request.record_title}」を取り下げました", + successMessage: "「{{ result.request.record_title }}」を取り下げました", params: { comment: { label: "コメント" @@ -212,7 +212,7 @@ export const jaJPObjects: NonNullable = { }, approval_resubmit: { label: "再提出", - successMessage: "「${result.request.record_title}」を再提出しました", + successMessage: "「{{ result.request.record_title }}」を再提出しました", params: { comment: { label: "何を変更しましたか?" @@ -221,7 +221,7 @@ export const jaJPObjects: NonNullable = { }, approval_comment: { label: "返信", - successMessage: "「${result.request.record_title}」に返信を投稿しました", + successMessage: "「{{ result.request.record_title }}」に返信を投稿しました", params: { comment: { label: "コメント" diff --git a/packages/plugins/plugin-approvals/src/translations/success-copy-names-request-i18n.test.ts b/packages/plugins/plugin-approvals/src/translations/success-copy-names-request-i18n.test.ts index cdf069ce5f2..88d9f90fc21 100644 --- a/packages/plugins/plugin-approvals/src/translations/success-copy-names-request-i18n.test.ts +++ b/packages/plugins/plugin-approvals/src/translations/success-copy-names-request-i18n.test.ts @@ -3,13 +3,14 @@ // Every request action's success toast names the request it acted on // (#23157), and every shipped locale says so too. // -// The copy is an `I18nLabel` the console resolves from these bundles BEFORE it -// fills the `${result.*}` tokens, so a locale whose value drops the token +// The copy is a `template` slot (#24814) the console resolves from these +// bundles BEFORE it fills the `{{ result.* }}` holes, so a locale whose value drops the hole // toasts the bare verb in that language while English names the record. No // drift gate sees that: `check:i18n` reads a translated-locale value as // hand-written text and never compares its tokens with the source's. import { describe, it, expect } from 'vitest'; +import { templateSlotSource } from '@objectstack/spec/automation'; import { SysApprovalRequest } from '../sys-approval-request.object.js'; import { enObjects } from './en.objects.generated.js'; import { zhCNObjects } from './zh-CN.objects.generated.js'; @@ -24,10 +25,12 @@ const LOCALES = [ ] as const; /** Every action declared on the request object, with its source copy. */ -const ACTIONS = ((SysApprovalRequest as any).actions as Array<{ name: string; successMessage?: string }>) - .map((a) => [a.name, a.successMessage ?? ''] as const); +// [#24814] `successMessage` is a `template` slot: the parsed object carries the +// envelope, read through the template judge's one reader. +const ACTIONS = ((SysApprovalRequest as any).actions as Array<{ name: string; successMessage?: unknown }>) + .map((a) => [a.name, templateSlotSource(a.successMessage) ?? ''] as const); -const tokensOf = (copy: string): string[] => [...copy.matchAll(/\$\{result\.[\w.]+\}/g)].map((m) => m[0]).sort(); +const tokensOf = (copy: string): string[] => [...copy.matchAll(/\{\{ result\.[\w.]+ \}\}/g)].map((m) => m[0]).sort(); const bundleCopy = (bundle: unknown, action: string): string | undefined => (bundle as any)?.sys_approval_request?._actions?.[action]?.successMessage; @@ -42,7 +45,7 @@ describe('each sys_approval_request action\'s success copy names the request, in it('every source copy reads the request title from its route\'s answer', () => { for (const [name, copy] of ACTIONS) { - expect(tokensOf(copy), `${name}.successMessage`).toEqual(['${result.request.record_title}']); + expect(tokensOf(copy), `${name}.successMessage`).toEqual(['{{ result.request.record_title }}']); } }); diff --git a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts index 85462b6fa2a..b1500234565 100644 --- a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts @@ -137,7 +137,7 @@ export const zhCNObjects: NonNullable = { approval_approve: { label: "通过", description: "通过该请求?你的审批将被记录,此步骤获得所需的审批后,请求即继续流转。", - successMessage: "已通过“${result.request.record_title}”", + successMessage: "已通过“{{ result.request.record_title }}”", params: { comment: { label: "审批意见" @@ -150,7 +150,7 @@ export const zhCNObjects: NonNullable = { approval_reject: { label: "拒绝", description: "拒绝该请求?拒绝对所有审批人立即生效。", - successMessage: "已拒绝“${result.request.record_title}”", + successMessage: "已拒绝“{{ result.request.record_title }}”", params: { comment: { label: "审批意见" @@ -162,7 +162,7 @@ export const zhCNObjects: NonNullable = { }, approval_reassign: { label: "转签", - successMessage: "已转签“${result.request.record_title}”", + successMessage: "已转签“{{ result.request.record_title }}”", params: { to: { label: "新审批人", @@ -175,7 +175,7 @@ export const zhCNObjects: NonNullable = { }, approval_send_back: { label: "退回修改", - successMessage: "已将“${result.request.record_title}”退回修改", + successMessage: "已将“{{ result.request.record_title }}”退回修改", params: { comment: { label: "原因" @@ -184,7 +184,7 @@ export const zhCNObjects: NonNullable = { }, approval_request_info: { label: "退回补充", - successMessage: "已就“${result.request.record_title}”退回给申请人补充材料", + successMessage: "已就“{{ result.request.record_title }}”退回给申请人补充材料", params: { comment: { label: "需要申请人补充什么?" @@ -193,7 +193,7 @@ export const zhCNObjects: NonNullable = { }, approval_remind: { label: "催办", - successMessage: "已就“${result.request.record_title}”发送催办", + successMessage: "已就“{{ result.request.record_title }}”发送催办", params: { comment: { label: "备注" @@ -203,7 +203,7 @@ export const zhCNObjects: NonNullable = { approval_recall: { label: "撤回", description: "撤回该请求?撤回后审批人将无法继续处理,记录随即解锁。", - successMessage: "已撤回“${result.request.record_title}”", + successMessage: "已撤回“{{ result.request.record_title }}”", params: { comment: { label: "备注" @@ -212,7 +212,7 @@ export const zhCNObjects: NonNullable = { }, approval_resubmit: { label: "重新提交", - successMessage: "已重新提交“${result.request.record_title}”", + successMessage: "已重新提交“{{ result.request.record_title }}”", params: { comment: { label: "你修改了什么?" @@ -221,7 +221,7 @@ export const zhCNObjects: NonNullable = { }, approval_comment: { label: "回复", - successMessage: "已在“${result.request.record_title}”上发送回复", + successMessage: "已在“{{ result.request.record_title }}”上发送回复", params: { comment: { label: "回复内容" diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index ce3ae3a284d..df6c5f4f465 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -638,6 +638,50 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ covers: ['ui/bulk-action.zod.ts:BulkActionDefSchema.confirmText'], note: 'EXPERIMENTAL until the console fills the holes. The spec half declares the slot, its closed scope and the display-value rule a param hole renders; the console half — the dialog rendering the slot through the formula template engine, `count` first and the param roots second — lands in objectui, and this row is re-stated as `enforced` with the fail policy that renderer has once it is measured there. Until then a hole reaches the screen as literal text, as it did before the slot was typed.', }, + // #24814 (ruling A): an action's copy — `confirmText`, `successMessage`, `errorMessage` — became a + // `template` slot over the closed scope the console's runner binds, on a registered action + // (`actionObject`, which `InlineActionSchema` picks) and on the three page action rows. + { + id: 'template-action-confirm-text', + summary: 'action confirm sentence (Action.confirmText, and the page action rows\' `confirmText`) — `{{ record.* }}` holes, and `{{ params.* }}` beside static values', + dialect: 'template', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', + enforcement: + 'PARSE, measured here: `ui/action-copy-template.ts` `actionCopyTemplateSlot` around `templateExpressionInput`, built at each declaring file, normalizes a bare string to `{dialect:"template",source}`, keeps a blank string as written and (on `Action`) the inline locale map. BUILD-TIME GATE, measured here: lint/validate-action-copy-templates.ts `actionCopyTemplateIssues`, run by `validateStackExpressions` at `objectstack validate` / `compile` and the publish gate (`action`, `object` and `page` writes), judges each sentence with `closedScopeTemplateRefusal` over `actionConfirmTemplateRoots(action)` and then `validateExpression(\'template\', …)` — every finding a WARNING. EVALUATOR: console (objectui) `ActionRunner.confirmMessage` → `fillTemplateHoles` → @objectstack/formula `templateEngine.evaluate` over `record` (the dispatched row, else the context record) and `params` (the static values), read at the pinned `.objectui-sha`; a sentence the engine refuses is shown as written, an unbound hole prints empty, and a sentence with no `{{ }}` hole keeps the transitional `${…}` reading', + covers: [ + 'ui/action.zod.ts:actionObject.confirmText', + 'ui/component.zod.ts:ActionButtonPropsSchema.confirmText', + 'ui/component.zod.ts:ActionIconPropsSchema.confirmText', + 'ui/component.zod.ts:actionContainerMemberShape.confirmText', + ], + note: 'Soft on both faces — a refused sentence reads as written and an unbound root as a blank fragment — which is why the doors name both ahead of time. Warnings, not refusals: each such sentence parsed while the key was a plain string, so the refusal is its own card. ⛔ NOT MEASURED HERE: the runner was read at the pin, not run, and the `-log` half of `fail-soft-log` (no log line was found on either face).', + }, + { + id: 'template-action-success-message', + summary: 'action success toast (Action.successMessage, and the page action rows\' `successMessage`) — `{{ result.* }}` and `{{ record.* }}` holes', + dialect: 'template', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', + enforcement: + 'PARSE and BUILD-TIME GATE: the same as `template-action-confirm-text`, over `ACTION_SUCCESS_TEMPLATE_ROOTS` (`result`, then `record`). EVALUATOR: console (objectui) `ActionRunner.composeSuccessMessage` → `fillTemplateHoles` → @objectstack/formula `templateEngine.evaluate` over `result` (the payload the handler answered) and `record`, read at the pinned `.objectui-sha`; `outcomeMessages[outcome]` outranks it, a blank sentence falls to the runner\'s default text, and a sentence with no `{{ }}` hole keeps the transitional `${result.*}` reading', + covers: [ + 'ui/action.zod.ts:actionObject.successMessage', + 'ui/component.zod.ts:ActionButtonPropsSchema.successMessage', + 'ui/component.zod.ts:ActionIconPropsSchema.successMessage', + 'ui/component.zod.ts:actionContainerMemberShape.successMessage', + ], + note: 'The platform\'s own sentences (`sys_invitation` / `sys_member` / `sys_organization` / `sys_user` `invite_user`, the nine `sys_approval_request` decisions) were converted from `${result.*}` to `{{ result.* }}` on the same change. `outcomeMessages` is NOT this row: it stays a `${result.*}` string. ⛔ NOT MEASURED HERE: the runner was read at the pin, not run, and the `-log` half of `fail-soft-log`.', + }, + { + id: 'template-action-error-message', + summary: 'page action row failure toast (the rows\' `errorMessage`) — a `template` slot that binds no root', + dialect: 'template', mode: 'interpret', state: 'experimental', failPolicy: 'unevaluated', + enforcement: + 'PARSE ONLY in this repo — the rows type the key with the same `template` slot as `template-action-confirm-text`, over `ACTION_ERROR_TEMPLATE_ROOTS`, which is EMPTY. NO EVALUATOR FOUND: the console\'s action runner shows an authored `errorMessage` as written (its envelope\'s `source`) and fills no hole, read at the pinned `.objectui-sha`. At build and at the publish gate the action copy pass names every hole and every `${…}` / `{…}` token in it as literal text, a WARNING', + covers: [ + 'ui/component.zod.ts:ActionButtonPropsSchema.errorMessage', + 'ui/component.zod.ts:ActionIconPropsSchema.errorMessage', + 'ui/component.zod.ts:actionContainerMemberShape.errorMessage', + ], + note: 'EXPERIMENTAL until a scope is ruled for the failure toast. `Action.errorMessage` (the registered action\'s) is NOT typed: it stays an `I18nLabel`, outside this family by the ruling, so it declares no expression surface.', + }, { id: 'template-notify-content', summary: 'flow `notify` node inline content (NotifyConfig.title, .message) — `{{ }}` template holes rendered per run by the formula template engine', diff --git a/packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts b/packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts index 58ecbeb1a29..da6d9b2e41d 100644 --- a/packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts +++ b/packages/qa/dogfood/test/member-transfer-ownership-step-down.dogfood.test.ts @@ -40,6 +40,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { celEngine } from '@objectstack/formula'; +import { templateSlotSource } from '@objectstack/spec/automation'; import { bootShowcase } from './showcase-boot.js'; const SYSTEM_CTX = { isSystem: true }; @@ -47,8 +48,10 @@ const SYSTEM_CTX = { isSystem: true }; type ServedAction = { name: string; label?: string; - confirmText?: string; - successMessage?: string; + // [#24814] `template` slots: a translated sentence is served as a string, an + // untranslated one as the parsed `{ dialect: 'template', source }` envelope. + confirmText?: unknown; + successMessage?: unknown; target?: string; bodyExtra?: Record; visible?: unknown; @@ -142,14 +145,15 @@ describe('sys_member.transfer_ownership makes the target an owner; the caller st expect(action!.target).toBe('/api/v1/auth/organization/update-member-role'); expect(action!.bodyExtra).toEqual({ role: 'owner' }); - const copy = [action!.label, action!.confirmText, action!.successMessage].join(' | '); + const confirmText = templateSlotSource(action!.confirmText); + const copy = [action!.label, confirmText, templateSlotSource(action!.successMessage)].join(' | '); // No demotion and no transfer is promised anywhere in the copy. expect(copy).not.toMatch(/demot/i); expect(copy).not.toMatch(/transfer/i); expect(action!.label).toBe('Make Owner'); // The confirm names the outcome and the real step-down, by the step-down's own label. - expect(action!.confirmText).toContain('You stay an owner'); - expect(action!.confirmText).toContain(served.get('update_member_role')?.label ?? 'update_member_role is not served'); + expect(confirmText).toContain('You stay an owner'); + expect(confirmText).toContain(served.get('update_member_role')?.label ?? 'update_member_role is not served'); }); it('the action, as an owner, makes the target an owner and leaves the caller an owner', async () => { diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index c302c6d1d30..8042db8169d 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -2,8 +2,13 @@ "description": "Every exported `name (kind)` of one published entry point of @objectstack/spec — the breadth half of the ADR-0059 backward-compatibility gate. Sharded by entry point (#5837) so two PRs touching different entry points never share a file. Reads the BUILT dist/*.d.ts: regenerate with `pnpm --filter @objectstack/spec gen:api-surface` after a real build.", "entry": "./ui", "exports": [ + "ACTION_CONFIRM_PARAMS_ROOT (const)", + "ACTION_COPY_RECORD_ROOT (const)", + "ACTION_ERROR_TEMPLATE_ROOTS (const)", "ACTION_LOCATIONS (const)", "ACTION_PARAM_BUILTIN_KEYS (const)", + "ACTION_SUCCESS_RESULT_ROOT (const)", + "ACTION_SUCCESS_TEMPLATE_ROOTS (const)", "ASSEMBLED_VIEW_ITEMS_KEY (const)", "Action (const)", "Action (type)", @@ -490,6 +495,7 @@ "WidgetActionTypeSchema (const)", "WidgetColorVariant (type)", "WidgetColorVariantSchema (const)", + "actionConfirmTemplateRoots (function)", "actionForm (const)", "anonymousFormIntakeCandidates (function)", "anonymousFormIntakeSlug (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index ad631f70cf8..6946b57ef5c 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -2,8 +2,13 @@ "description": "Which SOURCE DECLARATION each name exported by one public entry point of @objectstack/spec resolves to, after its alias chain is unwound: `# ()`. Two exports share an origin string iff they are the same declaration — so equal origins across two entries are a harmless re-export, and different origins under one name are the #4411 dual-source trap. Generated from src/ (no build needed) and read by the export-surface pin tests, which compare against it instead of each building their own ts.createProgram — that was ~55s of compilation per CI lap and a non-deterministic timeout that ejected unrelated PRs from the merge queue (#4796). Sharded by entry point (#5837) so two retirement PRs never share a file. Carries NO line numbers: the pins asserted the line as `\\d+`, and recording it would rewrite this artifact on every edit that shifts a line in any .zod.ts. Regenerate with `pnpm --filter @objectstack/spec gen:export-origins` and read the diff.", "entry": "./ui", "exports": { + "ACTION_CONFIRM_PARAMS_ROOT": "src/ui/action-copy-template.ts#ACTION_CONFIRM_PARAMS_ROOT (const)", + "ACTION_COPY_RECORD_ROOT": "src/ui/action-copy-template.ts#ACTION_COPY_RECORD_ROOT (const)", + "ACTION_ERROR_TEMPLATE_ROOTS": "src/ui/action-copy-template.ts#ACTION_ERROR_TEMPLATE_ROOTS (const)", "ACTION_LOCATIONS": "src/ui/action.zod.ts#ACTION_LOCATIONS (const)", "ACTION_PARAM_BUILTIN_KEYS": "src/ui/action-params.zod.ts#ACTION_PARAM_BUILTIN_KEYS (const)", + "ACTION_SUCCESS_RESULT_ROOT": "src/ui/action-copy-template.ts#ACTION_SUCCESS_RESULT_ROOT (const)", + "ACTION_SUCCESS_TEMPLATE_ROOTS": "src/ui/action-copy-template.ts#ACTION_SUCCESS_TEMPLATE_ROOTS (const)", "ASSEMBLED_VIEW_ITEMS_KEY": "src/ui/assembled-views.zod.ts#ASSEMBLED_VIEW_ITEMS_KEY (const)", "Action": "src/ui/action.zod.ts#Action (type)", "ActionAi": "src/ui/action.zod.ts#ActionAi (type)", @@ -475,6 +480,7 @@ "WidgetActionTypeSchema": "src/ui/dashboard.zod.ts#WidgetActionTypeSchema (const)", "WidgetColorVariant": "src/ui/dashboard.zod.ts#WidgetColorVariant (type)", "WidgetColorVariantSchema": "src/ui/dashboard.zod.ts#WidgetColorVariantSchema (const)", + "actionConfirmTemplateRoots": "src/ui/action-copy-template.ts#actionConfirmTemplateRoots (function)", "actionForm": "src/ui/action.form.ts#actionForm (const)", "anonymousFormIntakeCandidates": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeCandidates (function)", "anonymousFormIntakeSlug": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeSlug (function)", diff --git a/packages/spec/src/automation/flow-text-slot-template.test.ts b/packages/spec/src/automation/flow-text-slot-template.test.ts index e8ad28a0969..79323e8a84b 100644 --- a/packages/spec/src/automation/flow-text-slot-template.test.ts +++ b/packages/spec/src/automation/flow-text-slot-template.test.ts @@ -294,6 +294,50 @@ describe('closedScopeTemplateRefusal — the same judge over a CLOSED scope (#24 }); }); +describe('closedScopeTemplateRefusal — a `${…}` token and a scope with no root (#24814)', () => { + const confirm = { slot: "An action's `confirmText`", roots: ['record'] }; + const success = { slot: "An action's `successMessage`", roots: ['result', 'record'] }; + const error = { slot: "An action's `errorMessage`", roots: [] as string[] }; + + it('reads a `${…}` token with its `$`: the prescription is the hole alone, never `${{ … }}`', () => { + const message = closedScopeTemplateRefusal('Delete ${record.name}?', confirm)!; + expect(message).toContain('Write `Delete ${record.name}?` as `Delete {{ record.name }}?`.'); + expect(message).not.toContain('${{'); + expect(message.startsWith("An action's `confirmText` reads `{{ }}` template holes (ADR-0032 §3), not the `${…}` spelling")).toBe(true); + expect(closedScopeTemplateRefusal('Invitation sent to ${result.email}', success)) + .toContain('as `Invitation sent to {{ result.email }}`'); + }); + + it('names a `${…}` token over an unbound root, or holding logic, with its `$` and no rewrite', () => { + const message = closedScopeTemplateRefusal('${owner.name} ${record.amount * 2}', confirm)!; + expect(message).not.toContain('Write `'); + expect(message).toContain('`${owner.name}` names `owner`, which this slot does not bind'); + expect(message).toContain('`${record.amount * 2}` is logic'); + }); + + it('a text carrying both spellings leads with both and rewrites each', () => { + const message = closedScopeTemplateRefusal('${record.name} or {record.id}', confirm)!; + expect(message).toContain('not the `${…}` spelling or the single-brace `{…}` dialect'); + expect(message).toContain('as `{{ record.name }} or {{ record.id }}`'); + }); + + it('a scope with no root names every token and every hole as literal text, prescribing no hole', () => { + const tokens = closedScopeTemplateRefusal('Failed for ${record.name}', error)!; + expect(tokens).toMatch(/^An action's `errorMessage` binds no root, so it fills no hole/); + expect(tokens).not.toContain('Write `'); + expect(tokens).toContain('`${record.name}` is not filled here'); + const holes = closedScopeTemplateRefusal('Failed for {{ record.name }}', error)!; + expect(holes).toContain('fills no hole and a hole is shown as literal text: `{{ record.name }}`'); + expect(closedScopeTemplateRefusal('The invitation could not be sent.', error)).toBeUndefined(); + }); + + it('control: the bulk sentence over its own roots keeps its single-brace reading', () => { + const message = closedScopeTemplateRefusal('Update {count}', { slot: "A bulk action's `confirmText`", roots: ['count'] })!; + expect(message).toContain('not the single-brace `{…}` dialect: a `{…}` token there is no placeholder and is shown as literal text.'); + expect(message).toContain('as `Update {{ count }}`'); + }); +}); + describe('templateSlotSource — one reading of a template slot value', () => { it('reads a string, the source of a template envelope, and nothing else', () => { expect(templateSlotSource('a {{ b }}')).toBe('a {{ b }}'); diff --git a/packages/spec/src/automation/flow-text-slot-template.ts b/packages/spec/src/automation/flow-text-slot-template.ts index 722a0e505b2..e12c4385173 100644 --- a/packages/spec/src/automation/flow-text-slot-template.ts +++ b/packages/spec/src/automation/flow-text-slot-template.ts @@ -530,14 +530,20 @@ export function flowNodeTextSlotSources(nodeType: string, config: unknown): Flow * a closed slot binds a handful of names its contract declares, so any other * root renders a blank fragment and is judged by name. * - * Today one slot reads a closed scope: a bulk action's `confirmText` - * (`ui/bulk-action.zod.ts`, ADR-0032 Decisions 2–3), whose roots are - * `bulkConfirmTemplateRoots(def)` — `count` and the def's params by name. + * Two families read a closed scope (ADR-0032 Decisions 2–3): a bulk + * action's `confirmText` (`ui/bulk-action.zod.ts`), whose roots are + * `bulkConfirmTemplateRoots(def)` — `count` and the def's params by name — + * and (#24814) the action copy slots, an action's `confirmText`, + * `successMessage` and `errorMessage` (`ui/action-copy-template.ts`), + * whose roots are what the console's action runner binds for each. */ export interface ClosedTemplateScope { /** The slot as a sentence names it, capitalised: ``A bulk action's `confirmText` ``. */ readonly slot: string; - /** Every root a hole may name, in the order a refusal lists them. */ + /** + * Every root a hole may name, in the order a refusal lists them. Empty for a + * slot whose renderer fills no hole and shows its text as written. + */ readonly roots: readonly string[]; } @@ -546,26 +552,84 @@ function rootList(roots: readonly string[]): string { return roots.map((root) => `\`${root}\``).join(', '); } +/** + * Whether `token` is written `${…}` — a `$` directly before its brace (#24814). + * The action copy slots carried that spelling before they were typed: the + * console's runner filled it in a sentence with no `{{ }}` hole (through its + * `${…}` evaluator for `confirmText`, over `result` for `successMessage`), and + * the platform's own sentences were written in it. Read as a bare single-brace + * token, its prescription kept the `$` (`Delete ${{ record.name }}?`), a + * spelling that renders a stray `$`; so a closed scope reads the `$` as part + * of the token, and the token's hole replaces both. + */ +function isDollarToken(text: string, token: TemplateToken): boolean { + return token.index > 0 && text[token.index - 1] === '$'; +} + +/** `token` as the author wrote it — its `$` included when it is a `${…}` token. */ +function writtenToken(text: string, token: TemplateToken): string { + return isDollarToken(text, token) ? `$${token.text}` : token.text; +} + +/** + * `text` with each single-brace token `spellable` admits written as a hole, a + * `${…}` token's `$` replaced with it — {@link doubled} for a closed scope. + */ +function closedDoubled(text: string, tokens: readonly TemplateToken[], spellable: (token: TemplateToken) => boolean): string { + let out = ''; + let at = 0; + for (const token of tokens) { + const dollar = isDollarToken(text, token); + const from = dollar ? token.index - 1 : token.index; + out += text.slice(at, from) + (spellable(token) ? `{{ ${token.inner} }}` : writtenToken(text, token)); + at = token.index + token.text.length; + } + return out + text.slice(at); +} + /** The remedy for one single-brace token a closed scope cannot spell as a hole. */ -function closedUnspellableRemedy(token: TemplateToken, scope: ClosedTemplateScope): string { +function closedUnspellableRemedy(written: string, token: TemplateToken, scope: ClosedTemplateScope): string { + if (scope.roots.length === 0) return `\`${written}\` is not filled here: reword the sentence without it.`; if (token.kind === 'path') { return ( - `\`${token.text}\` names \`${pathRoot(token.inner) ?? token.inner}\`, which this slot does not bind, so no hole ` + `\`${written}\` names \`${pathRoot(token.inner) ?? token.inner}\`, which this slot does not bind, so no hole ` + `spells it: name one of ${rootList(scope.roots)}, or reword the sentence.` ); } if (token.kind === 'expression') { return ( - `\`${token.text}\` is logic, and a hole is a bound path with an optional whitelisted formatter ` - + '(`{{ count | number }}`), never logic (ADR-0032 §3): reword the sentence around a bound hole.' + `\`${written}\` is logic, and a hole is a bound path with an optional whitelisted formatter ` + + '(`{{ path | formatter }}`), never logic (ADR-0032 §3): reword the sentence around a bound hole.' ); } return ( - `\`${token.text}\` is not one of the roots this slot binds (${rootList(scope.roots)}), so no hole spells it: ` + `\`${written}\` is not one of the roots this slot binds (${rootList(scope.roots)}), so no hole spells it: ` + 'delete it or reword the sentence (a text slot has no escape for literal braces).' ); } +/** + * The sentence a closed scope's refusal of its single-brace and `${…}` tokens + * leads with. A scope with no root fills no hole at all — the slot's text is + * shown as written — so for it every token is literal text, whatever its + * spelling. + */ +function closedTokenLead(scope: ClosedTemplateScope, dollar: boolean, plain: boolean): string { + const spelled = dollar && plain ? '`${…}` or `{…}`' : dollar ? '`${…}`' : '`{…}`'; + if (scope.roots.length === 0) { + return `${scope.slot} binds no root, so it fills no hole: its text is shown as written, and a ${spelled} token ` + + 'there is shown as literal text.'; + } + const dialect = dollar && plain + ? 'the `${…}` spelling or the single-brace `{…}` dialect' + : dollar ? 'the `${…}` spelling' : 'the single-brace `{…}` dialect'; + const clauses = [ + ...(dollar ? ['write each `${…}` token as the `{{ }}` hole it names'] : []), + ...(plain ? ['a `{…}` token there is no placeholder and is shown as literal text'] : []), + ]; + return `${scope.slot} reads \`{{ }}\` template holes (ADR-0032 §3), not ${dialect}: ${clauses.join(', and ')}.`; +} + /** * Why `text` — the template of a slot that reads `scope` — is refused, or * `undefined` when it carries neither a single-brace `{…}` token nor a hole @@ -577,9 +641,14 @@ function closedUnspellableRemedy(token: TemplateToken, scope: ClosedTemplateScop * its path's first segment, a formatter not included. What differs is the * scope: a path token over a bound root is prescribed its `{{ }}` spelling, * any other token its own remedy, and every hole whose root is not in - * `scope.roots` is named. A hole that does not compile as a path (logic, an - * unknown formatter) is left to `validateExpression('template', …)`, which - * every door runs next. + * `scope.roots` is named. A token written `${…}` is read with its `$` + * ({@link isDollarToken}): its prescription is the hole alone. A hole that + * does not compile as a path (logic, an unknown formatter) is left to + * `validateExpression('template', …)`, which every door runs next. + * + * A scope with no root (`roots: []`) is a slot whose renderer shows its text + * as written: every token and every hole is named as literal text there, and + * none is prescribed a hole. */ export function closedScopeTemplateRefusal(text: string, scope: ClosedTemplateScope): string | undefined { const bound = new Set(scope.roots); @@ -592,16 +661,16 @@ export function closedScopeTemplateRefusal(text: string, scope: ClosedTemplateSc const tokens = singleBraceTokens(text); if (tokens.length > 0) { - const parts = [ - `${scope.slot} reads \`{{ }}\` template holes (ADR-0032 §3), not the single-brace \`{…}\` dialect: a \`{…}\` ` - + 'token there is no placeholder and is shown as literal text.', - ]; - if (tokens.some(spellable)) parts.push(`Write \`${text}\` as \`${doubled(text, tokens, spellable)}\`.`); + const dollar = tokens.some((token) => isDollarToken(text, token)); + const plain = tokens.some((token) => !isDollarToken(text, token)); + const parts = [closedTokenLead(scope, dollar, plain)]; + if (tokens.some(spellable)) parts.push(`Write \`${text}\` as \`${closedDoubled(text, tokens, spellable)}\`.`); const seen = new Set(); for (const token of tokens) { - if (spellable(token) || seen.has(token.text)) continue; - seen.add(token.text); - parts.push(closedUnspellableRemedy(token, scope)); + const written = writtenToken(text, token); + if (spellable(token) || seen.has(written)) continue; + seen.add(written); + parts.push(closedUnspellableRemedy(written, token, scope)); } refusals.push(parts.join(' ')); } @@ -613,9 +682,13 @@ export function closedScopeTemplateRefusal(text: string, scope: ClosedTemplateSc unbound.push(hole.text); } if (unbound.length > 0) { + const holes = unbound.map((hole) => `\`${hole}\``).join(', '); refusals.push( - `${scope.slot} binds only ${rootList(scope.roots)}, so a hole over any other root renders a blank fragment: ` - + `${unbound.map((hole) => `\`${hole}\``).join(', ')} — name one of those roots, or reword the sentence.`, + scope.roots.length === 0 + ? `${scope.slot} binds no root, so it fills no hole and a hole is shown as literal text: ${holes} — reword ` + + 'the sentence without it.' + : `${scope.slot} binds only ${rootList(scope.roots)}, so a hole over any other root renders a blank fragment: ` + + `${holes} — name one of those roots, or reword the sentence.`, ); } return refusals.length === 0 ? undefined : refusals.join(' '); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 2956a32125a..624ece64829 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -190,8 +190,14 @@ export interface ActionLike { * a resolver answers with ONE locale's string. */ description?: string; - confirmText?: string; - successMessage?: string; + /** + * `template` slots (#24814): the bare string, or the parsed + * `{ dialect: 'template', source }` envelope a served action carries. A + * translation replaces either with the bundle's sentence, a string; with no + * translation the authored value is answered as it is. + */ + confirmText?: string | { dialect: 'template'; source: string }; + successMessage?: string | { dialect: 'template'; source: string }; /** * `ActionSchema.outcomeMessages` — success copy per handler `outcome`, * narrowed to `string` values for the same reason `successMessage` is. @@ -647,13 +653,15 @@ export function resolveActionLabel( /** * Resolve a translated confirmation prompt for an action, returning - * `undefined` if neither the bundle nor the action defines one. + * `undefined` if neither the bundle nor the action defines one. With no + * translation the authored value is answered as written — on a parsed action + * the `template` envelope (#24814), which the console's runner reads. */ export function resolveActionConfirm( bundle: TranslationBundle | undefined, action: ActionLike, opts?: ResolveOptions, -): string | undefined { +): ActionLike['confirmText'] { return lookupActionField(bundle, action, 'confirmText', opts) ?? action.confirmText; } @@ -725,13 +733,14 @@ export function resolveActionResultDialog( /** * Resolve a translated success message for an action, returning `undefined` - * if neither the bundle nor the action defines one. + * if neither the bundle nor the action defines one. With no translation the + * authored value is answered as written, as {@link resolveActionConfirm} does. */ export function resolveActionSuccess( bundle: TranslationBundle | undefined, action: ActionLike, opts?: ResolveOptions, -): string | undefined { +): ActionLike['successMessage'] { return ( lookupActionField(bundle, action, 'successMessage', opts) ?? action.successMessage diff --git a/packages/spec/src/ui/action-copy-template.test.ts b/packages/spec/src/ui/action-copy-template.test.ts new file mode 100644 index 00000000000..96b6e770cce --- /dev/null +++ b/packages/spec/src/ui/action-copy-template.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #24814 (ruling A) — an action's person-facing copy is an ADR-0032 `template` + * slot over a CLOSED scope: `confirmText`, `successMessage` and `errorMessage` + * on the page action rows, `confirmText` and `successMessage` on + * `ActionSchema`. These pins hold each slot's accept set — a bare string is + * shorthand for the envelope, a holeless sentence still parses, a blank one + * still parses (the widening keeps that arm), and `ActionSchema` keeps its + * inline locale map — and the roots the spec declares from the runner. The + * holes are judged at the doors through `closedScopeTemplateRefusal` + * (`automation/flow-text-slot-template.test.ts` pins the judge) and + * `@objectstack/lint`'s `validate-action-copy-templates` pins the doors. + */ +import { describe, expect, it } from 'vitest'; + +import { + ACTION_CONFIRM_PARAMS_ROOT, + ACTION_COPY_RECORD_ROOT, + ACTION_ERROR_TEMPLATE_ROOTS, + ACTION_SUCCESS_RESULT_ROOT, + ACTION_SUCCESS_TEMPLATE_ROOTS, + actionConfirmTemplateRoots, +} from './index'; +import { ActionSchema } from './action.zod'; +import { ActionButtonPropsSchema, ActionGroupPropsSchema, ActionIconPropsSchema } from './component.zod'; + +const KEYS = ['confirmText', 'successMessage', 'errorMessage'] as const; + +/** The three row surfaces: each parses one action row, and answers the parsed row. */ +const ROWS = [ + ['action:button', (row: Record) => ActionButtonPropsSchema.safeParse({ label: 'Run', actionType: 'script', target: 'run', ...row })], + ['action:icon', (row: Record) => ActionIconPropsSchema.safeParse({ label: 'Run', actionType: 'script', target: 'run', ...row })], + ['action:group member', (row: Record) => { + const r = ActionGroupPropsSchema.safeParse({ actions: [{ name: 'run', type: 'script', target: 'run', ...row }] }); + return r.success ? { success: true as const, data: (r.data as { actions: Record[] }).actions[0] } : r; + }], +] as const; + +describe('the page action rows\' copy keys are template slots', () => { + for (const [surface, parse] of ROWS) { + for (const key of KEYS) { + it(`${surface} ${key}: a bare string is the envelope, the envelope is taken, a blank string stays`, () => { + const ok = (value: unknown) => { + const r = parse({ [key]: value }); + if (!r.success) throw new Error(r.error.issues.map((i) => i.message).join('\n')); + return (r.data as Record)[key]; + }; + expect(ok('Delete {{ record.name }}?')).toEqual({ dialect: 'template', source: 'Delete {{ record.name }}?' }); + expect(ok({ dialect: 'template', source: 'Done' })).toEqual({ dialect: 'template', source: 'Done' }); + // A `${…}` sentence parsed while the key was a string, and still does: + // the doors name it with a warning. + expect(ok('Delete ${record.name}?')).toEqual({ dialect: 'template', source: 'Delete ${record.name}?' }); + expect(ok('')).toBe(''); + expect(ok(' ')).toBe(' '); + }); + + it(`${surface} ${key}: a foreign dialect and a non-string are refused with the key's own sentence`, () => { + for (const value of [{ dialect: 'cel', source: 'record.name' }, 42, { en: 'Done' }]) { + const r = parse({ [key]: value }); + expect(r.success, JSON.stringify(value)).toBe(false); + if (r.success) continue; + const messages = r.error.issues.map((i) => i.message).join('\n'); + expect(messages).toContain(`An action's \`${key}\` is a template-typed slot`); + } + }); + } + } +}); + +describe('ActionSchema confirmText / successMessage are template slots that keep the locale map', () => { + const base = { name: 'invite', label: 'Invite', type: 'api', target: '/api/v1/invite' } as const; + const parsed = (patch: Record) => { + const r = ActionSchema.safeParse({ ...base, ...patch }); + if (!r.success) throw new Error(r.error.issues.map((i) => i.message).join('\n')); + return r.data as Record; + }; + + for (const key of ['confirmText', 'successMessage'] as const) { + it(`${key}: string → envelope, envelope taken, locale map and blank string unchanged`, () => { + expect(parsed({ [key]: 'Sent to {{ result.email }}' })[key]).toEqual({ dialect: 'template', source: 'Sent to {{ result.email }}' }); + expect(parsed({ [key]: { dialect: 'template', source: 'Sent' } })[key]).toEqual({ dialect: 'template', source: 'Sent' }); + expect(parsed({ [key]: { en: 'Sent', 'zh-CN': '已发送' } })[key]).toEqual({ en: 'Sent', 'zh-CN': '已发送' }); + expect(parsed({ [key]: '' })[key]).toBe(''); + }); + + it(`${key}: a foreign dialect is refused, naming the locale map among what it takes`, () => { + const r = ActionSchema.safeParse({ ...base, [key]: { dialect: 'cel', source: 'x' } }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.map((i) => i.message).join('\n')).toContain('or an inline locale map'); + }); + } + + it('errorMessage is NOT in this family on ActionSchema: it stays an I18nLabel, a string parsed as itself', () => { + expect(parsed({ errorMessage: 'Failed' }).errorMessage).toBe('Failed'); + }); +}); + +describe('the roots are what the runner binds', () => { + it('confirmText binds `record`, and `params` only beside static values', () => { + expect(ACTION_COPY_RECORD_ROOT).toBe('record'); + expect(ACTION_CONFIRM_PARAMS_ROOT).toBe('params'); + expect(actionConfirmTemplateRoots({})).toEqual(['record']); + expect(actionConfirmTemplateRoots({ params: [{ name: 'tier', type: 'text' }] })).toEqual(['record']); + expect(actionConfirmTemplateRoots({ params: { tier: 'gold' } })).toEqual(['record', 'params']); + expect(actionConfirmTemplateRoots({ params: null })).toEqual(['record']); + }); + + it('successMessage binds `result` then `record`; errorMessage binds nothing', () => { + expect(ACTION_SUCCESS_RESULT_ROOT).toBe('result'); + expect([...ACTION_SUCCESS_TEMPLATE_ROOTS]).toEqual(['result', 'record']); + expect([...ACTION_ERROR_TEMPLATE_ROOTS]).toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/action-copy-template.ts b/packages/spec/src/ui/action-copy-template.ts new file mode 100644 index 00000000000..03b1515c87f --- /dev/null +++ b/packages/spec/src/ui/action-copy-template.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import type { TypedExpressionRefusals } from '../shared/typed-expression-input'; +import { InlineLocaleMapSchema } from './i18n.zod'; + +/** + * @module ui/action-copy-template + * + * **An action's person-facing copy is a `template` slot** (#24814, ADR-0032 + * Decisions 2–3, ruling A): `confirmText`, `successMessage` and `errorMessage` + * on the page action rows (`action:button`, `action:icon`, an `action:group` / + * `action:menu` member — `ui/component.zod.ts`), and `confirmText` / + * `successMessage` on `ActionSchema` (`ui/action.zod.ts`). The rows and the + * registered action forward the same three keys to the one runner the console + * dispatches through, so they are one family with one dialect. + * + * ## The roots are what the runner binds + * + * Each key's holes read a CLOSED scope, measured at the console's action + * runner (objectui `ActionRunner`, the pinned `.objectui-sha`), not invented + * here: + * + * - `confirmText` — `record`, the record the action runs against (the clicked + * row on a list, else the page's record), and `params`, the action's + * STATIC parameter values when it has them: an object `params` on a page + * row. The values a param dialog collects do not exist yet when the + * confirmation shows, so an input list binds nothing + * ({@link actionConfirmTemplateRoots}); + * - `successMessage` — `result`, the payload the handler answered, and + * `record` ({@link ACTION_SUCCESS_TEMPLATE_ROOTS}); + * - `errorMessage` — nothing: the runner shows the text as written + * ({@link ACTION_ERROR_TEMPLATE_ROOTS}), so a hole there is literal text. + * + * The doors (`objectstack validate`, `objectstack compile`, the publish gate) + * judge every sentence through the one template judge's closed-scope entry, + * `closedScopeTemplateRefusal` (`automation/flow-text-slot-template.ts`), and + * then compile it as a template. ⛔ Never a second reading of the dialect. + * + * ## Widening only: the blank arm, and warnings at the doors + * + * Each key was a plain string (`ActionSchema`'s an `I18nLabel`), so a blank + * sentence and a sentence carrying a `${…}` or `{x}` token parsed. Both still + * parse: the slot keeps a blank-string arm, and the doors' findings are + * WARNINGS — an advisory at the publish gate, never a refusal. Refusing them + * is a narrowing no ADR-0087 conversion carries, so it is its own card. + * + * ## The `${…}` spelling + * + * Until these slots were typed, the console's runner filled `${…}` in them + * (`${record.name}` through its expression evaluator for `confirmText`, + * `${result.email}` for `successMessage`), and the platform's own sentences + * were written that way. The runner still fills `${…}` in a sentence that + * carries no `{{ }}` hole, for the transition; a sentence with a `{{ }}` hole + * is filled by the template engine alone. The judge reads a `${…}` token with + * its `$` and prescribes the `{{ }}` hole in its place. + */ + +/** The root a `confirmText` / `successMessage` hole names for the record the action runs against. */ +export const ACTION_COPY_RECORD_ROOT = 'record'; + +/** + * The root a `confirmText` hole names for the action's static parameter + * values — bound only when the action HAS them ({@link actionConfirmTemplateRoots}). + */ +export const ACTION_CONFIRM_PARAMS_ROOT = 'params'; + +/** The root a `successMessage` hole names for the payload the action's handler answered. */ +export const ACTION_SUCCESS_RESULT_ROOT = 'result'; + +/** + * The roots an action's `confirmText` holes may name — its CLOSED scope: + * {@link ACTION_COPY_RECORD_ROOT}, then {@link ACTION_CONFIRM_PARAMS_ROOT} + * when `action.params` is an object of static values. The runner binds + * `params` to that object only; an array `params` is the list of inputs a + * dialog collects AFTER the confirmation, so it binds nothing, and a + * registered action's `params` is always that list. + */ +export function actionConfirmTemplateRoots(action: { readonly params?: unknown }): string[] { + const params = action.params; + const staticValues = params !== null && typeof params === 'object' && !Array.isArray(params); + return staticValues ? [ACTION_COPY_RECORD_ROOT, ACTION_CONFIRM_PARAMS_ROOT] : [ACTION_COPY_RECORD_ROOT]; +} + +/** The roots an action's `successMessage` holes may name: the handler's answer, then the record. */ +export const ACTION_SUCCESS_TEMPLATE_ROOTS: readonly string[] = Object.freeze([ + ACTION_SUCCESS_RESULT_ROOT, + ACTION_COPY_RECORD_ROOT, +]); + +/** + * The roots an action's `errorMessage` holes may name: none. The runner shows + * an authored `errorMessage` as written, so no hole is filled there. + */ +export const ACTION_ERROR_TEMPLATE_ROOTS: readonly string[] = Object.freeze([]); + +/** The three copy keys, as the doors name them. */ +export type ActionCopyKey = 'confirmText' | 'successMessage' | 'errorMessage'; + +/** The sentence an example in a refusal shows for each key. */ +const EXAMPLE: Record = { + confirmText: 'Delete {{ record.name }}?', + successMessage: 'Invitation sent to {{ result.email }}', + errorMessage: 'The invitation could not be sent.', +}; + +/** The refusal of a value that is neither a template string nor a template envelope (nor, where taken, a locale map). */ +function dialectOnly(key: ActionCopyKey, localeMap: boolean): string { + return `An action's \`${key}\` is a template-typed slot: it accepts a bare template string, an envelope ` + + `declaring \`dialect: 'template'\`${localeMap ? ', or an inline locale map ({ en, "zh-CN" })' : ''} only. ` + + `Write \`'${EXAMPLE[key]}'\` or \`{ dialect: 'template', source: '${EXAMPLE[key]}' }\`.`; +} + +/** + * The two sentences one copy key's template input refuses with + * (`templateExpressionInput`'s `refusals`): both prescribe the key's own + * sentence, so a refusal names the slot the author wrote. + */ +export function actionCopyRefusals(key: ActionCopyKey, options: { localeMap?: boolean } = {}): TypedExpressionRefusals { + const refusal = dialectOnly(key, options.localeMap === true); + return { sourceRequired: refusal, dialectOnly: refusal }; +} + +/** + * The slot one copy key declares, around its `template` input: ADR-0032's + * `template` dialect, the input `TemplateExpressionInputSchema` takes — a bare + * string is shorthand for `{ dialect: 'template', source }`, an envelope must + * declare `dialect: 'template'` — then a blank-string arm, so the parse does + * not narrow (the module note); then, on `ActionSchema`, the inline locale map + * its `I18nLabel` took. + * + * The `template` input itself is built at each declaring `.zod.ts` file + * (`templateExpressionInput(ExpressionSchema, actionCopyRefusals(key))`), not + * here: the expression-surface census (the conformance ledger's scan) reads + * `.zod.ts` files, and a slot whose input is constructed in a plain module is + * a declared expression surface that census never sees. + */ +export function actionCopyTemplateSlot( + template: T, + key: ActionCopyKey, + options: { localeMap?: boolean } = {}, +) { + const { dialectOnly: refusal } = actionCopyRefusals(key, options); + // `trim()`'s whitespace set is exactly `\s` (ECMA-262), so this arm takes the blank strings + // `NON_BLANK_STRING` refuses — as a `pattern` the JSON Schema carries, not a dropped refinement. + const blank = z.string().regex(/^\s*$/); + return options.localeMap === true + ? z.union([template, blank, InlineLocaleMapSchema], { error: () => refusal }) + : z.union([template, blank], { error: () => refusal }); +} + +/** What every copy key's describe says about the shared grammar and the doors. */ +const GRAMMAR = + 'Formatters come from the template engine\'s whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole ' + + 'holds no logic (no CEL, no arithmetic).'; + +const DOORS = + 'at `objectstack validate`, `compile` and the publish gate, as warnings: the text still parses and saves.'; + +/** `confirmText`'s describe: its roots, by where the action's static values live. */ +export const ACTION_CONFIRM_TEXT_TEMPLATE_DESCRIBE = + "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. ${GRAMMAR} 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 ${DOORS}`; + +/** `successMessage`'s describe. */ +export const ACTION_SUCCESS_MESSAGE_TEMPLATE_DESCRIBE = + "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. ${GRAMMAR} 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 ${DOORS}`; + +/** `errorMessage`'s describe: a slot that fills no hole. */ +export const ACTION_ERROR_MESSAGE_TEMPLATE_DESCRIBE = + "a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, " + + 'and the text is shown as written, so a `{{ }}` hole, a `${…}` or a `{x}` token in it is literal text. Each of ' + + `those, and a blank sentence, is named ${DOORS}`; diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts index f6de74dd90f..66c4124b38b 100644 --- a/packages/spec/src/ui/action.test.ts +++ b/packages/spec/src/ui/action.test.ts @@ -1208,7 +1208,8 @@ describe('ActionSchema - variant', () => { icon: 'trash', }); expect(result.variant).toBe('danger'); - expect(result.confirmText).toBe('Are you sure?'); + // [#24814] `confirmText` is a `template` slot: the bare string parses to its envelope. + expect(result.confirmText).toEqual({ dialect: 'template', source: 'Are you sure?' }); }); }); diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 1eecae05f2c..16d72892f31 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -21,9 +21,16 @@ import { isActionParamValuePresent } from './action-params.zod'; // reaches only `shared/` + `data/`, so it cannot close a cycle back to `ui/`. import { BulkActionExecutionSchema } from './bulk-action.zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; -import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; +import { EvaluatedExpressionInputSchema, ExpressionSchema } from '../shared/expression.zod'; import { evaluatedExpressionUnionRefusal } from '../shared/evaluated-slot-union'; import { I18nLabelSchema } from './i18n.zod'; +import { + ACTION_CONFIRM_TEXT_TEMPLATE_DESCRIBE, + ACTION_SUCCESS_MESSAGE_TEMPLATE_DESCRIBE, + actionCopyRefusals, + actionCopyTemplateSlot, +} from './action-copy-template'; +import { templateExpressionInput } from '../shared/typed-expression-input'; import { HookBodySchema } from '../data/hook-body.zod'; // Imported file-directly (not via the kernel barrel): the module is // deliberately import-free, so this cannot introduce a cycle. @@ -863,6 +870,16 @@ const ActionConditionInputSchema = z.union([z.boolean(), EvaluatedExpressionInpu error: (issue) => evaluatedExpressionUnionRefusal(issue.input), }); +/** + * [#24814, ADR-0032 Decisions 2–3] A registered action's `confirmText` / + * `successMessage` — a `template` slot over the roots the console's runner + * binds, one family with the page action rows' copy keys + * (`./action-copy-template.ts`), which also keeps the inline locale map + * `I18nLabelSchema` took. The `template` input is built HERE, at the declaring + * file, so the expression-surface census sees both positions. + */ +const actionCopy = (key: 'confirmText' | 'successMessage') => actionCopyTemplateSlot(templateExpressionInput(ExpressionSchema, actionCopyRefusals(key, { localeMap: true })), key, { localeMap: true }); + /** * The object half of {@link ActionSchema}, before its refinements. * @@ -1341,14 +1358,21 @@ const actionObject = () => strictObject({ // tables derive from this one field factory), and the #7428 refusal lives on // `ActionSchema`'s refine chain alone — hence "a registered action" rather // than an unqualified claim that would be false on the inline surface. - confirmText: I18nLabelSchema.optional().describe('Confirmation message before execution. 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.'), - // `${result.*}` is the scope `onSuccess.navigate` declares (see that key's - // docblock for its members) — the copy reuses it rather than growing a - // second interpolation dialect (#21095). The sentence is phrased "on a - // registered action" for the same reason `confirmText`'s is: this describe() - // also renders into the InlineAction table, which does not pick - // `outcomeMessages`. - successMessage: I18nLabelSchema.optional().describe("Success message shown after the action succeeds. On a `type: 'api'` or `type: 'script'` action it may interpolate ${result.*} — the server response payload, the same scope `onSuccess.navigate` declares (e.g. ${result.id}). On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback."), + // + // [#24814, ADR-0032 Decisions 2–3] `confirmText` and `successMessage` are + // `template` slots, one family with the page action rows' copy keys + // (`./action-copy-template.ts`): the runner reads the same key from both. + // The inline locale map `I18nLabelSchema` took stays an arm, so the parse + // does not narrow; each of its values is a sentence the doors judge alike. + confirmText: actionCopy('confirmText').optional().describe(`Confirmation message before execution — ${ACTION_CONFIRM_TEXT_TEMPLATE_DESCRIBE} 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.`), + // `result` is the payload `onSuccess.navigate` reads as `${result.*}` (see + // that key's docblock for its members) — the copy reads the same payload, + // as a `{{ result.* }}` hole since #24814 (the `${result.*}` spelling it + // took before is the one the judge now prescribes a hole for). The sentence + // is phrased "on a registered action" for the same reason `confirmText`'s + // is: this describe() also renders into the InlineAction table, which does + // not pick `outcomeMessages`. + successMessage: actionCopy('successMessage').optional().describe(`Success message shown after the action succeeds — ${ACTION_SUCCESS_MESSAGE_TEMPLATE_DESCRIBE} 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.`), /** * Outcome-specific success copy — ruling A on objectstack-ai/cloud#2315, diff --git a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts index a9ecaf0c7ac..c8a631cab88 100644 --- a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts +++ b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts @@ -269,6 +269,12 @@ on(['object-master-detail-form'], ['details[].columns[].readonlyWhen.ast', 'deta // carries the same engine AST beside `source`. on(['object-grid', 'list-view'], ['conditionalFormatting[].condition.ast', 'bulkActionDefs[].visible.ast', 'bulkActionDefs[].confirmText.ast'], EXPRESSION_AST); on(['object-kanban'], ['conditionalFormatting[].condition.ast'], EXPRESSION_AST); +// [#24814] An action row's `confirmText` / `successMessage` / `errorMessage` +// is a `template` slot too, so each envelope carries the engine AST beside +// `source` — on the two button rows and on each container member. +const ACTION_COPY_AST = ['confirmText.ast', 'successMessage.ast', 'errorMessage.ast']; +on(['action:button', 'action:icon'], ACTION_COPY_AST, EXPRESSION_AST); +on(['action:group', 'action:menu'], ACTION_COPY_AST.map((path) => `actions[].${path}`), EXPRESSION_AST); // The S-forms stage: an inline form field (`customFields[]`, and a section's // inline entry) and the form view's `{ field }` entry carry the three `*When` // predicates, an option's `visibleWhen` and a grid column's two rules; a diff --git a/packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts b/packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts index 0b8d2024ead..7d5544d04fe 100644 --- a/packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts +++ b/packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts @@ -210,11 +210,17 @@ describe('§1 each member accepts every shape a measured writer authors', () => }], // objectui `types/src/__tests__/held-public-block-arms-10872.test.ts:188` / `:189`. ['a member acting on another object', 'action:menu', { actions: [{ name: 'log', objectName: 'task' }] }], + // [#24814] The three copy keys are `template` slots, so the parsed member + // carries the envelope: written as the envelope here, it parses + // byte-identical (the bare-string form is pinned to normalize below). ['a member with every forwarded scalar', 'action:menu', { actions: [{ name: 'close_case', label: 'Close', icon: 'check', type: 'api', description: 'Close this case', - target: '/api/close', openIn: 'self', method: 'POST', confirmText: 'Close it?', successMessage: 'Closed', - errorMessage: 'Could not close', refreshAfter: true, locations: ['record_header'], objectName: 'case', + target: '/api/close', openIn: 'self', method: 'POST', + confirmText: { dialect: 'template', source: 'Close it?' }, + successMessage: { dialect: 'template', source: 'Closed' }, + errorMessage: { dialect: 'template', source: 'Could not close' }, + refreshAfter: true, locations: ['record_header'], objectName: 'case', params: [{ name: 'reason', label: 'Reason', type: 'text' }], }], }], @@ -230,6 +236,17 @@ describe('§1 each member accepts every shape a measured writer authors', () => }); } + it('a member\'s bare-string copy normalizes to the `template` envelope, as on the rows (#24814)', () => { + const r = parse('action:menu', { actions: [{ name: 'a', confirmText: 'Close it?', successMessage: 'Closed', errorMessage: 'Could not close' }] }); + expect(issues(r)).toEqual([]); + const member = r.success ? (r.data as { actions: Array> }).actions[0]! : {}; + expect([member.confirmText, member.successMessage, member.errorMessage]).toEqual([ + { dialect: 'template', source: 'Close it?' }, + { dialect: 'template', source: 'Closed' }, + { dialect: 'template', source: 'Could not close' }, + ]); + }); + it('a member\'s bare CEL `visible` normalizes to the canonical envelope, as on the rows', () => { const r = parse('action:group', { actions: [{ name: 'a', visible: "record.status == 'open'" }] }); expect(issues(r)).toEqual([]); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 7ddfd71998d..e64cd0cdcdc 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -72,6 +72,14 @@ import { ACTION_TARGET_ALIASES } from './action-target-aliases'; import { ON_SUCCESS_EXECUTORS, onSuccessBesideExecutorRefusal } from './action-on-success-executor'; import { refuseDeclarativeUpdateContradictions } from './action-declarative-update'; import { navigationTargetRefusal } from './navigation-target'; +import { + ACTION_CONFIRM_TEXT_TEMPLATE_DESCRIBE, + ACTION_ERROR_MESSAGE_TEMPLATE_DESCRIBE, + ACTION_SUCCESS_MESSAGE_TEMPLATE_DESCRIBE, + actionCopyRefusals, + actionCopyTemplateSlot, + type ActionCopyKey, +} from './action-copy-template'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; // [#21464] `object-metric.aggregate.groupBy` is the chart aggregate's own // `groupBy` union, by reference — the tile routes it exactly as the chart does. @@ -88,7 +96,8 @@ import { DashboardWidgetSchema } from './dashboard.zod'; import { ReportSchema } from './report.zod'; import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; import { lazySchema } from '../shared/lazy-schema'; -import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; +import { EvaluatedExpressionInputSchema, ExpressionSchema } from '../shared/expression.zod'; +import { templateExpressionInput } from '../shared/typed-expression-input'; import { evaluatedExpressionUnionRefusal } from '../shared/evaluated-slot-union'; import { enumWithRetiredValues, retiredKey } from '../shared/retired-key'; // The retired page-component TYPES' prescriptions — one string per type, three @@ -3541,6 +3550,24 @@ const elementListHistory = (type: string) => `Until \`${type}\` had a ComponentPropsMap row, the whole node was refused as an unknown ` + '`element:` type although objectui renders it, so no key inside `properties` was ever judged.'; +/** + * [#24814, ADR-0032 Decisions 2–3] A page action row's copy key — + * `confirmText`, `successMessage`, `errorMessage` on `action:button`, + * `action:icon` and an `action:group` / `action:menu` member — as a `template` + * slot over the roots the console's runner binds (`./action-copy-template.ts` + * holds the roots, the refusals and the blank arm). The `template` input is + * built HERE, at the declaring file, so the expression-surface census sees each + * row's position. A factory, so each row's key carries its own instance. + */ +const actionRowCopy = (key: ActionCopyKey) => actionCopyTemplateSlot(templateExpressionInput(ExpressionSchema, actionCopyRefusals(key)), key); + +/** The describe of each row copy key — the row's wording, then the slot's grammar and roots. */ +const ACTION_ROW_COPY_DESCRIBE: Record = { + confirmText: `Confirmation question asked before the action runs — ${ACTION_CONFIRM_TEXT_TEMPLATE_DESCRIBE}`, + successMessage: `Toast shown when the action succeeds — ${ACTION_SUCCESS_MESSAGE_TEMPLATE_DESCRIBE}`, + errorMessage: `Toast shown when the action fails, in place of the raw error — ${ACTION_ERROR_MESSAGE_TEMPLATE_DESCRIBE}`, +}; + /** * The action-level condition shape the renderers evaluate — `visible` on all * four, `disabled` on `action:button` / `action:icon`: a boolean literal, a @@ -4044,9 +4071,9 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), operation: actionOperation().optional().describe('Declarative single-record write: `ActionSchema`\'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row\'s values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row\'s `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape`, `recordIdField` and `onSuccess` are refused, and `list_toolbar` is not a location'), patch: rowPatchRetired("An `action:button`'s"), - confirmText: z.string().optional().describe('Confirmation question asked before the action runs'), - successMessage: z.string().optional().describe('Toast shown when the action succeeds'), - errorMessage: z.string().optional().describe('Toast shown when the action fails, in place of the raw error'), + confirmText: actionRowCopy('confirmText').optional().describe(ACTION_ROW_COPY_DESCRIBE.confirmText), + successMessage: actionRowCopy('successMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.successMessage), + errorMessage: actionRowCopy('errorMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.errorMessage), refreshAfter: z.boolean().optional().describe('Refresh the surrounding data after the action runs'), undoable: z.boolean().optional().describe('Offer an Undo affordance after an update action'), recordIdField: z.string().optional().describe('Row field whose value identifies the record the action acts on'), @@ -4144,9 +4171,9 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), operation: actionOperation().optional().describe('Declarative single-record write: `ActionSchema`\'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row\'s values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row\'s `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location'), patch: rowPatchRetired("An `action:icon`'s"), - confirmText: z.string().optional().describe('Confirmation question asked before the action runs'), - successMessage: z.string().optional().describe('Toast shown when the action succeeds'), - errorMessage: z.string().optional().describe('Toast shown when the action fails, in place of the raw error'), + confirmText: actionRowCopy('confirmText').optional().describe(ACTION_ROW_COPY_DESCRIBE.confirmText), + successMessage: actionRowCopy('successMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.successMessage), + errorMessage: actionRowCopy('errorMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.errorMessage), refreshAfter: z.boolean().optional().describe('Refresh the surrounding data after the action runs'), locations: z.array(ActionLocationSchema).optional() .describe('Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one'), @@ -4294,9 +4321,9 @@ function actionContainerMemberShape() { bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), operation: actionOperation().optional().describe('Declarative single-record write: `ActionSchema`\'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row\'s values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row\'s `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location'), patch: rowPatchRetired('An `action:group` or `action:menu` member\'s'), - confirmText: z.string().optional().describe('Confirmation question asked before the action runs'), - successMessage: z.string().optional().describe('Toast shown when the action succeeds'), - errorMessage: z.string().optional().describe('Toast shown when the action fails, in place of the raw error'), + confirmText: actionRowCopy('confirmText').optional().describe(ACTION_ROW_COPY_DESCRIBE.confirmText), + successMessage: actionRowCopy('successMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.successMessage), + errorMessage: actionRowCopy('errorMessage').optional().describe(ACTION_ROW_COPY_DESCRIBE.errorMessage), refreshAfter: z.boolean().optional().describe('Refresh the surrounding data after the action runs'), locations: z.array(ActionLocationSchema).optional() .describe('Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one'), diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index 2767063acdd..6a57f625c41 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -49,6 +49,18 @@ export { datasetForm } from './dataset.form'; export { actionForm } from './action.form'; export { pageForm } from './page.form'; export * from './action.zod'; +// [#24814] The CLOSED scope of an action's copy — `confirmText`, +// `successMessage`, `errorMessage` — as the console's runner binds it: the +// roots the doors judge each sentence against. The slot factories stay +// package-internal. +export { + ACTION_COPY_RECORD_ROOT, + ACTION_CONFIRM_PARAMS_ROOT, + ACTION_SUCCESS_RESULT_ROOT, + ACTION_SUCCESS_TEMPLATE_ROOTS, + ACTION_ERROR_TEMPLATE_ROOTS, + actionConfirmTemplateRoots, +} from './action-copy-template'; // Action-param value validation (ADR-0104 D2) export * from './action-params.zod'; export * from './page.zod';