diff --git a/.changeset/25680-action-copy-refusal.md b/.changeset/25680-action-copy-refusal.md index 97013e5d715..faaa8223a4b 100644 --- a/.changeset/25680-action-copy-refusal.md +++ b/.changeset/25680-action-copy-refusal.md @@ -9,7 +9,7 @@ Clause-②: no (narrowing: an action's copy sentence that is blank, holes an unb Metadata-protocol: narrowed Ruling-ref: `6365736632` (on #24814, the refusal half); triage `6386348015` -An action's person-facing copy is an ADR-0032 `template` slot over the roots the console's runner binds (#24814): `confirmText` over `record` (and `params` beside static values), `successMessage` over `result` and `record`, `errorMessage` over none. That covers a page action row (`action:button`, `action:icon`, an `action:group` / `action:menu` member), a page component's inline `properties.action`, and a registered action (`stack.actions[]`, an object's `actions[]`). `validateStackExpressions`, the rule all three doors run, judges each sentence through `closedScopeTemplateRefusal`, then compiles it as a template. Its findings were warnings. They are now errors, all but one. This supersedes the warning-only behaviour #24814 described earlier in this release line; a refused finding no longer ends with "A warning, not a refusal". +An action's person-facing copy is an ADR-0032 `template` slot over the roots the console's runner binds (#24814): `confirmText` over `record` (and `params` beside static values), `successMessage` over `result` and `record`, `errorMessage` over none. That covers a page action row (`action:button`, `action:icon`, an `action:group` / `action:menu` member), a page component's inline `properties.action`, and a registered action (`stack.actions[]`, an object's `actions[]`). `validateStackExpressions`, the rule all three doors run, judges each sentence through `closedScopeTemplateRefusal`, then compiles it as a template. Its findings were warnings. They are now errors. This supersedes the warning-only behaviour #24814 described earlier in this release line; a refused finding no longer ends with "A warning, not a refusal". **BREAKING — what moves for consumers.** @@ -18,7 +18,7 @@ An action's person-facing copy is an ADR-0032 `template` slot over the roots the **Fix (one line).** Write the hole the finding prescribes (`Delete ${record.name}?` → `Delete {{ record.name }}?`), name a root the key binds, double a single-brace token, move logic out of the hole, or remove a blank key so the runner's default shows. -**Still a warning.** A `${…}` token the console's runner still fills in its transitional reading: in a sentence with no `{{ }}` hole and no other token, `${record.*}` in a `confirmText` and `${result.*}` in a `successMessage` (`ACTION_COPY_DOLLAR_TRANSITION_ROOTS`, measured at objectui `ActionRunner` at the pinned `.objectui-sha`, which fills them there). It is named with its `{{ }}` spelling and ends "A warning, not a refusal, for now". Refusing it waits for the runner to retire that reading. +**The holeless `${…}` sentence too.** `${record.*}` in a `confirmText` and `${result.*}` in a `successMessage`, in a sentence with no `{{ }}` hole and no other token, is refused as well: the console's runner shows it as written. Its own entry in this release says so (#25709). **Unchanged.** @@ -26,7 +26,7 @@ An action's person-facing copy is an ADR-0032 `template` slot over the roots the - `ActionSchema.errorMessage` and `outcomeMessages` are not in this family and draw nothing. - A sentence with no finding draws nothing at any door, in any form (the bare string, the `{ dialect: 'template', source }` envelope, an inline locale map). - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. -- No schema shape moves. `@objectstack/spec` adds two exports: `ACTION_COPY_DOLLAR_TRANSITION_ROOTS` (`@objectstack/spec/ui`) and `dollarOnlyTokenRoots` (`@objectstack/spec/automation`). The three copy describes now say the texts are refused. +- No schema shape moves, and `@objectstack/spec` adds no export. The three copy describes now say the texts are refused. - Measured before crossing, through the built `@objectstack/lint` at `6021b5e837`: 0 in-repo producers carry such a sentence. `examples/app-crm`, `app-showcase` (23 registered `successMessage`s, 29 pages), `app-todo` (2 `confirmText`, 16 `successMessage`) and `app-multi-package`, `@objectstack/platform-objects` (12 `confirmText`, 48 `successMessage`) and `@objectstack/plugin-approvals` (9 `successMessage`) draw 0 findings, against 1 for a planted unbound hole. HotCRM at `a4b3560` declares 1 action `confirmText` and 19 action `successMessage`s; judged through the same built pass, all 20 draw 0 findings, against an error for a planted `{count}`. diff --git a/.changeset/25709-action-copy-holeless-dollar-refusal.md b/.changeset/25709-action-copy-holeless-dollar-refusal.md new file mode 100644 index 00000000000..6a52d2c57c8 --- /dev/null +++ b/.changeset/25709-action-copy-holeless-dollar-refusal.md @@ -0,0 +1,24 @@ +--- +'@objectstack/lint': minor +'@objectstack/spec': minor +--- + +feat(lint)!: `objectstack validate`, `compile` and the publish gate refuse a holeless `${record.*}` `confirmText` and a holeless `${result.*}` `successMessage` on an action, the one action-copy finding that was still a warning (#25709) + +Clause-②: no (narrowing: an action's `confirmText` / `successMessage` sentence with no `{{ }}` hole whose tokens are `${record.*}` / `${result.*}` is refused at `objectstack validate`, `compile` and the publish gate instead of saving with a warning) +Metadata-protocol: narrowed + +The console's action runner no longer fills `${…}` in an action's copy: a sentence with no `{{ }}` hole is shown as written, `${…}` included (objectui#13476, in the `ui/` tree). The doors kept one `${…}` shape a warning while the runner still filled it: `Delete ${record.name}?` in a `confirmText`, `Invitation sent to ${result.email}` in a `successMessage`. The person reading the dialog or toast now sees that token raw, so the doors refuse it like every other `${…}` token. + +**BREAKING — what moves for consumers.** + +- FROM: a warning at `objectstack validate` and `compile`, and an entry on the publish gate's `advisories` channel while the action, object or page saved. TO: an error. Both commands fail, and an active `action`, `object` or `page` write answers `422 INVALID_METADATA` with an `expression-invalid` issue located at the key. The item is not stored. The finding no longer ends with "A warning, not a refusal, for now". +- `ACTION_COPY_DOLLAR_TRANSITION_ROOTS` (`@objectstack/spec/ui`) and `dollarOnlyTokenRoots` (`@objectstack/spec/automation`), which reached `main` after `@objectstack/spec@18.1.0` and were never published, are withdrawn before this release. The `confirmText` and `successMessage` describes no longer say the shape is a warning. + +**Fix (one line).** Write the hole the finding prescribes: `Delete ${record.name}?` → `Delete {{ record.name }}?`, `Invitation sent to ${result.email}` → `Invitation sent to {{ result.email }}`. + +**Unchanged.** The parse: each copy key accepts what it accepted, so a stored row with such a sentence keeps loading until its next save, and that save is judged. Saving as a draft is still allowed. `outcomeMessages` and `ActionSchema.errorMessage` are not in this family. `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + +**Measured before crossing**, by walking every action-copy sentence reachable from each module's exports on this branch's base `38050ced` and through the built `@objectstack/lint`: `examples/app-crm` (2 `successMessage`, 2 `errorMessage`), `app-todo` (1 `confirmText`, 8 `successMessage`), `app-showcase` (6 `confirmText`, 24 `successMessage`), `app-multi-package` (none), `@objectstack/platform-objects` and `@objectstack/plugin-approvals` carry 0 sentences with a `${…}` token and 0 of the holeless shape, and the four example stacks draw 0 copy findings. A planted `Delete ${record.name}?` `confirmText` on each example stack draws the finding. + + diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 7302d4f9277..c98d0986658 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 \| { 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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. 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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. 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. | +| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | | **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 a1885168117..b2ec876114d 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 \| { 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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. 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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. 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. | +| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | | **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 c47e8f8e0d8..f14a2f2abf9 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 \| { 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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. 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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. 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. | +| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | | **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 \| { 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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. 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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. 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. | +| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | | **errorMessage** | `string \| Record` | 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 965cec08d24..24e217b862e 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -45,8 +45,8 @@ 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 \| { 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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | -| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | +| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | +| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | | **undoable** | `boolean` | optional | Offer an Undo affordance after an update action | @@ -103,8 +103,8 @@ 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 \| { 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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | -| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | +| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | +| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **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 | @@ -139,8 +139,8 @@ 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 \| { 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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | -| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | +| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | +| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **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 | @@ -193,8 +193,8 @@ 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 \| { 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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | -| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. | +| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | +| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. | | **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 | @@ -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 \| { 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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${record.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. 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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. A `${result.*}` token in a sentence with no `{{ }}` hole, which the console's runner still fills, is named as a warning instead. 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. | +| **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 and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | +| **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | | **errorMessage** | `string \| Record` | 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/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index e42ab3f57cc..c90051c1c63 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -761,11 +761,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // written action's own `confirmText` / `successMessage` in the same pass. // #25680 makes each finding an `error`, so an action, object or page write // carrying one is refused (a narrowing no conversion carries, shipped in - // 18.x as its own BREAKING minor, as #25205 did for the bulk slot). One - // stays a `warning`: a `${…}` token the console's runner still fills, in a - // sentence with no `{{ }}` hole. MEASURED first over the shipped corpus at - // `6021b5e837`: 0 findings across examples/**, platform-objects and - // plugin-approvals, against one for a planted unbound hole. + // 18.x as its own BREAKING minor, as #25205 did for the bulk slot); since + // #25709 that includes a `${…}` token in a sentence with no `{{ }}` hole, + // which the console's runner now shows as written. MEASURED first over the + // shipped corpus at `6021b5e837`: 0 findings across examples/**, + // platform-objects and plugin-approvals, against one for a planted unbound + // hole; #25709 re-measured 0 sentences of the holeless `${…}` shape there. surfaces: CLI_AND_RUNTIME, runtimeTypes: ['flow', 'action', 'hook', 'object', 'view', 'page'], run: (stack, ctx) => diff --git a/packages/lint/src/validate-action-copy-templates.test.ts b/packages/lint/src/validate-action-copy-templates.test.ts index 122f7aadbfc..854af993b18 100644 --- a/packages/lint/src/validate-action-copy-templates.test.ts +++ b/packages/lint/src/validate-action-copy-templates.test.ts @@ -7,10 +7,11 @@ // 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 an -// ERROR (#25680), so the publish gate refuses the write; one stays a WARNING, -// a `${…}` token the console's runner still fills in a sentence with no -// `{{ }}` hole (`ACTION_COPY_DOLLAR_TRANSITION_ROOTS`). So a pin here asserts -// the severity per finding and, at the publish gate, which channel it rides. +// ERROR (#25680), so the publish gate refuses the write — a `${…}` token in a +// sentence with no `{{ }}` hole included (#25709): the console's runner shows +// it as written, so no finding rides the advisory channel any more. So a pin +// here asserts the severity per finding and, at the publish gate, that the +// finding rides the error channel and never the advisory one. // // The gate half drives the REAL dispatch (`runRuntimeAuthoringRules`) with a // page write and an action write, because a `runtimeTypes` widening whose @@ -56,18 +57,20 @@ describe('validateStackExpressions — a registered action\'s copy is judged as } }); - it('names a `${…}` token the runner still fills with a WARNING prescribing its `{{ }}` hole, never `${{ … }}`', () => { + it('refuses a holeless `${…}` sentence — the runner shows it as written — 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!.severity).toBe('error'); 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('${{'); - const [confirm] = copyIssues({ objects, actions: [invite({ confirmText: 'Invite ${record.name}?' })] }); - expect(confirm!.severity).toBe('warning'); + expect(issue!.message).not.toMatch(/A warning, not a refusal/); + const confirms = copyIssues({ objects, actions: [invite({ confirmText: 'Invite ${record.name}?' })] }); + expect(confirms.map((i) => [i.where, i.severity])).toEqual([["stack · action 'invite' confirmText", 'error']]); + expect(confirms[0]!.message).toContain('as `Invite {{ record.name }}?`'); }); - it('refuses a `${…}` token the runner shows raw — off its transitional roots, beside a `{{ }}` hole, or beside a `{x}`', () => { + it('refuses a `${…}` token in every other shape too — off the record/result roots, beside a `{{ }}` hole, or beside a `{x}`', () => { for (const patch of [ // `composeSuccessMessage` substitutes `${result.*}` alone. { successMessage: 'Sent for ${record.name}' }, @@ -167,7 +170,7 @@ describe('validateStackExpressions — a page action row\'s copy (#24814)', () = ]); expect(issues[0]!.message).toContain('as `Run {{ record.name }}?`'); expect(issues[2]!.message).toMatch(/^An action's `confirmText` is blank/); - expect(issues.map((i) => i.severity)).toEqual(['warning', 'error', 'error', 'warning']); + expect(issues.map((i) => i.severity)).toEqual(['error', 'error', 'error', 'error']); }); it('on a page write runs that pass alone', () => { @@ -182,7 +185,7 @@ describe('validateStackExpressions — a page action row\'s copy (#24814)', () = }); }); -describe('the runtime publish gate — a refusal, but for the runner\'s transitional `${…}` (#24814, #25680)', () => { +describe('the runtime publish gate — every finding a refusal, a holeless `${…}` included (#24814, #25680, #25709)', () => { 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); @@ -206,17 +209,20 @@ describe('the runtime publish gate — a refusal, but for the runner\'s transiti expect(findings('object', { ...objects[0], actions: [action] }).errors).toHaveLength(1); }); - it('a page write carrying a transitional `${…}` confirmText answers one advisory and no error', () => { + it('a page write carrying a holeless `${record.*}` confirmText answers one error and no advisory', () => { const { errors, advisories } = findings('page', row({ confirmText: 'Delete ${record.name}?' })); - expect(errors).toEqual([]); - expect(advisories).toHaveLength(1); - expect(advisories[0]!.message).toContain('as `Delete {{ record.name }}?`'); + expect(errors).toHaveLength(1); + expect(errors[0]!.where).toContain('confirmText'); + expect(errors[0]!.message).toContain('as `Delete {{ record.name }}?`'); + expect(advisories).toEqual([]); }); - it('an action write carrying a `${result.*}` successMessage answers one advisory and no error', () => { + it('an action write carrying a holeless `${result.*}` successMessage answers one error and no advisory', () => { const { errors, advisories } = findings('action', invite({ successMessage: 'Invitation sent to ${result.email}' })); - expect(errors).toEqual([]); - expect(advisories).toHaveLength(1); + expect(errors).toHaveLength(1); + expect(errors[0]!.where).toContain('successMessage'); + expect(errors[0]!.message).toContain('as `Invitation sent to {{ result.email }}`'); + expect(advisories).toEqual([]); }); it('control: the converted platform sentence draws nothing', () => { diff --git a/packages/lint/src/validate-action-copy-templates.ts b/packages/lint/src/validate-action-copy-templates.ts index dcede89c7bb..2180e70ec30 100644 --- a/packages/lint/src/validate-action-copy-templates.ts +++ b/packages/lint/src/validate-action-copy-templates.ts @@ -31,17 +31,14 @@ * fail, and the publish gate answers its 422. Each of these texts parsed while * the keys were plain strings, and it still parses (the slot keeps its blank * arm), so a stored row keeps loading until its next save; the save is judged. - * One finding stays a WARNING: a `${…}` token the console's runner still fills - * in its transitional reading — a sentence with no `{{ }}` hole whose every - * token is `${…}` over a root `ACTION_COPY_DOLLAR_TRANSITION_ROOTS` names for - * the key (`dollarOnlyTokenRoots`). Refusing it waits for the runner to retire - * that reading; any other `${…}` token is shown raw, so it is an error. + * A `${…}` token is one of them in every sentence (#25709): the console's + * runner shows it as written, a sentence with no `{{ }}` hole included, so + * there is no transitional reading left to warn about. */ import { validateExpression } from '@objectstack/formula'; -import { closedScopeTemplateRefusal, dollarOnlyTokenRoots, templateSlotSource } from '@objectstack/spec/automation'; +import { closedScopeTemplateRefusal, templateSlotSource } from '@objectstack/spec/automation'; import { - ACTION_COPY_DOLLAR_TRANSITION_ROOTS, ACTION_ERROR_TEMPLATE_ROOTS, ACTION_SUCCESS_TEMPLATE_ROOTS, actionConfirmTemplateRoots, @@ -69,10 +66,6 @@ const CONTAINER_COMPONENTS: ReadonlySet = new Set(['action:group', 'acti /** A blank sentence — `trim()`'s whitespace set is exactly `\s`. */ const BLANK_TEXT = /^\s*$/; -/** What the one finding that stays a warning ends with: why it is advice, for now. */ -const TRANSITIONAL = ' A warning, not a refusal, for now: the console\'s runner still fills a `${…}` token over this ' - + 'root in a sentence with no `{{ }}` hole.'; - /** 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 ' @@ -82,17 +75,6 @@ const BLANK_COST: Record = { + 'sentence, or remove the key.', }; -/** - * Whether `text` is the one shape the runner still reads in its `${…}` - * dialect for `key`: no `{{ }}` hole, and every token a `${…}` over a root it - * fills there. - */ -function isTransitionalDollar(key: CopyKey, text: string): boolean { - const roots = dollarOnlyTokenRoots(text); - const fills = ACTION_COPY_DOLLAR_TRANSITION_ROOTS[key]; - return roots !== undefined && roots.every((root) => fills.includes(root)); -} - /** 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); @@ -141,11 +123,7 @@ function judgeAction( } const refusal = closedScopeTemplateRefusal(text, { slot, roots: rootsOf(key, action) }); if (refusal !== undefined) { - if (isTransitionalDollar(key, text)) { - issues.push({ where: located, message: `${refusal}${TRANSITIONAL}`, source: text, severity: 'warning' }); - } else { - refuse(refusal, text); - } + refuse(refusal, text); continue; } for (const e of validateExpression('template', text).errors) refuse(e.message, e.source); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index bcd65d49e9c..990b134bfa6 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -2009,7 +2009,7 @@ export interface StackExpressionOptions { * collection a view write does not carry. * * [#24814] An action's copy pass (`actionCopyTemplateIssues`, errors since - * #25680 but for the runner's transitional `${…}` reading) judges an object + * #25680, a holeless `${…}` sentence included since #25709) 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. * @@ -3483,8 +3483,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression 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. Errors since #25680, but for the - // runner's transitional `${…}` reading, which stays a warning. + // pages' action rows off an object write. Errors since #25680, a holeless + // `${…}` sentence included since #25709. issues.push(...actionCopyTemplateIssues(stack, { actions: !objectWrite, objects: true, pages: !objectWrite })); return issues; diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index 48dcadfd29e..a99f601fe57 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -4391,14 +4391,16 @@ describe('runtime authoring gate on VIEW writes — a bulk confirmText template // registered action) is an ADR-0032 `template` slot over the roots the // console's runner binds (#24814). #24814 shipped its findings as advisories; // this card refuses them at the runtime save door, as `os build` refuses them, -// all but one: a `${…}` token the runner still fills in a sentence with no -// `{{ }}` hole stays an advisory. Pinned through the REAL `saveMetaItem`: +// and #25709 refuses the last one too: a `${…}` token in a sentence with no +// `{{ }}` hole, which the runner now shows as written. Pinned through the REAL +// `saveMetaItem`: // // (a) an active page save carrying a refused sentence is refused — 422 // `INVALID_METADATA` — naming the slot, and nothing lands; an action // write is refused the same way; the draft promotion is refused too; // (b) the door's finding IS the build's; -// (c) control: the transitional `${…}` sentence saves with one advisory; +// (c) the holeless `${record.*}` sentence is refused the same way, with no +// advisory left for it; // (d) the parse is not narrowed: a stored page carrying a blank or an // unbound sentence keeps loading, and its next save is judged. // @@ -4491,14 +4493,18 @@ describe('runtime authoring gate on PAGE and ACTION writes — an action\'s copy } }); - it('(c) CONTROL — the transitional `${record.*}` sentence saves with one advisory, and the row lands', async () => { + it('(c) REFUSES the holeless `${record.*}` sentence the runner shows as written — 422, prescribing its hole; nothing stored', async () => { const { protocol, rows } = makeProtocol(); - const res = await savePage(protocol, page('Delete ${record.name}?')); + const err = await savePage(protocol, page('Delete ${record.name}?')).catch((e: any) => e); - expect(res).toMatchObject({ success: true }); - expect(copyIssues(res.advisories)).toHaveLength(1); - expect(activePageRows(rows)).toHaveLength(1); + expect(err, 'the save resolved — the door still admits the holeless `${…}` sentence').toBeInstanceOf(Error); + expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issues = copyIssues(err.issues); + expect(issues, `issues: ${JSON.stringify(err.issues)}`).toHaveLength(1); + expect(issues[0]).toMatchObject({ rule: EXPRESSION_INVALID, severity: 'error' }); + expect(issues[0].message).toContain('as `Delete {{ record.name }}?`'); + expect(activePageRows(rows)).toEqual([]); }); for (const [label, text] of [['an unbound hole', UNBOUND], ['a blank sentence', '']] as const) { diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index f692bf36401..801bfa9ee23 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -646,7 +646,7 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ 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 an ERROR (the publish gate answers 422), but for a `${…}` token over a root `ACTION_COPY_DOLLAR_TRANSITION_ROOTS` names in a sentence with no `{{ }}` hole, which the runner still fills and the doors name as 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', + '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 an ERROR (the publish gate answers 422), a `${…}` token in a sentence with no `{{ }}` hole included. 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 in the `ui/` tree (`ui/packages/core/src/actions/ActionRunner.ts`); a sentence the engine refuses is shown as written, an unbound hole prints empty, and a sentence with no `{{ }}` hole is shown as written, a `${…}` token in it included', covers: [ 'ui/action.zod.ts:actionObject.confirmText', 'ui/component.zod.ts:ActionButtonPropsSchema.confirmText', @@ -660,7 +660,7 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ 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', + '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 in the `ui/` tree; `outcomeMessages[outcome]` outranks it, a blank sentence falls to the runner\'s default text, and a sentence with no `{{ }}` hole is shown as written, a `${result.*}` token in it included', covers: [ 'ui/action.zod.ts:actionObject.successMessage', 'ui/component.zod.ts:ActionButtonPropsSchema.successMessage', diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 5be2519530f..cc6d1246070 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -300,7 +300,6 @@ "defineFlow (function)", "defineWebhook (function)", "describeMissingScheduleOrganization (function)", - "dollarOnlyTokenRoots (function)", "exportConstructsToBpmn (function)", "findRegionEntry (function)", "flowFilterOperands (function)", diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 66ce48ecaf5..e895e467e86 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -3,7 +3,6 @@ "entry": "./ui", "exports": [ "ACTION_CONFIRM_PARAMS_ROOT (const)", - "ACTION_COPY_DOLLAR_TRANSITION_ROOTS (const)", "ACTION_COPY_RECORD_ROOT (const)", "ACTION_ERROR_TEMPLATE_ROOTS (const)", "ACTION_LOCATIONS (const)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 4a422123ff3..87c2d1fc5d6 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -294,7 +294,6 @@ "defineFlow": "src/automation/flow.zod.ts#defineFlow (function)", "defineWebhook": "src/automation/webhook.zod.ts#defineWebhook (function)", "describeMissingScheduleOrganization": "src/automation/schedule-organization.zod.ts#describeMissingScheduleOrganization (function)", - "dollarOnlyTokenRoots": "src/automation/flow-text-slot-template.ts#dollarOnlyTokenRoots (function)", "exportConstructsToBpmn": "src/automation/bpmn-mapping.ts#exportConstructsToBpmn (function)", "findRegionEntry": "src/automation/control-flow.zod.ts#findRegionEntry (function)", "flowFilterOperands": "src/automation/flow-node-expression-paths.ts#flowFilterOperands (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index a5c3a276055..9e9b356c3a4 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -3,7 +3,6 @@ "entry": "./ui", "exports": { "ACTION_CONFIRM_PARAMS_ROOT": "src/ui/action-copy-template.ts#ACTION_CONFIRM_PARAMS_ROOT (const)", - "ACTION_COPY_DOLLAR_TRANSITION_ROOTS": "src/ui/action-copy-template.ts#ACTION_COPY_DOLLAR_TRANSITION_ROOTS (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)", 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 866940360ad..79323e8a84b 100644 --- a/packages/spec/src/automation/flow-text-slot-template.test.ts +++ b/packages/spec/src/automation/flow-text-slot-template.test.ts @@ -16,7 +16,6 @@ import { FLOW_NODE_TEXT_SLOTS, TEXT_SLOT_TEMPLATE_REFUSAL, closedScopeTemplateRefusal, - dollarOnlyTokenRoots, flowNodeTextSlotSources, templateSlotSource, textSlotTemplateRefusal, @@ -464,16 +463,3 @@ describe('the node contracts — `By {{ $User.Id }}` is refused at the schema (# } }); }); - -describe('dollarOnlyTokenRoots — the shape the action runner still reads as `${…}` (#25680)', () => { - it('answers the roots when every token is a `${…}` path and no `{{ }}` hole is present', () => { - expect(dollarOnlyTokenRoots('Delete ${record.name}?')).toEqual(['record']); - expect(dollarOnlyTokenRoots('Sent to ${result.email} for ${record.name}')).toEqual(['result', 'record']); - }); - - it('answers undefined for anything else: no token, a `{{ }}` hole, a bare `{x}`, or logic', () => { - for (const text of ['Delete this?', 'Sent to {{ result.email }} at ${result.at}', 'Run ${record.name} as {role}', '${a + b}', '']) { - expect(dollarOnlyTokenRoots(text), text).toBeUndefined(); - } - }); -}); diff --git a/packages/spec/src/automation/flow-text-slot-template.ts b/packages/spec/src/automation/flow-text-slot-template.ts index 830be775a94..e12c4385173 100644 --- a/packages/spec/src/automation/flow-text-slot-template.ts +++ b/packages/spec/src/automation/flow-text-slot-template.ts @@ -693,28 +693,3 @@ export function closedScopeTemplateRefusal(text: string, scope: ClosedTemplateSc } return refusals.length === 0 ? undefined : refusals.join(' '); } - -/** - * The roots of `text`'s `${…}` tokens when they are the only thing - * {@link closedScopeTemplateRefusal} could name in it — no `{{ }}` hole, and - * every single-brace token a `${…}` path token — else `undefined`. - * - * [#25680] That is the one shape the console's action runner still reads in - * its transitional `${…}` dialect (a sentence with no `{{ }}` hole), so a door - * judging an action's copy can tell it apart from text the runner shows raw. - * Which of these roots the runner fills is the slot's fact, not this reader's - * (`ACTION_COPY_DOLLAR_TRANSITION_ROOTS` in `ui/action-copy-template.ts`). - */ -export function dollarOnlyTokenRoots(text: string): string[] | undefined { - if (/\{\{[^}]*\}\}/.test(text)) return undefined; - const tokens = singleBraceTokens(text); - if (tokens.length === 0) return undefined; - const roots: string[] = []; - for (const token of tokens) { - if (token.kind !== 'path' || !isDollarToken(text, token)) return undefined; - const root = pathRoot(token.inner); - if (root === undefined) return undefined; - roots.push(root); - } - return roots; -} diff --git a/packages/spec/src/ui/action-copy-template.test.ts b/packages/spec/src/ui/action-copy-template.test.ts index 7127ca945f4..409db964866 100644 --- a/packages/spec/src/ui/action-copy-template.test.ts +++ b/packages/spec/src/ui/action-copy-template.test.ts @@ -17,7 +17,6 @@ import { describe, expect, it } from 'vitest'; import { ACTION_CONFIRM_PARAMS_ROOT, - ACTION_COPY_DOLLAR_TRANSITION_ROOTS, ACTION_COPY_RECORD_ROOT, ACTION_ERROR_TEMPLATE_ROOTS, ACTION_SUCCESS_RESULT_ROOT, @@ -51,7 +50,7 @@ describe('the page action rows\' copy keys are template slots', () => { 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 judge it (a warning only while the runner still fills it). + // the doors refuse it, the parse keeps it so a stored one loads. expect(ok('Delete ${record.name}?')).toEqual({ dialect: 'template', source: 'Delete ${record.name}?' }); expect(ok('')).toBe(''); expect(ok(' ')).toBe(' '); @@ -114,10 +113,4 @@ describe('the roots are what the runner binds', () => { expect([...ACTION_SUCCESS_TEMPLATE_ROOTS]).toEqual(['result', 'record']); expect([...ACTION_ERROR_TEMPLATE_ROOTS]).toEqual([]); }); - - it('the `${…}` tokens the runner still fills: `${record.*}` in a confirmText, `${result.*}` in a successMessage', () => { - expect([...ACTION_COPY_DOLLAR_TRANSITION_ROOTS.confirmText]).toEqual(['record']); - expect([...ACTION_COPY_DOLLAR_TRANSITION_ROOTS.successMessage]).toEqual(['result']); - expect([...ACTION_COPY_DOLLAR_TRANSITION_ROOTS.errorMessage]).toEqual([]); - }); }); diff --git a/packages/spec/src/ui/action-copy-template.ts b/packages/spec/src/ui/action-copy-template.ts index 86cffc70e22..5cc6538f5c2 100644 --- a/packages/spec/src/ui/action-copy-template.ts +++ b/packages/spec/src/ui/action-copy-template.ts @@ -18,8 +18,7 @@ import { InlineLocaleMapSchema } from './i18n.zod'; * ## 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: + * runner (`ActionRunner` in the `ui/` tree), 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 @@ -46,44 +45,24 @@ import { InlineLocaleMapSchema } from './i18n.zod'; * stored blank sentence an invalid row on load, a narrowing wider than the * doors. The doors refuse instead (#25680): a blank sentence, a hole over a * root the key does not bind, a single-brace `{x}` token, a hole holding logic - * or an unknown formatter, and a `${…}` token the runner shows raw are - * ERRORS, so `objectstack validate` and `compile` fail and the publish gate - * answers its 422. A stored row keeps loading until its next save, and that - * save is judged. - * - * One finding stays a WARNING: a `${…}` token the runner still fills (the - * next section), in a sentence with no `{{ }}` hole, over a root - * {@link ACTION_COPY_DOLLAR_TRANSITION_ROOTS} names. Refusing it waits for - * the runner to retire that reading. + * or an unknown formatter, and a `${…}` token (the next section) are ERRORS, + * so `objectstack validate` and `compile` fail and the publish gate answers + * its 422. A stored row keeps loading until its next save, and that save is + * judged. * * ## 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. + * were written that way. It no longer does (objectui#13476): a sentence with + * no `{{ }}` hole is shown as written, and one with a hole is filled by the + * template engine alone, so a `${…}` token is shown raw in every sentence of + * every key, and the doors refuse it wherever it stands (#25709). The judge + * reads a `${…}` token with its `$` and prescribes the `{{ }}` hole in its + * place. */ -/** - * The roots whose `${…}` tokens the console's action runner still fills, per - * key, in a sentence with no `{{ }}` hole — measured at objectui - * `ActionRunner` (the pinned `.objectui-sha`): `confirmMessage` evaluates a - * holeless `confirmText` through its `${…}` expression evaluator over the - * runner's context, whose `record` is the record the action runs against; - * `composeSuccessMessage` substitutes `${result.*}` alone in a holeless - * `successMessage` (any other `${…}` is left as written); an `errorMessage` is - * shown as written. A door names a `${…}` token over one of these roots as a - * warning and every other one as an error (the module note). - */ -export const ACTION_COPY_DOLLAR_TRANSITION_ROOTS: Readonly> = Object.freeze({ - confirmText: Object.freeze(['record']), - successMessage: Object.freeze(['result']), - errorMessage: Object.freeze([]), -}); - /** The root a `confirmText` / `successMessage` hole names for the record the action runs against. */ export const ACTION_COPY_RECORD_ROOT = 'record'; @@ -187,11 +166,6 @@ const DOORS = 'and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one ' + 'loads until its next save.'; -/** What the confirm and success describes add: the one transitional `${…}` reading the doors only warn about. */ -const transition = (root: string) => - ` A \`\${${root}.*}\` token in a sentence with no \`{{ }}\` hole, which the console's runner still fills, is named ` - + 'as a warning instead.'; - /** `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 " @@ -200,7 +174,7 @@ export const ACTION_CONFIRM_TEXT_TEMPLATE_DESCRIBE = + '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}${transition('record')}`; + + `unknown formatter, and a blank sentence are named ${DOORS}`; /** `successMessage`'s describe. */ export const ACTION_SUCCESS_MESSAGE_TEMPLATE_DESCRIBE = @@ -209,7 +183,7 @@ export const ACTION_SUCCESS_MESSAGE_TEMPLATE_DESCRIBE = + '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}${transition('result')}`; + + `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 = diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index a61f3b19cd5..6a57f625c41 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -51,11 +51,9 @@ 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, and (#25680) the roots whose -// `${…}` tokens the runner still fills, which the doors only warn about. The -// slot factories stay package-internal. +// roots the doors judge each sentence against. The slot factories stay +// package-internal. export { - ACTION_COPY_DOLLAR_TRANSITION_ROOTS, ACTION_COPY_RECORD_ROOT, ACTION_CONFIRM_PARAMS_ROOT, ACTION_SUCCESS_RESULT_ROOT,