diff --git a/.changeset/27948-action-copy-envelope-readers.md b/.changeset/27948-action-copy-envelope-readers.md new file mode 100644 index 00000000000..7f216f38f31 --- /dev/null +++ b/.changeset/27948-action-copy-envelope-readers.md @@ -0,0 +1,21 @@ +--- +'@object-ui/core': patch +'@object-ui/react': patch +'@object-ui/fields': patch +--- + +fix(core,react): an action's `description`, a param's `helpText` and an `outcomeMessages` entry read the `template` envelope + +Clause-②: no +Metadata-protocol: none + +objectstack#27948 step 2 types these three as `template` slots, so a served action can carry `{ dialect: 'template', source }` where it carried a string. + +- **Param dialog.** The runner hands the param-collection handler the action and params with `description` and each `helpText` reduced to their text (`actionCopyText`), so the dialog draws the sentence, never `[object Object]`; a locale map is handed on unchanged for the host to localize. +- **Outcome toast.** `useActionTextLocalizer` keeps an `outcomeMessages` envelope's shape (a bundle entry replaces its `source`) instead of collapsing it to the word "template", and the runner fills the envelope's holes as it fills the string's. + +- **Param fields.** `@object-ui/fields`' `resolveActionParams` reads a param's `helpText` through `actionCopySource`, so an envelope yields its sentence (a field-backed param with no text falls back to the field's own help) and `ActionParam` stays assignable to its `RawActionParam`. + +- **Studio's action preview** (`@object-ui/app-shell`, unpublished) draws a param's `helpText`, `confirmText` and `successMessage` from the envelope's `source`: it drew `helpText` raw (an object as a React child) and read the other two as a locale map, which showed the word "template". + +What to do: nothing. `${…}` copy written before the migration keeps reading. diff --git a/.changeset/27948-action-copy-template-slots.md b/.changeset/27948-action-copy-template-slots.md new file mode 100644 index 00000000000..78030d340a3 --- /dev/null +++ b/.changeset/27948-action-copy-template-slots.md @@ -0,0 +1,33 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@objectstack/platform-objects': patch +'@objectstack/runtime': patch +'@objectstack/cli': patch +--- + +feat(spec)!: an action's `description`, each param's `helpText` and each `outcomeMessages` entry are `template` slots; the doors refuse `${…}` there + +Clause-②: yes (narrowing) +Metadata-protocol: narrowed + + + +Step 2 of #27948 (ruling A): the five action copy keys speak one hole syntax, ADR-0032's `{{ }}`. `confirmText` and `successMessage` already did (#24814); the param dialog's `description`, each param's `helpText` and each `outcomeMessages` entry now join them. + +- **The parse.** Each of the three is typed as `confirmText` is: a bare string is shorthand for `{ dialect: 'template', source }` and parses to that envelope; the envelope is taken; `description` and an `outcomeMessages` entry keep the inline locale map they took as an `I18nLabel`, and `helpText` stays without one (it was a plain string). A blank string and a `${…}` sentence still parse, so a stored action loads. +- **The roots.** `description` and `helpText` read `confirmText`'s roots: `record`, the record the action runs against (a registered action's `params` is the input list the dialog collects, so it binds nothing). An `outcomeMessages` entry reads `successMessage`'s: `result`, the handler's answer, and `record`. +- **The doors.** `objectstack validate`, `compile` and the publish gate (422) judge each sentence through the same judge as `confirmText`: a `${…}` token, a single-brace `{x}` token, a hole over a root the runner does not bind, a hole holding logic or an unknown formatter, and a blank sentence are errors, each named at its own path (`description`, `params[N].helpText`, `outcomeMessages.OUTCOME`) with its `{{ }}` spelling. An inline `properties.action` is judged on its params' `helpText`. +- **The readers.** The CLI's i18n extraction seeds the envelope's text exactly as the bare string's, and the MCP `list_actions` param description reads `helpText`'s text, never the object. +- **The platform's own copy** is rewritten: the invite actions' outcomes (`Invitation sent to {{ result.email }}`), `resend_invitation`'s and `update_member_role`'s descriptions, the role-change `permission_set_granted` outcomes, and their four translation bundles. + +What to do (FROM → TO, on `description`, `params[].helpText` and `outcomeMessages` entries): + +- `Resend the invitation to ${record.email}?` → `Resend the invitation to {{ record.email }}?` +- `Invitation sent to ${result.email}` → `Invitation sent to {{ result.email }}` +- `${record.owner?.name ?? 'nobody'}` → `{{ record.owner.name | default:'nobody' }}` (the `default` formatter also covers an empty value) +- other logic in a `${…}` token moves into the handler's answer and is read from `result`. + +The console keeps filling `${…}` in a stored sentence with no `{{ }}` hole, so stored copy reads as before until its next save, which is judged. `os migrate meta` lists the change (`action-dialog-outcome-copy-template-holes`); no conversion is registered, since a `${…}` token held an expression and a hole holds only a path and formatters. + +A TypeScript reader of a parsed `ActionSchema` now meets the envelope on these three keys: read its text with `templateSlotSource` (`@objectstack/spec/automation`). diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index de4f82ee7ac..02a763144b2 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -793,7 +793,7 @@ Metadata query with filtering, sorting, and pagination | **filePatterns** | `string[]` | ✅ | File glob patterns | | **supportsOverlay** | `boolean` | ✅ | Whether a stored overlay row of an item a managed package ships may be removed through the ordinary delete door without the type's environment-overlay permission: the removal removes no managed definition, and changes what is served for permission alone; it opens no save and it decides no read | | **domain** | `string` | ✅ | Protocol domain | -| **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Declarative type-level actions (buttons) the metadata-admin UI renders for this type | +| **actions** | `{ name: string; label: string \| Record; description?: string \| object \| string \| Record; objectName?: string; … }[]` | optional | Declarative type-level actions (buttons) the metadata-admin UI renders for this type | --- @@ -970,7 +970,7 @@ Metadata query with filtering, sorting, and pagination | **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`, `queues`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). 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. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | -| **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | +| **actions** | `{ name: string; label: string \| Record; description?: string \| object \| string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this object. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | diff --git a/content/docs/references/api/package-api-assembled.mdx b/content/docs/references/api/package-api-assembled.mdx index e79e8497e41..faf13aa0a5e 100644 --- a/content/docs/references/api/package-api-assembled.mdx +++ b/content/docs/references/api/package-api-assembled.mdx @@ -122,7 +122,7 @@ Installed package row whose manifest is the assembled package body | **dashboards** | `{ name: string; label: string \| Record; description?: string \| Record; header?: object; … }[]` | optional | Dashboards | | **reports** | `{ name: string; label: string \| Record; description?: string \| Record; type?: Enum<'tabular' \| 'summary' \| 'matrix' \| 'joined'>; … }[]` | optional | Analytics Reports | | **datasets** | `{ name: string; label: string \| Record; description?: string \| Record; object: string; … }[]` | optional | Analytics semantic-layer datasets (ADR-0021) | -| **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Global and Object Actions. Unique per scope, not per stack: the runtime keys every action by its owning object's name (or 'global' when object-less), a colon, then the action name, and defineStack refuses two declarations that resolve to one key — both here, both on one object's actions, or one in each position, identical twins included (an embedded action is keyed by the object it is written on, not by its own objectName). One global and one object-bound action may share a name; on that object's route the object's own actions take precedence for by-name readers. composeStacks runs the same key rule across its input stacks (counting distinct stacks, not sites) and names both source stacks on a collision. | +| **actions** | `{ name: string; label: string \| Record; description?: string \| object \| string \| Record; objectName?: string; … }[]` | optional | Global and Object Actions. Unique per scope, not per stack: the runtime keys every action by its owning object's name (or 'global' when object-less), a colon, then the action name, and defineStack refuses two declarations that resolve to one key — both here, both on one object's actions, or one in each position, identical twins included (an embedded action is keyed by the object it is written on, not by its own objectName). One global and one object-bound action may share a name; on that object's route the object's own actions take precedence for by-name readers. composeStacks runs the same key rule across its input stacks (counting distinct stacks, not sites) and names both source stacks on a collision. | | **flows** | `{ name: string; label: string; description?: string; successMessage?: string; … }[]` | optional | Screen Flows | | **jobs** | `{ name: string; label?: string; description?: string; schedule: object \| object \| object; … }[]` | optional | Background / Scheduled Jobs (run by IJobService on cron/interval/once schedules) | | **emailTemplates** | `{ name: string; label: string; locale?: string; subject: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | @@ -372,7 +372,7 @@ This schema accepts one of the following structures: | **dashboards** | `{ name: string; label: string \| Record; description?: string \| Record; header?: object; … }[]` | optional | Dashboards | | **reports** | `{ name: string; label: string \| Record; description?: string \| Record; type?: Enum<'tabular' \| 'summary' \| 'matrix' \| 'joined'>; … }[]` | optional | Analytics Reports | | **datasets** | `{ name: string; label: string \| Record; description?: string \| Record; object: string; … }[]` | optional | Analytics semantic-layer datasets (ADR-0021) | -| **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Global and Object Actions. Unique per scope, not per stack: the runtime keys every action by its owning object's name (or 'global' when object-less), a colon, then the action name, and defineStack refuses two declarations that resolve to one key — both here, both on one object's actions, or one in each position, identical twins included (an embedded action is keyed by the object it is written on, not by its own objectName). One global and one object-bound action may share a name; on that object's route the object's own actions take precedence for by-name readers. composeStacks runs the same key rule across its input stacks (counting distinct stacks, not sites) and names both source stacks on a collision. | +| **actions** | `{ name: string; label: string \| Record; description?: string \| object \| string \| Record; objectName?: string; … }[]` | optional | Global and Object Actions. Unique per scope, not per stack: the runtime keys every action by its owning object's name (or 'global' when object-less), a colon, then the action name, and defineStack refuses two declarations that resolve to one key — both here, both on one object's actions, or one in each position, identical twins included (an embedded action is keyed by the object it is written on, not by its own objectName). One global and one object-bound action may share a name; on that object's route the object's own actions take precedence for by-name readers. composeStacks runs the same key rule across its input stacks (counting distinct stacks, not sites) and names both source stacks on a collision. | | **flows** | `{ name: string; label: string; description?: string; successMessage?: string; … }[]` | optional | Screen Flows | | **jobs** | `{ name: string; label?: string; description?: string; schedule: object \| object \| object; … }[]` | optional | Background / Scheduled Jobs (run by IJobService on cron/interval/once schedules) | | **emailTemplates** | `{ name: string; label: string; locale?: string; subject: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index ba999fbf3ee..4d3ed55bf4c 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -177,7 +177,7 @@ const result = ApiMethod.parse(data); | **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`, `queues`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). 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. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | -| **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | +| **actions** | `{ name: string; label: string \| Record; description?: string \| object \| string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this object. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -477,7 +477,7 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Machine name (lowercase snake_case) | | **label** | `string \| Record` | ✅ | Display label | -| **description** | `string \| Record` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`. | +| **description** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`. It is a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record); the values the dialog collects are not bound, since the line shows before they exist. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Choose the new role for `{{ record.name }}`.'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. | | **objectName** | `string` | optional | Target object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack(). | | **icon** | `string` | optional | Icon name | | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Locations where this action is visible | @@ -495,7 +495,7 @@ const result = ApiMethod.parse(data); | **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. 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). | +| **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 is a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`), and `record`, the record the action ran against — the roots `successMessage` reads. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation resent 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. 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 | | **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set here, but `type: 'api'` is fulfilled by the console, which builds the undo envelope from `undoable` alone. Those two shapes — `operation: 'update'` and `type: 'api'` — are the whole fulfillable set; on a registered action `undoable` beside any other shape is refused, because nothing would anchor the capture. | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 027fa3d685d..7400a32fd9c 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -278,7 +278,7 @@ const result = MetadataBulkResultSchema.parse(data); | **executionPinned** | `boolean` | optional (default: `false`) | Transaction rows reference a specific version_hash; history GC is disabled and getByHash() MUST resolve old hashes (ADR-0009) | | **loadOrder** | `integer` | optional (default: `100`) | Loading priority (lower = earlier) | | **domain** | `Enum<'data' \| 'ui' \| 'automation' \| 'system' \| 'security' \| 'ai'>` | ✅ | Protocol domain | -| **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Declarative type-level actions (e.g. datasource "Test connection"), reusing ActionSchema; merged with plugin-registered actions when emitted | +| **actions** | `{ name: string; label: string \| Record; description?: string \| object \| string \| Record; objectName?: string; … }[]` | optional | Declarative type-level actions (e.g. datasource "Test connection"), reusing ActionSchema; merged with plugin-registered actions when emitted | ### Allowed Values: `MetadataTypeRegistryEntry.type` @@ -320,7 +320,7 @@ const result = MetadataBulkResultSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Machine name (lowercase snake_case) | | **label** | `string \| Record` | ✅ | Display label | -| **description** | `string \| Record` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`. | +| **description** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`. It is a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record); the values the dialog collects are not bound, since the line shows before they exist. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Choose the new role for `{{ record.name }}`.'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. | | **objectName** | `string` | optional | Target object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack(). | | **icon** | `string` | optional | Icon name | | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Locations where this action is visible | @@ -338,7 +338,7 @@ const result = MetadataBulkResultSchema.parse(data); | **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. 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). | +| **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 is a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`), and `record`, the record the action ran against — the roots `successMessage` reads. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation resent 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. 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 | | **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set here, but `type: 'api'` is fulfilled by the console, which builds the undo envelope from `undoable` alone. Those two shapes — `operation: 'update'` and `type: 'api'` — are the whole fulfillable set; on a registered action `undoable` beside any other shape is refused, because nothing would anchor the capture. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 86a6ac4aaa3..988021e4b23 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -362,7 +362,7 @@ Create a new object | **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`, `queues`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). 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. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | -| **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | +| **actions** | `{ name: string; label: string \| Record; description?: string \| object \| string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this object. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -660,7 +660,7 @@ Create a new object | **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`, `queues`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). 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. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | -| **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | +| **actions** | `{ name: string; label: string \| Record; description?: string \| object \| string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this object. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index b5ceb4a3023..20769dea8e6 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -30,7 +30,7 @@ const result = ActionSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Machine name (lowercase snake_case) | | **label** | `string \| Record` | ✅ | Display label | -| **description** | `string \| Record` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`. | +| **description** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`. It is a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record); the values the dialog collects are not bound, since the line shows before they exist. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Choose the new role for `{{ record.name }}`.'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. | | **objectName** | `string` | optional | Target object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack(). | | **icon** | `string` | optional | Icon name | | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Locations where this action is visible | @@ -48,7 +48,7 @@ const result = ActionSchema.parse(data); | **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. 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). | +| **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 is a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`), and `record`, the record the action ran against — the roots `successMessage` reads. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation resent 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. 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 | | **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set here, but `type: 'api'` is fulfilled by the console, which builds the undo envelope from `undoable` alone. Those two shapes — `operation: 'update'` and `type: 'api'` — are the whole fulfillable set; on a registered action `undoable` beside any other shape is refused, because nothing would anchor the capture. | @@ -142,7 +142,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | -| **helpText** | `string` | optional | | +| **helpText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Hint shown under this input in the action's param dialog — 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 dialog's own values are not bound. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Why `{{ record.name }}` is paused'. 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. | | **defaultValue** | `any` | optional | | | **multiple** | `boolean` | optional | Allow multiple values (array value shape); mirrors FieldSchema.multiple. | | **accept** | `string[]` | optional | Accepted upload types (MIME types / extensions) for file/image params. | @@ -228,7 +228,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | -| **helpText** | `string` | optional | | +| **helpText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Hint shown under this input in the action's param dialog — 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 dialog's own values are not bound. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Why `{{ record.name }}` is paused'. 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. | | **defaultValue** | `any` | optional | | | **multiple** | `boolean` | optional | Allow multiple values (array value shape); mirrors FieldSchema.multiple. | | **accept** | `string[]` | optional | Accepted upload types (MIME types / extensions) for file/image params. | @@ -363,7 +363,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | -| **helpText** | `string` | optional | | +| **helpText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string` | optional | Hint shown under this input in the action's param dialog — 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 dialog's own values are not bound. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Why `{{ record.name }}` is paused'. 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. | | **defaultValue** | `any` | optional | | | **multiple** | `boolean` | optional | Allow multiple values (array value shape); mirrors FieldSchema.multiple. | | **accept** | `string[]` | optional | Accepted upload types (MIME types / extensions) for file/image params. | diff --git a/content/docs/ui/actions.mdx b/content/docs/ui/actions.mdx index fd352674c8b..7b117611490 100644 --- a/content/docs/ui/actions.mdx +++ b/content/docs/ui/actions.mdx @@ -386,7 +386,8 @@ strict params gate admits either bag without a word. `'Invitation sent to {{ result.email }}'`; a `${…}` token in it is refused. A `type: 'api'` or `'script'` action whose handler can succeed in more than one way adds `outcomeMessages`, one entry per snake_case `outcome` the handler - returns, each of which may read `${result.*}`; the toast shows the entry the + returns, each a template like `successMessage` (`{{ result.* }}`, + `{{ record.* }}`; a `${…}` token is refused); the toast shows the entry the answer names, else `successMessage`, else a generic "Action completed successfully". A `message` in the server's answer is shown only when the copy asks for it (`{{ result.message }}`). Post-success navigation is diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index a78806461be..2f6f13b4447 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -721,8 +721,10 @@ function pushDerived( } /** - * [#24814] An action's `confirmText` / `successMessage` is a `template` slot - * that also takes an inline locale map: a bare string, the parsed + * [#24814, #27948] An action's `confirmText` / `successMessage` / + * `description` / `outcomeMessages` entry is a `template` slot that also takes + * an inline locale map, and a param's `helpText` one without it: a bare + * string, the parsed * `{ dialect: 'template', source }` envelope, or the map. The envelope and the * string are the same authored sentence, so the envelope is read through the * template judge's one reader and seeded like the string; anything else (the @@ -801,7 +803,7 @@ function pushActionParams( } else { pushDerived(out, [...base, 'label'], literalLabel ?? pname, param.label, kind, { objectName }); } - pushOptional(out, [...base, 'helpText'], param.helpText, kind, { objectName }); + pushOptional(out, [...base, 'helpText'], actionCopy(param.helpText), kind, { objectName }); pushOptional(out, [...base, 'placeholder'], param.placeholder, kind, { objectName }); if (Array.isArray(param.options)) { for (const opt of param.options) { @@ -839,7 +841,7 @@ function pushActionOutcomeMessages( const messages = action?.outcomeMessages; if (!messages || typeof messages !== 'object' || Array.isArray(messages)) return; for (const [outcome, message] of Object.entries(messages)) { - pushOptional(out, [...actionRoot, 'outcomeMessages', outcome], message, kind, { objectName }); + pushOptional(out, [...actionRoot, 'outcomeMessages', outcome], actionCopy(message), kind, { objectName }); } } @@ -1522,7 +1524,7 @@ export function collectExpectedEntries( const aname = action.name as string; const aroot = ['objects', objectName, '_actions', aname]; pushDerived(out, [...aroot, 'label'], inlineText(action.label) ?? aname, action.label, 'action', { objectName }); - pushOptional(out, [...aroot, 'description'], action.description, 'action', { objectName }); + pushOptional(out, [...aroot, 'description'], actionCopy(action.description), 'action', { objectName }); pushOptional(out, [...aroot, 'confirmText'], actionCopy(action.confirmText), 'action', { objectName }); pushOptional(out, [...aroot, 'successMessage'], actionCopy(action.successMessage), 'action', { objectName }); pushOptional(out, [...aroot, 'disabledReason'], action.disabledReason, 'action', { objectName }); @@ -1617,7 +1619,7 @@ export function collectExpectedEntries( // string when the action declares none, so an undeclared description is // not an i18n gap to seed (`pushDerived` would invent an English source // string nothing authored). #7367. - pushOptional(out, [...root, 'description'], action.description, kind, { objectName }); + pushOptional(out, [...root, 'description'], actionCopy(action.description), kind, { objectName }); pushOptional(out, [...root, 'confirmText'], actionCopy(action.confirmText), kind, { objectName }); pushOptional(out, [...root, 'successMessage'], actionCopy(action.successMessage), kind, { objectName }); // [#26619] OPTIONAL-not-derived like `description`: an action that gives no diff --git a/packages/cli/test/i18n-extract-action-copy-envelope-27948.test.ts b/packages/cli/test/i18n-extract-action-copy-envelope-27948.test.ts new file mode 100644 index 00000000000..82c149d3fa7 --- /dev/null +++ b/packages/cli/test/i18n-extract-action-copy-envelope-27948.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #27948 (ruling A, step 2): an action's `description`, each param's + * `helpText` and an `outcomeMessages` entry become ADR-0032 `template` slots, + * as `confirmText` / `successMessage` already are (#24814). A parsed slot is + * the `{ dialect: 'template', source }` envelope, so the extraction walk must + * seed the envelope's `source` exactly as it seeds the bare string — read as + * a plain value, the envelope was recorded like an absent key and nothing was + * left to translate. + * + * Both action walks are driven (the object's own `actions[]` and the top-level + * `actions[]`), the regression `i18n-walk-output-parity` exists for. + */ +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries } from '../src/utils/i18n-extract.js'; + +const envelope = (source: string) => ({ dialect: 'template', source }); + +/** One action carrying the three slots, either as strings or as envelopes. */ +function action(name: string, wrap: (s: string) => unknown, extra: Record = {}) { + return { + name, + label: 'Pause', + type: 'script', + target: 'pause', + description: wrap('Pause {{ record.name }}?'), + params: [{ name: 'reason', label: 'Reason', type: 'text', helpText: wrap('Why pause {{ record.name }}?') }], + outcomeMessages: { paused: wrap('Paused {{ result.name }}.') }, + ...extra, + }; +} + +function config(wrap: (s: string) => unknown): any { + return { + objects: [{ name: 'crm_account', label: 'Account', fields: { name: { label: 'Name' } }, actions: [action('pause_account', wrap)] }], + actions: [action('pause_all', wrap, { objectName: 'crm_account' }), action('pause_global', wrap)], + }; +} + +/** `path → sourceValue` for the three slots on every action walk. */ +function seeds(wrap: (s: string) => unknown): Record { + const out: Record = {}; + for (const e of collectExpectedEntries(config(wrap))) { + const key = e.path.join('.'); + if (key.startsWith('metadataForms.')) continue; + if (/\.(description|helpText|outcomeMessages\.paused)$/.test(key) && /pause_(account|all|global)/.test(key)) { + out[key] = e.sourceValue; + } + } + return out; +} + +describe('i18n extraction reads the template envelope on description / helpText / outcomeMessages (#27948)', () => { + it('seeds each envelope exactly as the bare string, on every action walk', () => { + const fromStrings = seeds((s) => s); + const fromEnvelopes = seeds(envelope); + // 3 actions × 3 slots, every one seeded with the authored sentence. + expect(Object.keys(fromStrings)).toHaveLength(9); + expect(Object.values(fromStrings).every((v) => typeof v === 'string' && v.includes('{{'))).toBe(true); + expect(fromEnvelopes).toEqual(fromStrings); + }); + + it('an inline locale map is still authored, never seeded (unchanged)', () => { + const map = { en: 'Pause it?', 'zh-CN': '暂停?' }; + const entry = collectExpectedEntries({ + objects: [], + actions: [{ name: 'pause_map', label: 'Pause', type: 'script', target: 'pause', description: map }], + } as any).find((e) => e.path.join('.') === 'globalActions.pause_map.description'); + expect(entry).toBeDefined(); + expect(entry?.sourceValue).not.toEqual(map); + }); +}); diff --git a/packages/cli/test/i18n-extract-action-description.test.ts b/packages/cli/test/i18n-extract-action-description.test.ts index 01ee8342db0..3eb29dce16c 100644 --- a/packages/cli/test/i18n-extract-action-description.test.ts +++ b/packages/cli/test/i18n-extract-action-description.test.ts @@ -169,8 +169,10 @@ describe('one fixture action, all three surfaces (#7367 end-to-end)', () => { // 1. The producer accepts it. const parsed = ActionSchema.safeParse(authored); expect(parsed.success).toBe(true); + // `description` is a `template` slot (#27948): a bare string parses to the + // envelope, whose `source` is the sentence as written. expect(parsed.success && parsed.data.description) - .toBe('Reject this request? The requester sees your reason.'); + .toEqual({ dialect: 'template', source: 'Reject this request? The requester sees your reason.' }); expect(parsed.success && parsed.data.confirmText).toBeUndefined(); // 2. The extractor emits the address objectui's resolver reads diff --git a/packages/lint/src/validate-action-copy-templates.test.ts b/packages/lint/src/validate-action-copy-templates.test.ts index 854af993b18..7965caeb87e 100644 --- a/packages/lint/src/validate-action-copy-templates.test.ts +++ b/packages/lint/src/validate-action-copy-templates.test.ts @@ -229,3 +229,109 @@ describe('the runtime publish gate — every finding a refusal, a holeless `${ expect(findings('action', invite({ successMessage: 'Invitation sent to {{ result.email }}' }))).toEqual({ errors: [], advisories: [] }); }); }); + +// #27948 (ruling A, step 2) — the param dialog's `description`, each +// `params[].helpText` and each `outcomeMessages` entry joined the family: the +// dialog copy over `confirmText`'s roots (`record`; a registered action's +// `params` is the input list, which binds nothing), an outcome over +// `successMessage`'s (`result`, `record`). The doors refuse a `${…}` token +// there exactly as in the other keys, prescribing the `{{ }}` hole. +describe('validateStackExpressions — description, params[].helpText and outcomeMessages are template slots (#27948)', () => { + const newSlotIssues = (stack: Record) => + validateStackExpressions(stack).filter((i) => /description|helpText|outcomeMessages/.test(i.where)); + const withParam = (helpText: unknown) => ({ params: [{ name: 'reason', type: 'text', helpText }] }); + + it('passes plain text and holes over bound roots — bare, as the envelope, or in a locale map', () => { + for (const patch of [ + { description: 'Resend the invitation to {{ record.email }}?' }, + { description: { dialect: 'template', source: 'Pause {{ record.name }}?' } }, + { description: { en: 'Pause {{ record.name }}?', 'zh-CN': '暂停 {{ record.name }}?' } }, + { description: "Choose the role for {{ record.user_id.name | default:'this member' }}." }, + withParam('Why pause {{ record.name }}?'), + withParam('Plain hint'), + { outcomeMessages: { sent: 'Invitation sent to {{ result.email }}', kept: 'Kept {{ record.name }}' } }, + { outcomeMessages: { sent: { en: 'Sent to {{ result.email }}', 'zh-CN': '已发送至 {{ result.email }}' } } }, + ]) { + expect(newSlotIssues({ objects, actions: [invite(patch)] }), JSON.stringify(patch)).toEqual([]); + } + }); + + it('refuses a `${…}` token in each, as an error at its own path, prescribing the `{{ }}` hole', () => { + const issues = newSlotIssues({ + objects, + actions: [invite({ + description: 'Resend the invitation to ${record.email}?', + params: [{ name: 'reason', type: 'text', helpText: 'Why ${record.name}?' }], + outcomeMessages: { sent: 'Invitation sent to ${result.email}', kept: { en: 'Kept', 'zh-CN': '保留 ${record.name}' } }, + })], + }); + expect(issues.map((i) => [i.where, i.severity])).toEqual([ + ["stack · action 'invite' description", 'error'], + ["stack · action 'invite' outcomeMessages.sent", 'error'], + ["stack · action 'invite' outcomeMessages.kept.zh-CN", 'error'], + ["stack · action 'invite' params[0].helpText", 'error'], + ]); + expect(issues[0]!.message).toContain("An action's `description`"); + expect(issues[0]!.message).toContain('as `Resend the invitation to {{ record.email }}?`'); + expect(issues[1]!.message).toContain("An action's `outcomeMessages` entry"); + expect(issues[1]!.message).toContain('as `Invitation sent to {{ result.email }}`'); + expect(issues[3]!.message).toContain("An action param's `helpText`"); + expect(issues[3]!.message).toContain('as `Why {{ record.name }}?`'); + }); + + it('binds what the runner binds: `result` is refused in the dialog copy, accepted in an outcome', () => { + const issues = newSlotIssues({ + objects, + actions: [invite({ description: 'Sent to {{ result.email }}', ...withParam('{{ result.email }}'), outcomeMessages: { sent: '{{ result.email }}' } })], + }); + expect(issues.map((i) => i.where)).toEqual([ + "stack · action 'invite' description", + "stack · action 'invite' params[0].helpText", + ]); + }); + + it('refuses a blank sentence, a single-brace token and an unknown formatter', () => { + const issues = newSlotIssues({ + objects, + actions: [invite({ description: ' ', ...withParam('Why {record.name}?'), outcomeMessages: { sent: '{{ result.email | shout }}' } })], + }); + expect(issues.map((i) => i.where)).toEqual([ + "stack · action 'invite' description", + "stack · action 'invite' outcomeMessages.sent", + "stack · action 'invite' params[0].helpText", + ]); + expect(issues[0]!.message).toContain('is blank'); + }); + + it('judges an inline `properties.action` param helpText; an object\'s own action on an object write', () => { + const page = pageWith([{ + type: 'element:button', + properties: { action: { type: 'script', target: 'run', params: [{ name: 'reason', type: 'text', helpText: 'Why ${record.name}?' }] } }, + }]); + const pageIssues = runStackExpressionPasses({ objects, pages: [page] }, { runtimeWriteType: 'page' }) + .filter((i) => /helpText/.test(i.where)); + expect(pageIssues).toHaveLength(1); + expect(pageIssues[0]!.where).toContain('· action params[0].helpText'); + + const onObjectWrite = newSlotIssues({ objects: [{ ...objects[0], actions: [invite({ description: 'Hi ${record.name}' })] }] }); + expect(onObjectWrite.map((i) => i.where)).toEqual(["object 'crm_account' · action 'invite' description"]); + }); + + it('the publish gate answers each `${…}` with one error and no advisory', () => { + const gate = (item: unknown) => { + const { errors, advisories } = runRuntimeAuthoringRules({ type: 'action', item, context: { objects } }); + const copy = (f: { where: string }) => /description|helpText|outcomeMessages/.test(f.where); + return { errors: errors.filter(copy), advisories: advisories.filter(copy) }; + }; + for (const patch of [ + { description: 'Resend to ${record.email}?' }, + withParam('Why ${record.name}?'), + { outcomeMessages: { sent: 'Invitation sent to ${result.email}' } }, + ]) { + const { errors, advisories } = gate(invite(patch)); + expect(errors, JSON.stringify(patch)).toHaveLength(1); + expect(advisories, JSON.stringify(patch)).toEqual([]); + } + expect(gate(invite({ outcomeMessages: { sent: 'Invitation sent to {{ result.email }}' } }))).toEqual({ errors: [], advisories: [] }); + }); +}); diff --git a/packages/lint/src/validate-action-copy-templates.ts b/packages/lint/src/validate-action-copy-templates.ts index 2180e70ec30..c2bbc45e0d0 100644 --- a/packages/lint/src/validate-action-copy-templates.ts +++ b/packages/lint/src/validate-action-copy-templates.ts @@ -8,13 +8,16 @@ * * - a registered action (`stack.actions[]`, and an object's own `actions[]`): * `confirmText` and `successMessage`, each a bare string, the parsed - * envelope, or an inline locale map whose every value is such a sentence; + * envelope, or an inline locale map whose every value is such a sentence — + * and, since #27948 (ruling A, step 2), `description`, each + * `outcomeMessages` entry (both with the locale map) and each + * `params[].helpText` (without it); * - a page action row — `action:button` / `action:icon` `properties`, and * each `properties.actions[]` member of an `action:group` / `action:menu` — * all three keys; * - a page component's inline `properties.action` (`InlineActionSchema`, the - * pick of `ActionSchema` an `element:button` carries): `confirmText` and - * `successMessage`. + * pick of `ActionSchema` an `element:button` carries): `confirmText`, + * `successMessage` and each `params[].helpText`. * * The same two steps the bulk `confirmText` pass takes, through the same * judge: `closedScopeTemplateRefusal` names a `${…}` or single-brace token @@ -23,7 +26,9 @@ * `template` it is (`validateExpression('template', …)`), which names a hole * holding logic or an unknown formatter. A blank sentence is named too. Each * key's roots are the spec's, measured at the console's runner: - * `actionConfirmTemplateRoots(action)`, `ACTION_SUCCESS_TEMPLATE_ROOTS`, + * `actionConfirmTemplateRoots(action)` (also for `description` and + * `helpText`, the param dialog's copy), `ACTION_SUCCESS_TEMPLATE_ROOTS` (also + * for an `outcomeMessages` entry, the success toast of one outcome), * `ACTION_ERROR_TEMPLATE_ROOTS` (none — the runner shows an `errorMessage` as * written). * @@ -49,14 +54,36 @@ import { walkPageComponents } from './page-walk.js'; type AnyRec = Record; -type CopyKey = 'confirmText' | 'successMessage' | 'errorMessage'; +type CopyKey = 'confirmText' | 'successMessage' | 'errorMessage' | 'description' | 'helpText' | 'outcomeMessages'; /** The keys a registered or inline action carries as `template` slots (`ActionSchema`'s `errorMessage` is not one). */ const ACTION_KEYS: readonly CopyKey[] = ['confirmText', 'successMessage']; +/** + * [#27948] The keys only a REGISTERED action carries as `template` slots, past + * {@link ACTION_KEYS}: the param dialog's `description`, each + * `outcomeMessages` entry, each `params[].helpText`. An inline action's pick + * has `params` but neither of the other two, so it is judged on `helpText` + * alone ({@link INLINE_KEYS}). + */ +const REGISTERED_KEYS: readonly CopyKey[] = [...ACTION_KEYS, 'description', 'outcomeMessages', 'helpText']; + +/** The keys an inline `properties.action` carries as `template` slots. */ +const INLINE_KEYS: readonly CopyKey[] = [...ACTION_KEYS, 'helpText']; + /** The keys a page action row carries as `template` slots. */ const ROW_KEYS: readonly CopyKey[] = ['confirmText', 'successMessage', 'errorMessage']; +/** How a finding names the slot. */ +const SLOT_NAME: Record = { + confirmText: "An action's `confirmText`", + successMessage: "An action's `successMessage`", + errorMessage: "An action's `errorMessage`", + description: "An action's `description`", + helpText: "An action param's `helpText`", + outcomeMessages: "An action's `outcomeMessages` entry", +}; + /** The page components whose own `properties` are an action row. */ const ROW_COMPONENTS: ReadonlySet = new Set(['action:button', 'action:icon']); @@ -73,12 +100,44 @@ const BLANK_COST: Record = { successMessage: 'so the success toast shows the runner\'s default text: write the sentence, or remove the key.', errorMessage: 'so the failure toast shows the raw error (or an empty toast, for a whitespace sentence): write the ' + 'sentence, or remove the key.', + description: 'so the param dialog shows no line under its title: write the sentence, or remove the key.', + helpText: 'so the input shows no hint: write the hint, or remove the key.', + outcomeMessages: 'so that outcome\'s toast falls back to `successMessage` (or the runner\'s default text): write the ' + + 'sentence, or remove the entry.', }; /** The roots one key's holes may name, for the action that carries it. */ function rootsOf(key: CopyKey, action: AnyRec): readonly string[] { - if (key === 'confirmText') return actionConfirmTemplateRoots(action); - return key === 'successMessage' ? ACTION_SUCCESS_TEMPLATE_ROOTS : ACTION_ERROR_TEMPLATE_ROOTS; + switch (key) { + case 'confirmText': + case 'description': + case 'helpText': + return actionConfirmTemplateRoots(action); + case 'successMessage': + case 'outcomeMessages': + return ACTION_SUCCESS_TEMPLATE_ROOTS; + default: + return ACTION_ERROR_TEMPLATE_ROOTS; + } +} + +/** + * The copy values one key carries on `action`, each with the path it is named + * at: the key itself, or — for the two keys that are maps of slots — each + * `outcomeMessages.` entry and each `params[].helpText`. + */ +function valuesOf(action: AnyRec, key: CopyKey): Array<{ path: string; value: unknown }> { + if (key === 'outcomeMessages') { + const messages = action.outcomeMessages; + if (!isRec(messages)) return []; + return Object.entries(messages).map(([outcome, value]) => ({ path: `outcomeMessages.${outcome}`, value })); + } + if (key === 'helpText') { + if (!Array.isArray(action.params)) return []; + return action.params.flatMap((param: unknown, i: number) => + isRec(param) && param.helpText !== undefined ? [{ path: `params[${i}].helpText`, value: param.helpText }] : []); + } + return [{ path: key, value: action[key] }]; } function isRec(v: unknown): v is AnyRec { @@ -111,22 +170,27 @@ function judgeAction( localeMap: boolean, ): void { for (const key of keys) { - for (const { at, text } of sentencesOf(action[key], localeMap)) { - const located = `${where} ${key}${at}`; - const slot = `An action's \`${key}\``; - const refuse = (message: string, quoted: string): void => { - issues.push({ where: located, message, source: quoted, severity: 'error' }); - }; - if (BLANK_TEXT.test(text)) { - refuse(`${slot} is blank, ${BLANK_COST[key]}`, text); - continue; - } - const refusal = closedScopeTemplateRefusal(text, { slot, roots: rootsOf(key, action) }); - if (refusal !== undefined) { - refuse(refusal, text); - continue; + // A param's `helpText` was a plain string, never a locale map (#27948). + const takesMap = localeMap && key !== 'helpText'; + const slot = SLOT_NAME[key]; + const roots = rootsOf(key, action); + for (const { path, value } of valuesOf(action, key)) { + for (const { at, text } of sentencesOf(value, takesMap)) { + const located = `${where} ${path}${at}`; + const refuse = (message: string, quoted: string): void => { + issues.push({ where: located, message, source: quoted, severity: 'error' }); + }; + if (BLANK_TEXT.test(text)) { + refuse(`${slot} is blank, ${BLANK_COST[key]}`, text); + continue; + } + const refusal = closedScopeTemplateRefusal(text, { slot, roots }); + if (refusal !== undefined) { + refuse(refusal, text); + continue; + } + for (const e of validateExpression('template', text).errors) refuse(e.message, e.source); } - for (const e of validateExpression('template', text).errors) refuse(e.message, e.source); } } } @@ -156,7 +220,7 @@ export function actionCopyTemplateIssues(stack: AnyRec, scope: ActionCopyTemplat const key = `${bound}:${name}`; if (seen.has(key)) return; seen.add(key); - judgeAction(issues, action, `${owner} · action '${name}'`, ACTION_KEYS, true); + judgeAction(issues, action, `${owner} · action '${name}'`, REGISTERED_KEYS, true); }; if (scope.actions) { for (const action of recordsOf(stack.actions)) registered('stack', action); @@ -182,7 +246,7 @@ export function actionCopyTemplateIssues(stack: AnyRec, scope: ActionCopyTemplat if (isRec(member)) judgeAction(issues, member, `${path} (${type}) · actions[${mi}]`, ROW_KEYS, false); }); } - if (isRec(props.action)) judgeAction(issues, props.action, `${path} (${type}) · action`, ACTION_KEYS, true); + if (isRec(props.action)) judgeAction(issues, props.action, `${path} (${type}) · action`, INLINE_KEYS, true); } } } diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 37723dfda96..14a9b56561a 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1825,7 +1825,7 @@ export const enMetadataForms: NonNullable = { }, outcomeMessages: { label: "Outcome Messages", - helpText: "Success message per handler outcome, as JSON: each key is a snake_case `outcome` value the handler returns in its success payload, and each value is the label shown for that outcome, e.g. {\"archived\": \"Archived\", \"already_archived\": \"Already archived\"}. A label may interpolate ${result.*}. An outcome with no entry shows the success message; a key the handler never returns is never shown. Not allowed beside a result dialog or `operation: 'update'`." + helpText: "Success message per handler outcome, as JSON: each key is a snake_case `outcome` value the handler returns in its success payload, and each value is the label shown for that outcome, e.g. {\"archived\": \"Archived\", \"already_archived\": \"Already archived\"}. A label may hold {{ result.* }} holes. An outcome with no entry shows the success message; a key the handler never returns is never shown. Not allowed beside a result dialog or `operation: 'update'`." }, onSuccess: { label: "On Success", diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index caf62206427..8e3a0a8bffa 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -141,8 +141,8 @@ export const enObjects: NonNullable = { description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.", successMessage: "Invitation sent to {{ result.email }}", outcomeMessages: { - sent: "Invitation sent to ${result.email}", - link_only: "Invitation created for ${result.email}. Mail is not configured on this server, so no email went out. Use Copy Invite Link on the invitation to share it." + sent: "Invitation sent to {{ result.email }}", + link_only: "Invitation created for {{ result.email }}. Mail is not configured on this server, so no email went out. Use Copy Invite Link on the invitation to share it." }, params: { role: { @@ -581,8 +581,8 @@ export const enObjects: NonNullable = { description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.", successMessage: "Invitation sent to {{ result.email }}", outcomeMessages: { - sent: "Invitation sent to ${result.email}", - link_only: "Invitation created for ${result.email}. Mail is not configured on this server, so no email went out. Use Copy Invite Link on the invitation to share it." + sent: "Invitation sent to {{ result.email }}", + link_only: "Invitation created for {{ result.email }}. Mail is not configured on this server, so no email went out. Use Copy Invite Link on the invitation to share it." }, params: { role: { @@ -673,8 +673,8 @@ export const enObjects: NonNullable = { description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.", successMessage: "Invitation sent to {{ result.email }}", outcomeMessages: { - sent: "Invitation sent to ${result.email}", - link_only: "Invitation created for ${result.email}. Mail is not configured on this server, so no email went out. Use Copy Invite Link on the invitation to share it." + sent: "Invitation sent to {{ result.email }}", + link_only: "Invitation created for {{ result.email }}. Mail is not configured on this server, so no email went out. Use Copy Invite Link on the invitation to share it." }, params: { role: { @@ -700,10 +700,10 @@ export const enObjects: NonNullable = { }, update_member_role: { label: "Change Role", - description: "Choose the new organization role for ${record.user_id?.name ?? 'this member'}.", + description: "Choose the new organization role for {{ record.user_id.name | default:'this member' }}.", successMessage: "Member role updated", outcomeMessages: { - permission_set_granted: "Member role updated. The new role also granted the permission set ${result.grantedPermissionSet.label}." + permission_set_granted: "Member role updated. The new role also granted the permission set {{ result.grantedPermissionSet.label }}." }, params: { role: { @@ -721,7 +721,7 @@ export const enObjects: NonNullable = { confirmText: "Make the selected member an owner of this organization? They get every owner privilege, including deleting the organization. You stay an owner too; to step down, use Change Role on your own membership.", successMessage: "Member is now an owner", outcomeMessages: { - permission_set_granted: "Member is now an owner. The new role also granted the permission set ${result.grantedPermissionSet.label}." + permission_set_granted: "Member is now an owner. The new role also granted the permission set {{ result.grantedPermissionSet.label }}." } } } @@ -825,8 +825,8 @@ export const enObjects: NonNullable = { description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.", successMessage: "Invitation sent to {{ result.email }}", outcomeMessages: { - sent: "Invitation sent to ${result.email}", - link_only: "Invitation created for ${result.email}. Mail is not configured on this server, so no email went out. Use Copy Invite Link on the invitation to share it." + sent: "Invitation sent to {{ result.email }}", + link_only: "Invitation created for {{ result.email }}. Mail is not configured on this server, so no email went out. Use Copy Invite Link on the invitation to share it." }, params: { role: { @@ -848,11 +848,11 @@ export const enObjects: NonNullable = { }, resend_invitation: { label: "Resend Invitation", - description: "Resend the invitation to ${record.email}? Its expiry date starts over.", + description: "Resend the invitation to {{ record.email }}? Its expiry date starts over.", successMessage: "Invitation resent", outcomeMessages: { - sent: "Invitation resent to ${result.email}", - link_only: "Invitation renewed for ${result.email}. Mail is not configured on this server, so no email went out. Use Copy Invite Link on the invitation to share it." + sent: "Invitation resent to {{ result.email }}", + link_only: "Invitation renewed for {{ result.email }}. Mail is not configured on this server, so no email went out. Use Copy Invite Link on the invitation to share it." } }, copy_invitation_link: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 0dd92b413a3..3f25d359f15 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1825,7 +1825,7 @@ export const esESMetadataForms: NonNullable = }, outcomeMessages: { label: "Mensajes por resultado", - helpText: "Mensaje de éxito por cada resultado del manejador, en JSON: cada clave es un valor `outcome` en snake_case que el manejador devuelve en su carga útil de éxito, y cada valor es la etiqueta que se muestra para ese resultado, p. ej., {\"archived\": \"Archivado\", \"already_archived\": \"Ya estaba archivado\"}. Una etiqueta puede interpolar ${result.*}. Un resultado sin entrada muestra el mensaje de éxito; una clave que el manejador nunca devuelve no se muestra nunca. No se admite junto a un diálogo de resultado ni con `operation: 'update'`." + helpText: "Mensaje de éxito por cada resultado del manejador, en JSON: cada clave es un valor `outcome` en snake_case que el manejador devuelve en su carga útil de éxito, y cada valor es la etiqueta que se muestra para ese resultado, p. ej., {\"archived\": \"Archivado\", \"already_archived\": \"Ya estaba archivado\"}. Una etiqueta puede interpolar {{ result.* }}. Un resultado sin entrada muestra el mensaje de éxito; una clave que el manejador nunca devuelve no se muestra nunca. No se admite junto a un diálogo de resultado ni con `operation: 'update'`." }, onSuccess: { label: "Al completarse", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index ec43f717095..f93a12ad28c 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -141,8 +141,8 @@ export const esESObjects: NonNullable = { description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.", successMessage: "Invitación enviada a {{ result.email }}", outcomeMessages: { - sent: "Invitación enviada a ${result.email}", - link_only: "Invitación creada para ${result.email}. El correo no está configurado en este servidor, así que no ha salido ningún mensaje. Usa Copiar enlace de invitación en la invitación para compartirla." + sent: "Invitación enviada a {{ result.email }}", + link_only: "Invitación creada para {{ result.email }}. El correo no está configurado en este servidor, así que no ha salido ningún mensaje. Usa Copiar enlace de invitación en la invitación para compartirla." }, params: { role: { @@ -581,8 +581,8 @@ export const esESObjects: NonNullable = { description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.", successMessage: "Invitación enviada a {{ result.email }}", outcomeMessages: { - sent: "Invitación enviada a ${result.email}", - link_only: "Invitación creada para ${result.email}. El correo no está configurado en este servidor, así que no ha salido ningún mensaje. Usa Copiar enlace de invitación en la invitación para compartirla." + sent: "Invitación enviada a {{ result.email }}", + link_only: "Invitación creada para {{ result.email }}. El correo no está configurado en este servidor, así que no ha salido ningún mensaje. Usa Copiar enlace de invitación en la invitación para compartirla." }, params: { role: { @@ -673,8 +673,8 @@ export const esESObjects: NonNullable = { description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.", successMessage: "Invitación enviada a {{ result.email }}", outcomeMessages: { - sent: "Invitación enviada a ${result.email}", - link_only: "Invitación creada para ${result.email}. El correo no está configurado en este servidor, así que no ha salido ningún mensaje. Usa Copiar enlace de invitación en la invitación para compartirla." + sent: "Invitación enviada a {{ result.email }}", + link_only: "Invitación creada para {{ result.email }}. El correo no está configurado en este servidor, así que no ha salido ningún mensaje. Usa Copiar enlace de invitación en la invitación para compartirla." }, params: { role: { @@ -700,10 +700,10 @@ export const esESObjects: NonNullable = { }, update_member_role: { label: "Cambiar rol", - description: "Elija el nuevo rol en la organización para ${record.user_id?.name ?? 'este miembro'}.", + description: "Elija el nuevo rol en la organización para {{ record.user_id.name | default:'este miembro' }}.", successMessage: "Rol del miembro actualizado", outcomeMessages: { - permission_set_granted: "Rol del miembro actualizado. El nuevo rol también concedió el conjunto de permisos ${result.grantedPermissionSet.label}." + permission_set_granted: "Rol del miembro actualizado. El nuevo rol también concedió el conjunto de permisos {{ result.grantedPermissionSet.label }}." }, params: { role: { @@ -721,7 +721,7 @@ export const esESObjects: NonNullable = { confirmText: "¿Hacer propietario de esta organización al miembro seleccionado? Obtendrá todos los privilegios de propietario, incluido eliminar la organización. Tú sigues siendo propietario; para dejar de serlo, usa «Cambiar rol» en tu propia membresía.", successMessage: "El miembro ahora es propietario", outcomeMessages: { - permission_set_granted: "El miembro ahora es propietario. El nuevo rol también concedió el conjunto de permisos ${result.grantedPermissionSet.label}." + permission_set_granted: "El miembro ahora es propietario. El nuevo rol también concedió el conjunto de permisos {{ result.grantedPermissionSet.label }}." } } } @@ -825,8 +825,8 @@ export const esESObjects: NonNullable = { description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.", successMessage: "Invitación enviada a {{ result.email }}", outcomeMessages: { - sent: "Invitación enviada a ${result.email}", - link_only: "Invitación creada para ${result.email}. El correo no está configurado en este servidor, así que no ha salido ningún mensaje. Usa Copiar enlace de invitación en la invitación para compartirla." + sent: "Invitación enviada a {{ result.email }}", + link_only: "Invitación creada para {{ result.email }}. El correo no está configurado en este servidor, así que no ha salido ningún mensaje. Usa Copiar enlace de invitación en la invitación para compartirla." }, params: { role: { @@ -848,11 +848,11 @@ export const esESObjects: NonNullable = { }, resend_invitation: { label: "Reenviar invitación", - description: "¿Reenviar la invitación a ${record.email}? Su plazo de vencimiento vuelve a empezar.", + description: "¿Reenviar la invitación a {{ record.email }}? Su plazo de vencimiento vuelve a empezar.", successMessage: "Invitación reenviada", outcomeMessages: { - sent: "Invitación reenviada a ${result.email}", - link_only: "Invitación renovada para ${result.email}. El correo no está configurado en este servidor, así que no ha salido ningún mensaje. Usa Copiar enlace de invitación en la invitación para compartirla." + sent: "Invitación reenviada a {{ result.email }}", + link_only: "Invitación renovada para {{ result.email }}. El correo no está configurado en este servidor, así que no ha salido ningún mensaje. Usa Copiar enlace de invitación en la invitación para compartirla." } }, copy_invitation_link: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index d485db6f16a..1817c75c3cd 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1825,7 +1825,7 @@ export const jaJPMetadataForms: NonNullable = }, outcomeMessages: { label: "結果別の成功メッセージ", - helpText: "ハンドラーの結果ごとの成功メッセージ(JSON で記述)。各キーはハンドラーが成功時のペイロードで返す snake_case の `outcome` 値、各値はその結果に表示するラベルです(例:{\"archived\": \"アーカイブしました\", \"already_archived\": \"すでにアーカイブ済みです\"})。ラベルには ${result.*} を埋め込めます。対応する項目がない結果には成功メッセージが表示され、ハンドラーが返さないキーは表示されることがありません。結果ダイアログや `operation: 'update'` とは併用できません。" + helpText: "ハンドラーの結果ごとの成功メッセージ(JSON で記述)。各キーはハンドラーが成功時のペイロードで返す snake_case の `outcome` 値、各値はその結果に表示するラベルです(例:{\"archived\": \"アーカイブしました\", \"already_archived\": \"すでにアーカイブ済みです\"})。ラベルには {{ result.* }} を埋め込めます。対応する項目がない結果には成功メッセージが表示され、ハンドラーが返さないキーは表示されることがありません。結果ダイアログや `operation: 'update'` とは併用できません。" }, onSuccess: { label: "成功後の遷移", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 450e60ea621..21e7fc35d2b 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -141,8 +141,8 @@ export const jaJPObjects: NonNullable = { description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", successMessage: "{{ result.email }} に招待を送信しました", outcomeMessages: { - sent: "${result.email} に招待を送信しました", - link_only: "${result.email} の招待を作成しました。このサーバーではメールが設定されていないため、メールは配信されていません。招待の「招待リンクをコピー」で共有してください。" + sent: "{{ result.email }} に招待を送信しました", + link_only: "{{ result.email }} の招待を作成しました。このサーバーではメールが設定されていないため、メールは配信されていません。招待の「招待リンクをコピー」で共有してください。" }, params: { role: { @@ -581,8 +581,8 @@ export const jaJPObjects: NonNullable = { description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", successMessage: "{{ result.email }} に招待を送信しました", outcomeMessages: { - sent: "${result.email} に招待を送信しました", - link_only: "${result.email} の招待を作成しました。このサーバーではメールが設定されていないため、メールは配信されていません。招待の「招待リンクをコピー」で共有してください。" + sent: "{{ result.email }} に招待を送信しました", + link_only: "{{ result.email }} の招待を作成しました。このサーバーではメールが設定されていないため、メールは配信されていません。招待の「招待リンクをコピー」で共有してください。" }, params: { role: { @@ -673,8 +673,8 @@ export const jaJPObjects: NonNullable = { description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", successMessage: "{{ result.email }} に招待を送信しました", outcomeMessages: { - sent: "${result.email} に招待を送信しました", - link_only: "${result.email} の招待を作成しました。このサーバーではメールが設定されていないため、メールは配信されていません。招待の「招待リンクをコピー」で共有してください。" + sent: "{{ result.email }} に招待を送信しました", + link_only: "{{ result.email }} の招待を作成しました。このサーバーではメールが設定されていないため、メールは配信されていません。招待の「招待リンクをコピー」で共有してください。" }, params: { role: { @@ -700,10 +700,10 @@ export const jaJPObjects: NonNullable = { }, update_member_role: { label: "ロールを変更", - description: "${record.user_id?.name ?? 'このメンバー'} の新しい組織ロールを選択してください。", + description: "{{ record.user_id.name | default:'このメンバー' }} の新しい組織ロールを選択してください。", successMessage: "メンバーロールを更新しました", outcomeMessages: { - permission_set_granted: "メンバーのロールを更新しました。新しいロールにより権限セット ${result.grantedPermissionSet.label} も付与されました。" + permission_set_granted: "メンバーのロールを更新しました。新しいロールにより権限セット {{ result.grantedPermissionSet.label }} も付与されました。" }, params: { role: { @@ -721,7 +721,7 @@ export const jaJPObjects: NonNullable = { confirmText: "選択したメンバーをこの組織の所有者にしますか?このメンバーには、組織の削除を含む所有者のすべての権限が付与されます。あなたも引き続き所有者です。退く場合は、自分のメンバーシップで「ロールを変更」を使用してください。", successMessage: "メンバーが所有者になりました", outcomeMessages: { - permission_set_granted: "このメンバーはオーナーになりました。新しいロールにより権限セット ${result.grantedPermissionSet.label} も付与されました。" + permission_set_granted: "このメンバーはオーナーになりました。新しいロールにより権限セット {{ result.grantedPermissionSet.label }} も付与されました。" } } } @@ -825,8 +825,8 @@ export const jaJPObjects: NonNullable = { description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", successMessage: "{{ result.email }} に招待を送信しました", outcomeMessages: { - sent: "${result.email} に招待を送信しました", - link_only: "${result.email} の招待を作成しました。このサーバーではメールが設定されていないため、メールは配信されていません。招待の「招待リンクをコピー」で共有してください。" + sent: "{{ result.email }} に招待を送信しました", + link_only: "{{ result.email }} の招待を作成しました。このサーバーではメールが設定されていないため、メールは配信されていません。招待の「招待リンクをコピー」で共有してください。" }, params: { role: { @@ -848,11 +848,11 @@ export const jaJPObjects: NonNullable = { }, resend_invitation: { label: "招待を再送", - description: "${record.email} に招待を再送しますか?有効期限は改めて始まります。", + description: "{{ record.email }} に招待を再送しますか?有効期限は改めて始まります。", successMessage: "招待を再送しました", outcomeMessages: { - sent: "${result.email} に招待を再送しました", - link_only: "${result.email} の招待を更新しました。このサーバーではメールが設定されていないため、メールは配信されていません。招待の「招待リンクをコピー」で共有してください。" + sent: "{{ result.email }} に招待を再送しました", + link_only: "{{ result.email }} の招待を更新しました。このサーバーではメールが設定されていないため、メールは配信されていません。招待の「招待リンクをコピー」で共有してください。" } }, copy_invitation_link: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 49682fb7368..cf1a74913aa 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1825,7 +1825,7 @@ export const zhCNMetadataForms: NonNullable = }, outcomeMessages: { label: "按结果的成功消息", - helpText: "按处理程序结果显示的成功消息,以 JSON 填写:每个键是处理程序在成功返回中给出的 snake_case `outcome` 值,每个值是该结果显示的文字,例如 {\"archived\": \"已归档\", \"already_archived\": \"此前已归档\"}。文字中可插入 ${result.*}。没有对应条目的结果显示成功消息;处理程序从不返回的键永远不会显示。不能与结果对话框或 `operation: 'update'` 同时使用。" + helpText: "按处理程序结果显示的成功消息,以 JSON 填写:每个键是处理程序在成功返回中给出的 snake_case `outcome` 值,每个值是该结果显示的文字,例如 {\"archived\": \"已归档\", \"already_archived\": \"此前已归档\"}。文字中可插入 {{ result.* }}。没有对应条目的结果显示成功消息;处理程序从不返回的键永远不会显示。不能与结果对话框或 `operation: 'update'` 同时使用。" }, onSuccess: { label: "成功后跳转", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index ec282359b14..83a937c0f61 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -141,8 +141,8 @@ export const zhCNObjects: NonNullable = { description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", successMessage: "已向 {{ result.email }} 发送邀请", outcomeMessages: { - sent: "已向 ${result.email} 发送邀请", - link_only: "已为 ${result.email} 创建邀请。此服务器未配置邮件,因此没有发出任何邮件。请在该邀请上使用“复制邀请链接”分享给对方。" + sent: "已向 {{ result.email }} 发送邀请", + link_only: "已为 {{ result.email }} 创建邀请。此服务器未配置邮件,因此没有发出任何邮件。请在该邀请上使用“复制邀请链接”分享给对方。" }, params: { role: { @@ -581,8 +581,8 @@ export const zhCNObjects: NonNullable = { description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", successMessage: "已向 {{ result.email }} 发送邀请", outcomeMessages: { - sent: "已向 ${result.email} 发送邀请", - link_only: "已为 ${result.email} 创建邀请。此服务器未配置邮件,因此没有发出任何邮件。请在该邀请上使用“复制邀请链接”分享给对方。" + sent: "已向 {{ result.email }} 发送邀请", + link_only: "已为 {{ result.email }} 创建邀请。此服务器未配置邮件,因此没有发出任何邮件。请在该邀请上使用“复制邀请链接”分享给对方。" }, params: { role: { @@ -673,8 +673,8 @@ export const zhCNObjects: NonNullable = { description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", successMessage: "已向 {{ result.email }} 发送邀请", outcomeMessages: { - sent: "已向 ${result.email} 发送邀请", - link_only: "已为 ${result.email} 创建邀请。此服务器未配置邮件,因此没有发出任何邮件。请在该邀请上使用“复制邀请链接”分享给对方。" + sent: "已向 {{ result.email }} 发送邀请", + link_only: "已为 {{ result.email }} 创建邀请。此服务器未配置邮件,因此没有发出任何邮件。请在该邀请上使用“复制邀请链接”分享给对方。" }, params: { role: { @@ -700,10 +700,10 @@ export const zhCNObjects: NonNullable = { }, update_member_role: { label: "更改角色", - description: "为 ${record.user_id?.name ?? '该成员'} 选择新的组织角色。", + description: "为 {{ record.user_id.name | default:'该成员' }} 选择新的组织角色。", successMessage: "成员角色已更新", outcomeMessages: { - permission_set_granted: "成员角色已更新。新角色还授予了权限集 ${result.grantedPermissionSet.label}。" + permission_set_granted: "成员角色已更新。新角色还授予了权限集 {{ result.grantedPermissionSet.label }}。" }, params: { role: { @@ -721,7 +721,7 @@ export const zhCNObjects: NonNullable = { confirmText: "确定要将所选成员设为该组织的所有者吗?对方将获得所有者的全部权限,包括删除该组织。您仍是所有者;如需卸任,请在您自己的成员记录上使用“更改角色”。", successMessage: "该成员现已成为所有者", outcomeMessages: { - permission_set_granted: "该成员现已成为所有者。新角色还授予了权限集 ${result.grantedPermissionSet.label}。" + permission_set_granted: "该成员现已成为所有者。新角色还授予了权限集 {{ result.grantedPermissionSet.label }}。" } } } @@ -825,8 +825,8 @@ export const zhCNObjects: NonNullable = { description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", successMessage: "已向 {{ result.email }} 发送邀请", outcomeMessages: { - sent: "已向 ${result.email} 发送邀请", - link_only: "已为 ${result.email} 创建邀请。此服务器未配置邮件,因此没有发出任何邮件。请在该邀请上使用“复制邀请链接”分享给对方。" + sent: "已向 {{ result.email }} 发送邀请", + link_only: "已为 {{ result.email }} 创建邀请。此服务器未配置邮件,因此没有发出任何邮件。请在该邀请上使用“复制邀请链接”分享给对方。" }, params: { role: { @@ -848,11 +848,11 @@ export const zhCNObjects: NonNullable = { }, resend_invitation: { label: "重新发送邀请", - description: "要重新发送邀请给 ${record.email} 吗?邀请的有效期将重新计算。", + description: "要重新发送邀请给 {{ record.email }} 吗?邀请的有效期将重新计算。", successMessage: "邀请已重新发送", outcomeMessages: { - sent: "已向 ${result.email} 重新发送邀请", - link_only: "已为 ${result.email} 续期邀请。此服务器未配置邮件,因此没有发出任何邮件。请在该邀请上使用“复制邀请链接”分享给对方。" + sent: "已向 {{ result.email }} 重新发送邀请", + link_only: "已为 {{ result.email }} 续期邀请。此服务器未配置邮件,因此没有发出任何邮件。请在该邀请上使用“复制邀请链接”分享给对方。" } }, copy_invitation_link: { diff --git a/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts b/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts index 32eb06fede9..b405e8378e4 100644 --- a/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts +++ b/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts @@ -34,6 +34,29 @@ import { SysAccount } from './sys-account.object.js'; import { SysOrganization } from './sys-organization.object.js'; import { SysSsoProvider } from './sys-sso-provider.object.js'; import { SysTeamMember } from './sys-team-member.object.js'; +import { templateSlotSource } from '@objectstack/spec/automation'; + +// [#27948] An action's `description`, each param's `helpText` and each +// `outcomeMessages` entry are `template` slots: the parsed object carries the +// `{ dialect: 'template', source }` envelope, so the copy is read as written +// through the template judge's one reader, `templateSlotSource` (a locale map +// is kept as it is). +function copyAsWritten(declared: T): T { + const a = declared as Record | undefined; + if (!a) return declared; + const text = (v: unknown) => templateSlotSource(v) ?? v; + return { + ...a, + ...(a.description !== undefined ? { description: text(a.description) } : {}), + ...(Array.isArray(a.params) + ? { params: a.params.map((p: any) => (p && p.helpText !== undefined ? { ...p, helpText: text(p.helpText) } : p)) } + : {}), + ...(a.outcomeMessages && typeof a.outcomeMessages === 'object' + ? { outcomeMessages: Object.fromEntries(Object.entries(a.outcomeMessages).map(([k, v]) => [k, text(v)])) } + : {}), + } as T; +} + const OBJECTS = [ ['sys_user', SysUser], @@ -45,7 +68,7 @@ const OBJECTS = [ ['sys_team_member', SysTeamMember], ] as const; -const actionsOf = (obj: any): any[] => (obj?.actions ?? []) as any[]; +const actionsOf = (obj: any): any[] => ((obj?.actions ?? []) as any[]).map(copyAsWritten); const collects = (a: any) => Array.isArray(a.params) && a.params.length > 0; const byName = (obj: any, name: string) => { diff --git a/packages/platform-objects/src/identity/invitation-delivery-copy.test.ts b/packages/platform-objects/src/identity/invitation-delivery-copy.test.ts index f060c234c69..b7d782fa077 100644 --- a/packages/platform-objects/src/identity/invitation-delivery-copy.test.ts +++ b/packages/platform-objects/src/identity/invitation-delivery-copy.test.ts @@ -31,13 +31,36 @@ import { SysInvitation } from './sys-invitation.object.js'; import { SysMember } from './sys-member.object.js'; import { SysOrganization } from './sys-organization.object.js'; import { SysUser } from './sys-user.object.js'; +import { templateSlotSource } from '@objectstack/spec/automation'; + +// [#27948] An action's `description`, each param's `helpText` and each +// `outcomeMessages` entry are `template` slots: the parsed object carries the +// `{ dialect: 'template', source }` envelope, so the copy is read as written +// through the template judge's one reader, `templateSlotSource` (a locale map +// is kept as it is). +function copyAsWritten(declared: T): T { + const a = declared as Record | undefined; + if (!a) return declared; + const text = (v: unknown) => templateSlotSource(v) ?? v; + return { + ...a, + ...(a.description !== undefined ? { description: text(a.description) } : {}), + ...(Array.isArray(a.params) + ? { params: a.params.map((p: any) => (p && p.helpText !== undefined ? { ...p, helpText: text(p.helpText) } : p)) } + : {}), + ...(a.outcomeMessages && typeof a.outcomeMessages === 'object' + ? { outcomeMessages: Object.fromEntries(Object.entries(a.outcomeMessages).map(([k, v]) => [k, text(v)])) } + : {}), + } as T; +} + type Action = Record; function action(object: unknown, name: string): Action { const found = ((object as { actions?: Action[] }).actions ?? []).find((a) => a.name === name); if (!found) throw new Error(`no action ${name}`); - return found; + return copyAsWritten(found); } const MIRRORS: Array<[string, unknown]> = [ @@ -56,8 +79,8 @@ const TRANSLATED = [ /** Any spelling of "sent" a `link_only` copy must not carry. */ const SAYS_SENT = /\b(re)?sent\b/i; -/** The interpolation `outcomeMessages` copy reads the invitee's address through. */ -const INVITEE = '${result.email}'; +/** The `outcomeMessages` hole the copy reads the invitee's address through. */ +const INVITEE = '{{ result.email }}'; const bundleAction = (bundle: unknown, object: string, name: string): Action | undefined => (bundle as Record }>)?.[object]?._actions?.[name]; diff --git a/packages/platform-objects/src/identity/invitation-screens-copy.test.ts b/packages/platform-objects/src/identity/invitation-screens-copy.test.ts index 100412ae3bf..3fa455dd5a0 100644 --- a/packages/platform-objects/src/identity/invitation-screens-copy.test.ts +++ b/packages/platform-objects/src/identity/invitation-screens-copy.test.ts @@ -24,12 +24,35 @@ import { SysInvitation } from './sys-invitation.object.js'; import { SysMember } from './sys-member.object.js'; import { SysOrganization } from './sys-organization.object.js'; import { SysUser } from './sys-user.object.js'; +import { templateSlotSource } from '@objectstack/spec/automation'; + +// [#27948] An action's `description`, each param's `helpText` and each +// `outcomeMessages` entry are `template` slots: the parsed object carries the +// `{ dialect: 'template', source }` envelope, so the copy is read as written +// through the template judge's one reader, `templateSlotSource` (a locale map +// is kept as it is). +function copyAsWritten(declared: T): T { + const a = declared as Record | undefined; + if (!a) return declared; + const text = (v: unknown) => templateSlotSource(v) ?? v; + return { + ...a, + ...(a.description !== undefined ? { description: text(a.description) } : {}), + ...(Array.isArray(a.params) + ? { params: a.params.map((p: any) => (p && p.helpText !== undefined ? { ...p, helpText: text(p.helpText) } : p)) } + : {}), + ...(a.outcomeMessages && typeof a.outcomeMessages === 'object' + ? { outcomeMessages: Object.fromEntries(Object.entries(a.outcomeMessages).map(([k, v]) => [k, text(v)])) } + : {}), + } as T; +} + type Action = Record; type Param = Record; function action(object: unknown, name: string): Action { - const found = ((object as { actions?: Action[] }).actions ?? []).find((a) => a.name === name); + const found = copyAsWritten(((object as { actions?: Action[] }).actions ?? []).find((a) => a.name === name)); if (!found) throw new Error(`no action ${name}`); return found; } @@ -97,7 +120,9 @@ describe('row 8 — one role help text, a line per role', () => { // (`ActionParamDialog.invitationScreens-27521.test.tsx`) runs this exact // text through a real `ActionRunner`, and reads "this member" for a row // that does not expand `user_id`. A reworded declaration reds here first. - expect(changeRole.description).toBe("Choose the new organization role for ${record.user_id?.name ?? 'this member'}."); + // Written with `{{ }}` since #27948: the `default` formatter reads "this + // member" for a row that does not expand `user_id`. + expect(changeRole.description).toBe("Choose the new organization role for {{ record.user_id.name | default:'this member' }}."); expect(changeRole.confirmText).toBeUndefined(); }); }); @@ -106,7 +131,7 @@ describe('row 9 — Resend Invitation is a confirmation', () => { const resend = action(SysInvitation, 'resend_invitation'); it('asks its question in `description`, naming the invitee', () => { - expect(resend.description).toContain('${record.email}'); + expect(resend.description).toContain('{{ record.email }}'); // The spec refuses `confirmText` beside `params` on a registered action. expect(resend.confirmText).toBeUndefined(); }); @@ -146,8 +171,8 @@ describe('the new copy in the translated bundles', () => { it.each(TRANSLATED)('%s keeps the holes the console fills', (_locale, bundle) => { const byPath = new Map(leaves(bundle)); - expect(byPath.get('sys_invitation.resend_invitation.description')).toContain('${record.email}'); - expect(byPath.get('sys_member.update_member_role.description')).toMatch(/\$\{record\.user_id\?\.name \?\? '[^']+'\}/); + expect(byPath.get('sys_invitation.resend_invitation.description')).toContain('{{ record.email }}'); + expect(byPath.get('sys_member.update_member_role.description')).toMatch(/\{\{ record\.user_id\.name \| default:'[^']+' \}\}/); }); it.each(TRANSLATED)('%s keeps one help line per role', (_locale, bundle) => { diff --git a/packages/platform-objects/src/identity/invite-outcome-messages.ts b/packages/platform-objects/src/identity/invite-outcome-messages.ts index d481fc3a3cf..2fedb992b1b 100644 --- a/packages/platform-objects/src/identity/invite-outcome-messages.ts +++ b/packages/platform-objects/src/identity/invite-outcome-messages.ts @@ -12,22 +12,22 @@ * for `sys_invitation.resend_invitation`. ⛔ A `link_only` message never says * the invitation was sent: nothing left the server. * - * `${result.email}` is the `outcomeMessages` interpolation (the scope - * `onSuccess.navigate` declares), the invitee's address on the row. + * `{{ result.email }}` is an `outcomeMessages` `template` hole over `result`, + * the payload invite-member answers (#27948): the invitee's address on the row. */ /** `invite_user.outcomeMessages`, on all four mirrors. */ export const INVITE_USER_OUTCOME_MESSAGES = { - sent: 'Invitation sent to ${result.email}', + sent: 'Invitation sent to {{ result.email }}', link_only: - 'Invitation created for ${result.email}. Mail is not configured on this server, so no email went out. ' + 'Invitation created for {{ result.email }}. Mail is not configured on this server, so no email went out. ' + 'Use Copy Invite Link on the invitation to share it.', } as const; /** `sys_invitation.resend_invitation.outcomeMessages`. */ export const RESEND_INVITATION_OUTCOME_MESSAGES = { - sent: 'Invitation resent to ${result.email}', + sent: 'Invitation resent to {{ result.email }}', link_only: - 'Invitation renewed for ${result.email}. Mail is not configured on this server, so no email went out. ' + 'Invitation renewed for {{ result.email }}. Mail is not configured on this server, so no email went out. ' + 'Use Copy Invite Link on the invitation to share it.', } as const; diff --git a/packages/platform-objects/src/identity/invite-user-action-copy.test.ts b/packages/platform-objects/src/identity/invite-user-action-copy.test.ts index 0df4692be07..dae7f00ae8b 100644 --- a/packages/platform-objects/src/identity/invite-user-action-copy.test.ts +++ b/packages/platform-objects/src/identity/invite-user-action-copy.test.ts @@ -34,6 +34,28 @@ import { SysMember } from './sys-member.object.js'; import { SysOrganization } from './sys-organization.object.js'; import { SysUser } from './sys-user.object.js'; +// [#27948] An action's `description`, each param's `helpText` and each +// `outcomeMessages` entry are `template` slots: the parsed object carries the +// `{ dialect: 'template', source }` envelope, so the copy is read as written +// through the template judge's one reader, `templateSlotSource` (a locale map +// is kept as it is). +function copyAsWritten(declared: T): T { + const a = declared as Record | undefined; + if (!a) return declared; + const text = (v: unknown) => templateSlotSource(v) ?? v; + return { + ...a, + ...(a.description !== undefined ? { description: text(a.description) } : {}), + ...(Array.isArray(a.params) + ? { params: a.params.map((p: any) => (p && p.helpText !== undefined ? { ...p, helpText: text(p.helpText) } : p)) } + : {}), + ...(a.outcomeMessages && typeof a.outcomeMessages === 'object' + ? { outcomeMessages: Object.fromEntries(Object.entries(a.outcomeMessages).map(([k, v]) => [k, text(v)])) } + : {}), + } as T; +} + + interface CopyAction { name?: string; description?: unknown; @@ -64,7 +86,7 @@ function invite(object: unknown): CopyAction { (a) => a.name === 'invite_user', ); expect(found, 'invite_user is declared').toBeDefined(); - return { ...found, successMessage: templateSlotSource(found?.successMessage) } as CopyAction; + return { ...copyAsWritten(found), successMessage: templateSlotSource(found?.successMessage) } as CopyAction; } const bundleNode = (bundle: unknown, object: string): CopyAction | undefined => diff --git a/packages/platform-objects/src/identity/oauth-register-form-types.test.ts b/packages/platform-objects/src/identity/oauth-register-form-types.test.ts index 6759d975a66..e4edf99085c 100644 --- a/packages/platform-objects/src/identity/oauth-register-form-types.test.ts +++ b/packages/platform-objects/src/identity/oauth-register-form-types.test.ts @@ -30,10 +30,33 @@ import { enObjects } from '../apps/translations/en.objects.generated.js'; import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js'; import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js'; import { esESObjects } from '../apps/translations/es-ES.objects.generated.js'; +import { templateSlotSource } from '@objectstack/spec/automation'; + +// [#27948] An action's `description`, each param's `helpText` and each +// `outcomeMessages` entry are `template` slots: the parsed object carries the +// `{ dialect: 'template', source }` envelope, so the copy is read as written +// through the template judge's one reader, `templateSlotSource` (a locale map +// is kept as it is). +function copyAsWritten(declared: T): T { + const a = declared as Record | undefined; + if (!a) return declared; + const text = (v: unknown) => templateSlotSource(v) ?? v; + return { + ...a, + ...(a.description !== undefined ? { description: text(a.description) } : {}), + ...(Array.isArray(a.params) + ? { params: a.params.map((p: any) => (p && p.helpText !== undefined ? { ...p, helpText: text(p.helpText) } : p)) } + : {}), + ...(a.outcomeMessages && typeof a.outcomeMessages === 'object' + ? { outcomeMessages: Object.fromEntries(Object.entries(a.outcomeMessages).map(([k, v]) => [k, text(v)])) } + : {}), + } as T; +} + type Param = { name?: string; type?: string; required?: boolean; defaultValue?: unknown; helpText?: string; options?: unknown[] }; -const register = (SysOauthApplication.actions ?? []).find((a) => a.name === 'create_oauth_application'); +const register = copyAsWritten((SysOauthApplication.actions ?? []).find((a) => a.name === 'create_oauth_application')); const param = (name: string): Param | undefined => ((register?.params ?? []) as Param[]).find((p) => p.name === name); const optionValues = (options: unknown[] | undefined): unknown[] => diff --git a/packages/platform-objects/src/identity/rotate-client-secret-copy.test.ts b/packages/platform-objects/src/identity/rotate-client-secret-copy.test.ts index 9f98e12764e..cc970d159e5 100644 --- a/packages/platform-objects/src/identity/rotate-client-secret-copy.test.ts +++ b/packages/platform-objects/src/identity/rotate-client-secret-copy.test.ts @@ -27,8 +27,31 @@ import { enObjects } from '../apps/translations/en.objects.generated.js'; import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js'; import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js'; import { esESObjects } from '../apps/translations/es-ES.objects.generated.js'; +import { templateSlotSource } from '@objectstack/spec/automation'; -const rotate = (SysOauthApplication.actions ?? []).find((a) => a.name === 'rotate_client_secret'); +// [#27948] An action's `description`, each param's `helpText` and each +// `outcomeMessages` entry are `template` slots: the parsed object carries the +// `{ dialect: 'template', source }` envelope, so the copy is read as written +// through the template judge's one reader, `templateSlotSource` (a locale map +// is kept as it is). +function copyAsWritten(declared: T): T { + const a = declared as Record | undefined; + if (!a) return declared; + const text = (v: unknown) => templateSlotSource(v) ?? v; + return { + ...a, + ...(a.description !== undefined ? { description: text(a.description) } : {}), + ...(Array.isArray(a.params) + ? { params: a.params.map((p: any) => (p && p.helpText !== undefined ? { ...p, helpText: text(p.helpText) } : p)) } + : {}), + ...(a.outcomeMessages && typeof a.outcomeMessages === 'object' + ? { outcomeMessages: Object.fromEntries(Object.entries(a.outcomeMessages).map(([k, v]) => [k, text(v)])) } + : {}), + } as T; +} + + +const rotate = copyAsWritten((SysOauthApplication.actions ?? []).find((a) => a.name === 'rotate_client_secret')); const node = (bundle: any) => bundle?.sys_oauth_application?._actions?.rotate_client_secret; /** Any promise of a window during which the previous secret still works. */ diff --git a/packages/platform-objects/src/identity/sys-api-key-create-expiry.test.ts b/packages/platform-objects/src/identity/sys-api-key-create-expiry.test.ts index f838940e8f1..937b526313f 100644 --- a/packages/platform-objects/src/identity/sys-api-key-create-expiry.test.ts +++ b/packages/platform-objects/src/identity/sys-api-key-create-expiry.test.ts @@ -21,6 +21,29 @@ // named, never clamped — is `packages/runtime/src/api-key-lifecycle.pin.test.ts`. import { describe, expect, it } from 'vitest'; import { SysApiKey } from './sys-api-key.object.js'; +import { templateSlotSource } from '@objectstack/spec/automation'; + +// [#27948] An action's `description`, each param's `helpText` and each +// `outcomeMessages` entry are `template` slots: the parsed object carries the +// `{ dialect: 'template', source }` envelope, so the copy is read as written +// through the template judge's one reader, `templateSlotSource` (a locale map +// is kept as it is). +function copyAsWritten(declared: T): T { + const a = declared as Record | undefined; + if (!a) return declared; + const text = (v: unknown) => templateSlotSource(v) ?? v; + return { + ...a, + ...(a.description !== undefined ? { description: text(a.description) } : {}), + ...(Array.isArray(a.params) + ? { params: a.params.map((p: any) => (p && p.helpText !== undefined ? { ...p, helpText: text(p.helpText) } : p)) } + : {}), + ...(a.outcomeMessages && typeof a.outcomeMessages === 'object' + ? { outcomeMessages: Object.fromEntries(Object.entries(a.outcomeMessages).map(([k, v]) => [k, text(v)])) } + : {}), + } as T; +} + interface DeclaredParam { field?: string; @@ -30,7 +53,7 @@ interface DeclaredParam { helpText?: string; } -const createKey = (SysApiKey.actions ?? []).find((a) => a.name === 'create_api_key') as +const createKey = copyAsWritten((SysApiKey.actions ?? []).find((a) => a.name === 'create_api_key')) as | { params?: DeclaredParam[]; resultDialog?: { fields?: Array<{ path: string }> } } | undefined; diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index 90c10b6ea6b..2c846c4bbb2 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -145,7 +145,7 @@ export const SysInvitation = ObjectSchema.create({ // carried over from the row. invite-member renews a pending invitation // with its stored role whatever role is sent, and another email would // invite a different person, so neither is the user's to edit here. - description: 'Resend the invitation to ${record.email}? Its expiry date starts over.', + description: 'Resend the invitation to {{ record.email }}? Its expiry date starts over.', icon: 'send', variant: 'secondary', locations: ['list_item'], diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 10eb07e23b9..8407a7f5b83 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -218,8 +218,9 @@ export const SysMember = ObjectSchema.create({ label: 'Change Role', // Names the member: a list row carries `user_id` expanded to the user // record wherever the list shows that column (the organization page's - // Members list); a row without it reads "this member". - description: "Choose the new organization role for ${record.user_id?.name ?? 'this member'}.", + // Members list); a row without it reads "this member" (the `default` + // formatter, which also covers an empty name). + description: "Choose the new organization role for {{ record.user_id.name | default:'this member' }}.", icon: 'shield', mode: 'edit', locations: ['list_item'], @@ -235,7 +236,7 @@ export const SysMember = ObjectSchema.create({ // the member row (plugin-auth `role-change-granted-set.ts`). outcomeMessages: { permission_set_granted: - 'Member role updated. The new role also granted the permission set ${result.grantedPermissionSet.label}.', + 'Member role updated. The new role also granted the permission set {{ result.grantedPermissionSet.label }}.', }, refreshAfter: true, params: [ @@ -307,7 +308,7 @@ export const SysMember = ObjectSchema.create({ // [#27862] The same answer as `update_member_role` (the same route). outcomeMessages: { permission_set_granted: - 'Member is now an owner. The new role also granted the permission set ${result.grantedPermissionSet.label}.', + 'Member is now an owner. The new role also granted the permission set {{ result.grantedPermissionSet.label }}.', }, refreshAfter: true, }, diff --git a/packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts b/packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts index 331893a19a3..8c3fca0f4e0 100644 --- a/packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts +++ b/packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts @@ -23,6 +23,28 @@ import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js'; import { esESObjects } from '../apps/translations/es-ES.objects.generated.js'; import { SysMember } from './sys-member.object.js'; +// [#27948] An action's `description`, each param's `helpText` and each +// `outcomeMessages` entry are `template` slots: the parsed object carries the +// `{ dialect: 'template', source }` envelope, so the copy is read as written +// through the template judge's one reader, `templateSlotSource` (a locale map +// is kept as it is). +function copyAsWritten(declared: T): T { + const a = declared as Record | undefined; + if (!a) return declared; + const text = (v: unknown) => templateSlotSource(v) ?? v; + return { + ...a, + ...(a.description !== undefined ? { description: text(a.description) } : {}), + ...(Array.isArray(a.params) + ? { params: a.params.map((p: any) => (p && p.helpText !== undefined ? { ...p, helpText: text(p.helpText) } : p)) } + : {}), + ...(a.outcomeMessages && typeof a.outcomeMessages === 'object' + ? { outcomeMessages: Object.fromEntries(Object.entries(a.outcomeMessages).map(([k, v]) => [k, text(v)])) } + : {}), + } as T; +} + + interface CopyAction { name?: string; label?: string; @@ -43,7 +65,7 @@ function declared(name: string): CopyAction { const found = ((SysMember as { actions?: CopyAction[] }).actions ?? []).find((a) => a.name === name); expect(found, `${name} is declared`).toBeDefined(); return { - ...found, + ...copyAsWritten(found), confirmText: templateSlotSource(found?.confirmText), successMessage: templateSlotSource(found?.successMessage), } as CopyAction; diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.object.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.object.test.ts index fa7b5b3d785..08356a048b3 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.object.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.object.test.ts @@ -22,6 +22,7 @@ */ import { describe, it, expect } from 'vitest'; +import { templateSlotSource } from '@objectstack/spec/automation'; import { SysApprovalRequest } from './sys-approval-request.object.js'; const actions = (SysApprovalRequest as any).actions as any[]; @@ -134,18 +135,22 @@ describe('sys_approval_request declared actions', () => { it('the confirm question rides `description`, not `confirmText`, so one decision opens one dialog (#7278)', () => { const reject = byName('approval_reject'); expect(reject.confirmText).toBeUndefined(); - expect(reject.description).toBe( + // `description` is a `template` slot (#27948): the parsed value is the + // envelope, so the sentence is read through the slot's one reader. + const rejectText = templateSlotSource(reject.description); + expect(rejectText).toBe( 'Reject this request? A rejection is final for every approver.', ); // the finality warning is the half that must survive the move - expect(reject.description).toContain('final for every approver'); + expect(rejectText).toContain('final for every approver'); const recall = byName('approval_recall'); expect(recall.confirmText).toBeUndefined(); - expect(recall.description).toBe( + const recallText = templateSlotSource(recall.description); + expect(recallText).toBe( 'Recall this request? Approvers can no longer act on it and the record is unlocked.', ); - expect(recall.description).toContain('can no longer act on it'); + expect(recallText).toContain('can no longer act on it'); }); it('no declared action pairs `confirmText` with `params` (#7278 ruling, object-wide)', () => { diff --git a/packages/plugins/plugin-approvals/src/translations/approve-decision-question.test.ts b/packages/plugins/plugin-approvals/src/translations/approve-decision-question.test.ts index 304a49c4edb..ffb8d755c81 100644 --- a/packages/plugins/plugin-approvals/src/translations/approve-decision-question.test.ts +++ b/packages/plugins/plugin-approvals/src/translations/approve-decision-question.test.ts @@ -15,6 +15,7 @@ // that stopped being consulted shows up as English in the zh-CN case. import { describe, it, expect } from 'vitest'; +import { templateSlotSource } from '@objectstack/spec/automation'; import { translateMetadataDocument } from '@objectstack/spec/system'; import { ApprovalsTranslations } from './index.js'; import { enObjects } from './en.objects.generated.js'; @@ -31,7 +32,10 @@ interface CopyAction { const action = (doc: unknown, name: string): CopyAction => { const found = ((doc as { actions?: CopyAction[] }).actions ?? []).find((a) => a.name === name); expect(found, `${name} is declared`).toBeDefined(); - return found as CopyAction; + // `description` is a `template` slot (#27948): a parsed object carries the + // envelope, a translated one the bundle's string. Both read as the sentence. + const copy = found as CopyAction; + return { ...copy, description: templateSlotSource(copy.description) ?? copy.description }; }; const bundleDescription = (bundle: unknown, name: string): unknown => diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 25c9c9afa61..9473c60d082 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -692,7 +692,30 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ 'ui/component.zod.ts:ActionIconPropsSchema.successMessage', 'ui/component.zod.ts:actionContainerMemberShape.successMessage', ], - note: 'The platform\'s own sentences (`sys_invitation` / `sys_member` / `sys_organization` / `sys_user` `invite_user`, the nine `sys_approval_request` decisions) were converted from `${result.*}` to `{{ result.* }}` on the same change. `outcomeMessages` is NOT this row: it stays a `${result.*}` string. ⛔ NOT MEASURED HERE: the runner was read at the pin, not run, and the `-log` half of `fail-soft-log`.', + note: 'The platform\'s own sentences (`sys_invitation` / `sys_member` / `sys_organization` / `sys_user` `invite_user`, the nine `sys_approval_request` decisions) were converted from `${result.*}` to `{{ result.* }}` on the same change. `outcomeMessages` is NOT this row: it is `template-action-outcome-message`. ⛔ NOT MEASURED HERE: the runner was read at the pin, not run, and the `-log` half of `fail-soft-log`.', + }, + // #27948 (ruling A, step 2): the param dialog's copy (`description`, each param's `helpText`) + // and each `outcomeMessages` entry joined the family, over the roots of the key each stands beside. + { + id: 'template-action-dialog-copy', + summary: 'action param dialog copy (Action.description, ActionParam.helpText) — `{{ 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 `actionConfirmTemplateRoots(action)` (`record`; a registered action\'s `params` is the input list, which binds nothing); `helpText` takes no inline locale map. EVALUATOR: console (objectui) `ActionRunner` hands the param-collection handler the action and params with each envelope reduced to its text (`dialogCopyOf`) and an `interpolate` (`dialogCopyFiller`) that fills a `{{ }}` sentence through `fillTemplateHoles` → @objectstack/formula `templateEngine.evaluate` over `record` and static `params`, read in the `ui/` tree (`ui/packages/core/src/actions/ActionRunner.ts`); a sentence with no `{{ }}` hole keeps the transitional `${…}` reading, so stored copy written before the migration reads as before', + covers: [ + 'ui/action.zod.ts:actionObject.description', + 'ui/action.zod.ts:ActionParamSchema.helpText', + ], + note: 'Refused at the doors, not at the parse: a `${…}` sentence parsed while the keys were plain strings, so a stored one keeps loading until its next save. ⛔ NOT MEASURED HERE: the runner was read in the `ui/` tree\'s pins, not run against a server, and the `-log` half of `fail-soft-log`.', + }, + { + id: 'template-action-outcome-message', + summary: 'action per-outcome success toast (Action.outcomeMessages entries) — `{{ 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-success-message`, per entry, at `outcomeMessages.`, over `ACTION_SUCCESS_TEMPLATE_ROOTS` (`result`, then `record`). EVALUATOR: console (objectui) `ActionRunner.composeSuccessMessage` picks the entry the answer\'s `outcome` names (its envelope reduced to its text) and fills a `{{ }}` sentence through `fillTemplateHoles` over `result` and `record`, read in the `ui/` tree; a blank entry falls to `successMessage`, and a sentence with no `{{ }}` hole keeps the transitional `${result.*}` reading', + covers: ['ui/action.zod.ts:actionObject.outcomeMessages'], + note: 'The platform\'s own entries (the invite actions\' `sent` / `link_only`, the role change\'s `permission_set_granted`) were converted from `${result.*}` to `{{ result.* }}` on the same change. ⛔ NOT MEASURED HERE: the runner was read in the `ui/` tree\'s pins, not run against a server, and the `-log` half of `fail-soft-log`.', }, { id: 'template-action-error-message', diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 314d6c5b312..9d17d9857f7 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -16,6 +16,7 @@ */ import { validateActionParams, type ActionSession, type ResolvedActionParam } from '@objectstack/spec/ui'; +import { templateSlotSource } from '@objectstack/spec/automation'; // [#15124] The facade's `find` judges its own parameter against the SAME // declaration its type names, so the refusal and the type cannot drift apart. import { EngineQueryOptionsSchema, DATA_ACTION_TO_API_OPERATION } from '@objectstack/spec/data'; @@ -2494,7 +2495,10 @@ export function summarizeActionParams(deps: ActionExecutionDeps, action: any, ob if (!name) continue; const type = jsonTypeOf(deps, p?.type ?? field?.type); const label = typeof p?.label === 'string' ? p.label : field?.label; - const help = p?.helpText ?? field?.description; + // A param's `helpText` is a `template` slot (#27948, ruling A): the + // parsed value is `{ dialect: 'template', source }`, so the tool + // schema reads its text, never the object. + const help = templateSlotSource(p?.helpText) ?? field?.description; const description = [label, help].filter(Boolean).join(' — ') || undefined; const optionSource = p?.options ?? field?.options; const enumVals = Array.isArray(optionSource) diff --git a/packages/runtime/src/action-param-help-envelope-27948.test.ts b/packages/runtime/src/action-param-help-envelope-27948.test.ts new file mode 100644 index 00000000000..7944581a61f --- /dev/null +++ b/packages/runtime/src/action-param-help-envelope-27948.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #27948 (ruling A, step 2): a param's `helpText` becomes an ADR-0032 + * `template` slot, so a parsed declaration carries + * `{ dialect: 'template', source }` where it carried a string. + * `summarizeActionParams` joins the help into the agent-facing param + * description; read as a plain value, the envelope printed + * `Reason — [object Object]`. It reads the slot's text, the same sentence the + * bare string gives. + */ + +import { describe, it, expect } from 'vitest'; + +import { summarizeActionParams } from './action-execution.js'; + +const NO_DEPS: any = {}; +const SOURCE = 'Why pause {{ record.name }}?'; + +const actionWith = (helpText: unknown): any => ({ + name: 'pause_account', + type: 'script', + params: [{ name: 'reason', label: 'Reason', type: 'text', helpText }], +}); + +describe('summarizeActionParams reads a template-envelope helpText (#27948)', () => { + it('the envelope gives the same param description as the bare string', () => { + const fromString = summarizeActionParams(NO_DEPS, actionWith(SOURCE), undefined); + const fromEnvelope = summarizeActionParams(NO_DEPS, actionWith({ dialect: 'template', source: SOURCE }), undefined); + expect(fromString[0].description).toBe(`Reason — ${SOURCE}`); + expect(fromEnvelope).toEqual(fromString); + }); + + it('an envelope with no source falls back to the bound field description, as an absent helpText does', () => { + const obj = { fields: { reason: { label: 'Reason', type: 'text', description: 'The field help' } } }; + const action = (helpText: unknown): any => ({ + name: 'pause_account', + params: [{ field: 'reason', ...(helpText === undefined ? {} : { helpText }) }], + }); + const absent = summarizeActionParams(NO_DEPS, action(undefined), obj); + expect(absent[0].description).toBe('Reason — The field help'); + expect(summarizeActionParams(NO_DEPS, action({ dialect: 'template' }), obj)).toEqual(absent); + }); +}); diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 719b74620df..4345006ea35 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -111,7 +111,7 @@ "status": "live", "verifiedAt": "2026-10-03", "evidenceScope": "in-repo", - "evidence": "ui/packages/core/src/actions/ActionRunner.ts#composeSuccessMessage — the success toast's text: `outcomeMessages[outcome]` off the handler's return value, then `successMessage`, then the runner's default text, with `${result.*}` filled in whichever is shown (called from `handlePostExecution`); ui/packages/components/src/renderers/action/action-button.tsx#ActionButtonRenderer — forwards the key to the runner, and `action:bar` spreads a registered action onto `action:button` whole (`action-icon.tsx`, `action-group.tsx` and `action-menu.tsx` forward it too); packages/spec/src/ui/action.zod.ts#refuseInertOutcomeMessages (authoring-time: the map is refused on a type with no server response to report an outcome and beside `resultDialog`, which suppresses the toast; the declarative-update table refuses it beside `operation: 'update'`); packages/spec/src/system/i18n-resolver.ts#translateAction (the REST localization seam overlays each DECLARED outcome's translated copy at the action's one address: `_actions..outcomeMessages.` for a bound action, `globalActions` for an object-less one); packages/cli/src/utils/i18n-extract.ts#pushActionOutcomeMessages (one translation key per declared outcome, beside `successMessage`)", + "evidence": "ui/packages/core/src/actions/ActionRunner.ts#composeSuccessMessage — the success toast's text: `outcomeMessages[outcome]` off the handler's return value, then `successMessage`, then the runner's default text, with the `{{ }}` holes (#27948; a holeless sentence keeps its transitional `${result.*}` reading) filled in whichever is shown (called from `handlePostExecution`); ui/packages/components/src/renderers/action/action-button.tsx#ActionButtonRenderer — forwards the key to the runner, and `action:bar` spreads a registered action onto `action:button` whole (`action-icon.tsx`, `action-group.tsx` and `action-menu.tsx` forward it too); packages/spec/src/ui/action.zod.ts#refuseInertOutcomeMessages (authoring-time: the map is refused on a type with no server response to report an outcome and beside `resultDialog`, which suppresses the toast; the declarative-update table refuses it beside `operation: 'update'`); packages/spec/src/system/i18n-resolver.ts#translateAction (the REST localization seam overlays each DECLARED outcome's translated copy at the action's one address: `_actions..outcomeMessages.` for a bound action, `globalActions` for an object-less one); packages/cli/src/utils/i18n-extract.ts#pushActionOutcomeMessages (one translation key per declared outcome, beside `successMessage`)", "note": "LIVE from the `.objectui-sha` pin ab1879721595 (2026-10-03): the console reader objectstack-ai/objectui#11344 (objectui c476be0e0) is in the pin's range 89cad75d5570..ab1879721595, so the flip this row prescribed lands on the pin bump that carries it, with `authorWarn` / `authorHint` dropped in the same edit. At 89cad75d5570 no objectui source outside `packages/types` named the key (0 hit lines), and `handlePostExecution` toasted `result.data.message || action.successMessage || runner default`; at ab1879721595 the server-message rung is gone and `composeSuccessMessage` selects the outcome copy. An `outcome` reaches it only from a handler that returns one, which a `script` handler's author writes beside the map and an `api` target returns in its body. History: PLANNED, carrier objectstack-ai/objectui#11344 (the console's success toast picks `outcomeMessages[result.outcome]`, interpolates ${result.*}, falls back to `successMessage`, then the runner default). Declared by ruling A on objectstack-ai/cloud#2315 (maintainer 2026-10-01), the mechanism half of ruling B there — the server returns facts, the console composes the message in the user's locale — landing spec+client first (#21095), the console second, cloud's producers LAST. Measured at objectui @e420df31: packages/core/src/actions/ActionRunner.ts#handlePostExecution composes the toast from `result.data.message || action.successMessage || runner default` and reads no `outcomeMessages`; re-read unchanged at objectui @31971ff1 (the `.objectui-sha` pin after the base merge), and objectui main 3c3ce15 has no reader either. The in-repo pieces cited in `evidence` validate, translate and extract the copy; none of them SELECTS or shows it, which is what a `live` verdict would claim. Flip to `live` on the commit that cites the console reader at the `.objectui-sha` pin — and drop `authorWarn` / `authorHint` in that same edit: a warned `live` row reds this gate (#21127). Re-read at objectui @b9814b334 on 2026-10-14 (the record moved out of packages/spec/src/ui/action.zod.ts, #23434): objectui @b9814b334 packages/core/src/actions/ActionRunner.ts#composeSuccessMessage unchanged; at objectui @89cad75d5570 no objectui source outside its types named the key." }, "errorMessage": { diff --git a/packages/spec/src/migrations/entries/semantic/18.action-dialog-outcome-copy-template-holes.ts b/packages/spec/src/migrations/entries/semantic/18.action-dialog-outcome-copy-template-holes.ts new file mode 100644 index 00000000000..53cc9dfa6f5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.action-dialog-outcome-copy-template-holes.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #27948 (ruling A, step 2) — an action's param dialog `description`, each +// param's `helpText` and each `outcomeMessages` entry became ADR-0032 +// `template` slots, the family `confirmText` / `successMessage` joined in +// #24814: holes are `{{ }}` over the roots the console's runner binds, and the +// doors refuse a `${…}` token there. Semantic only: the `${…}` reading took any +// expression the runner's evaluator could run (`${record.user_id?.name ?? +// 'this member'}`), and a template hole holds no logic, so no conversion maps +// every such sentence; the console keeps reading `${…}` in a stored sentence +// with no `{{ }}` hole, so stored copy keeps working until its next save. +export const entry: SemanticMigration = { + id: 'action-dialog-outcome-copy-template-holes', + surface: 'an action\'s `description`, each `params[].helpText` and each `outcomeMessages` entry (an object\'s ' + + '`actions[]`, the stack\'s `actions[]`, and an inline `properties.action` param\'s `helpText`) — a string, ' + + 'or each value of an inline locale map, carrying a `${…}` token such as `${record.email}` or ' + + '`${result.email}`', + replacement: 'the `{{ }}` hole over the same path: `Resend the invitation to ${record.email}?` becomes ' + + '`Resend the invitation to {{ record.email }}?`, and `Invitation sent to ${result.email}` becomes ' + + '`Invitation sent to {{ result.email }}`. `description` and `helpText` read `record`, the record the action ' + + 'runs against; an `outcomeMessages` entry reads `result` (the handler\'s answer) and `record`. A fallback ' + + 'written as `${record.owner?.name ?? \'nobody\'}` becomes `{{ record.owner.name | default:\'nobody\' }}` ' + + '(the `default` formatter also covers an empty value); other logic moves into the handler\'s answer and is ' + + 'read from `result`.', + reason: 'These three keys were plain copy that the console\'s action runner filled with `${…}` through its ' + + 'expression evaluator, over its whole context, while `confirmText` and `successMessage` beside them were ' + + 'already `template` slots that refuse `${…}`; one action spoke two hole syntaxes. They are now `template` ' + + 'slots too: a bare string parses to the `{ dialect: \'template\', source }` envelope, and `objectstack ' + + 'validate`, `compile` and the publish gate refuse a `${…}` token, a single-brace `{x}` token, a hole over a ' + + 'root the runner does not bind, a hole holding logic or an unknown formatter, and a blank sentence, each ' + + 'named with its `{{ }}` spelling. The text still parses, so a stored action loads; the console still fills ' + + '`${…}` in a sentence with no `{{ }}` hole, so stored copy reads as before until its next save, which is ' + + 'judged. No conversion is registered: a `${…}` token held an expression, and a hole holds a path and ' + + 'formatters only, so a token with logic in it has no mechanical `{{ }}` spelling.', + acceptanceCriteria: 'Run `objectstack validate`: it reports no finding at an action\'s `description`, ' + + '`params[N].helpText` or `outcomeMessages.OUTCOME`. Open the action\'s param dialog from a list row and from ' + + 'the record header and confirm the line and each hint carry the record\'s value; run the action to each ' + + 'outcome it answers and confirm the toast carries the answer\'s value, with no `{{` or `${` left in the text.', +}; diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index bdd3436ca25..dca0053a454 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -212,10 +212,11 @@ export interface ActionLike { label?: string; /** * `ActionSchema.description` — the explanatory line under the title in the - * action's param dialog. Narrowed to `string` for the same reason `label` is: - * a resolver answers with ONE locale's string. + * action's param dialog. A `template` slot (#27948) like `confirmText`: the + * bare string, or the parsed envelope a served action carries; a + * translation replaces either with the bundle's sentence. */ - description?: string; + description?: string | { dialect: 'template'; source: string }; /** * `template` slots (#24814): the bare string, or the parsed * `{ dialect: 'template', source }` envelope a served action carries. A @@ -226,19 +227,19 @@ export interface ActionLike { successMessage?: string | { dialect: 'template'; source: string }; /** * `ActionSchema.disabledReason` (#26619) — the display-only reason a greyed - * action shows. Narrowed to `string` for the same reason `description` is. + * action shows. Narrowed to `string` for the same reason `label` is. */ disabledReason?: string; /** * `ActionSchema.submitText` (#27944) — the param dialog's submit button - * label. Narrowed to `string` for the same reason `description` is. + * label. Narrowed to `string` for the same reason `label` is. */ submitText?: string; /** - * `ActionSchema.outcomeMessages` — success copy per handler `outcome`, - * narrowed to `string` values for the same reason `successMessage` is. + * `ActionSchema.outcomeMessages` — success copy per handler `outcome`, each + * entry a `template` slot like `successMessage` (#27948). */ - outcomeMessages?: Record; + outcomeMessages?: Record; /** `ActionSchema.params` — the param dialog's own copy. */ params?: ActionParamLike[]; /** When omitted, the action is treated as global. */ @@ -265,8 +266,11 @@ export interface ActionParamLike { /** `ActionParamSchema.field` — the translation key when `name` is omitted. */ field?: string; label?: string; - /** An ACTION param spells its hint `helpText`; a BULK param spells it `help`. */ - helpText?: string; + /** + * An ACTION param spells its hint `helpText`; a BULK param spells it `help`. + * A `template` slot (#27948): the bare string or the parsed envelope. + */ + helpText?: string | { dialect: 'template'; source: string }; placeholder?: string; /** * `ActionParamSchema.options[]` is an ARRAY of entries while the translation @@ -806,12 +810,12 @@ function resolveActionOutcomeMessages( bundle: TranslationBundle | undefined, action: ActionLike, opts?: ResolveOptions, -): Record | undefined { +): ActionLike['outcomeMessages'] { const declared = action.outcomeMessages; if (!declared || typeof declared !== 'object') return undefined; if (!bundle) return declared; const chain = localeChain(opts); - let out: Record | undefined; + let out: NonNullable | undefined; for (const outcome of Object.keys(declared)) { for (const code of chain) { const candidate = actionTranslationNode(pickData(bundle, code), action)?.outcomeMessages?.[outcome]; diff --git a/packages/spec/src/ui/action-copy-template.test.ts b/packages/spec/src/ui/action-copy-template.test.ts index 409db964866..b2db1698996 100644 --- a/packages/spec/src/ui/action-copy-template.test.ts +++ b/packages/spec/src/ui/action-copy-template.test.ts @@ -4,7 +4,8 @@ * #24814 (ruling A) — an action's person-facing copy is an ADR-0032 `template` * slot over a CLOSED scope: `confirmText`, `successMessage` and `errorMessage` * on the page action rows, `confirmText` and `successMessage` on - * `ActionSchema`. These pins hold each slot's accept set — a bare string is + * `ActionSchema` — and, since #27948, `ActionSchema.description`, each + * `params[].helpText` and each `outcomeMessages` entry. These pins hold each slot's accept set — a bare string is * shorthand for the envelope, a holeless sentence still parses, a blank one * still parses (the arm is kept so a stored one loads; the doors refuse it, * #25680), and `ActionSchema` keeps its @@ -14,6 +15,7 @@ * `@objectstack/lint`'s `validate-action-copy-templates` pins the doors. */ import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; import { ACTION_CONFIRM_PARAMS_ROOT, @@ -98,6 +100,60 @@ describe('ActionSchema confirmText / successMessage are template slots that keep }); }); +describe('ActionSchema description / params[].helpText / outcomeMessages joined the family (#27948)', () => { + const base = { name: 'pause', label: 'Pause', type: 'api', target: '/api/v1/pause' } as const; + const parse = (patch: Record) => ActionSchema.safeParse({ ...base, ...patch }); + const parsed = (patch: Record) => { + const r = parse(patch); + if (!r.success) throw new Error(r.error.issues.map((i) => i.message).join('\n')); + return r.data as Record; + }; + /** Each slot: how to write a value into it, how to read it back, its refusal's slot name, and whether it keeps a locale map. */ + const SLOTS = [ + ['description', (v: unknown) => ({ description: v }), (d: Record) => d.description, "An action's `description`", true], + ['params[].helpText', (v: unknown) => ({ params: [{ name: 'reason', type: 'text', helpText: v }] }), (d: Record) => d.params[0].helpText, "An action param's `helpText`", false], + ['outcomeMessages entry', (v: unknown) => ({ outcomeMessages: { paused: v } }), (d: Record) => d.outcomeMessages.paused, "An action's `outcomeMessages` entry", true], + ] as const; + + for (const [slot, write, read, name, localeMap] of SLOTS) { + it(`${slot}: string → envelope, envelope taken, blank string unchanged; a \${…} sentence still parses`, () => { + expect(read(parsed(write('Pause {{ record.name }}?')))).toEqual({ dialect: 'template', source: 'Pause {{ record.name }}?' }); + expect(read(parsed(write({ dialect: 'template', source: 'Paused' })))).toEqual({ dialect: 'template', source: 'Paused' }); + // Stored copy written before the migration still loads; the doors refuse it. + expect(read(parsed(write('Pause ${record.name}?')))).toEqual({ dialect: 'template', source: 'Pause ${record.name}?' }); + expect(read(parsed(write('')))).toBe(''); + }); + + it(`${slot}: ${localeMap ? 'keeps' : 'takes no'} inline locale map; a foreign dialect is refused with the slot's own sentence`, () => { + const map = { en: 'Paused', 'zh-CN': '已暂停' }; + if (localeMap) expect(read(parsed(write(map)))).toEqual(map); + else expect(parse(write(map)).success).toBe(false); + const r = parse(write({ dialect: 'cel', source: 'record.name' })); + expect(r.success).toBe(false); + if (r.success) return; + const messages = r.error.issues.map((i) => i.message).join('\n'); + expect(messages).toContain(`${name} is a template-typed slot`); + expect(messages.includes('or an inline locale map')).toBe(localeMap); + }); + } + + it('the describes an author reads in Studio teach `{{ }}`, never `${…}`', () => { + const json = z.toJSONSchema(ActionSchema, { unrepresentable: 'any', io: 'input' }) as { + properties: Record; + }; + const describes = { + description: json.properties.description.description as string, + outcomeMessages: json.properties.outcomeMessages.description as string, + }; + for (const [key, text] of Object.entries(describes)) { + expect(text, key).toContain('`template` slot'); + expect(text, key).toContain('{{ '); + expect(text, key).not.toMatch(/may interpolate \$\{/); + } + expect(describes.outcomeMessages).toContain('{{ result.email }}'); + }); +}); + describe('the roots are what the runner binds', () => { it('confirmText binds `record`, and `params` only beside static values', () => { expect(ACTION_COPY_RECORD_ROOT).toBe('record'); diff --git a/packages/spec/src/ui/action-copy-template.ts b/packages/spec/src/ui/action-copy-template.ts index 5cc6538f5c2..8574702e5f5 100644 --- a/packages/spec/src/ui/action-copy-template.ts +++ b/packages/spec/src/ui/action-copy-template.ts @@ -15,6 +15,13 @@ import { InlineLocaleMapSchema } from './i18n.zod'; * registered action forward the same three keys to the one runner the console * dispatches through, so they are one family with one dialect. * + * The param dialog's copy and the per-outcome toast joined the family + * (#27948, ruling A, step 2): `ActionSchema.description`, each + * `params[].helpText` (`ActionParamSchema`) and each `outcomeMessages` entry. + * The same runner fills them, over the same roots as the key they stand + * beside — the dialog copy over `confirmText`'s, an outcome over + * `successMessage`'s — so one action speaks one hole syntax in all five keys. + * * ## The roots are what the runner binds * * Each key's holes read a CLOSED scope, measured at the console's action @@ -29,7 +36,13 @@ import { InlineLocaleMapSchema } from './i18n.zod'; * - `successMessage` — `result`, the payload the handler answered, and * `record` ({@link ACTION_SUCCESS_TEMPLATE_ROOTS}); * - `errorMessage` — nothing: the runner shows the text as written - * ({@link ACTION_ERROR_TEMPLATE_ROOTS}), so a hole there is literal text. + * ({@link ACTION_ERROR_TEMPLATE_ROOTS}), so a hole there is literal text; + * - `description` and a param's `helpText` — `confirmText`'s roots: the + * param dialog opens before the run, over the record the action runs + * against, and a registered action's `params` is the input list the dialog + * collects, so it binds nothing ({@link actionConfirmTemplateRoots}); + * - an `outcomeMessages` entry — `successMessage`'s roots, since it is the + * success toast for one `outcome` ({@link ACTION_SUCCESS_TEMPLATE_ROOTS}). * * The doors (`objectstack validate`, `objectstack compile`, the publish gate) * judge every sentence through the one template judge's closed-scope entry, @@ -38,7 +51,8 @@ import { InlineLocaleMapSchema } from './i18n.zod'; * * ## The doors refuse; the parse keeps the blank arm * - * Each key was a plain string (`ActionSchema`'s an `I18nLabel`), so a blank + * Each key was a plain string (`ActionSchema`'s an `I18nLabel`, a param's + * `helpText` a plain string), so a blank * sentence and a sentence carrying a `${…}` or `{x}` token parsed. They still * parse: the slot keeps a blank-string arm, because the boot load and the read * path judge a stored row with this parse, and dropping the arm would make a @@ -61,6 +75,14 @@ import { InlineLocaleMapSchema } from './i18n.zod'; * 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. + * + * `description`, `helpText` and `outcomeMessages` took the same road one step + * later (#27948): the runner filled `${…}` in them until they were typed, and + * still fills it in a sentence with no `{{ }}` hole, so copy stored before the + * rewrite keeps reading until its next save. The rewrite is by hand: the + * semantic migration entry `action-dialog-outcome-copy-template-holes` names it + * and registers no conversion. The doors refuse a `${…}` token there as in the + * other keys, so new copy is written with `{{ }}`. */ /** The root a `confirmText` / `successMessage` hole names for the record the action runs against. */ @@ -101,19 +123,41 @@ export const ACTION_SUCCESS_TEMPLATE_ROOTS: readonly string[] = Object.freeze([ */ export const ACTION_ERROR_TEMPLATE_ROOTS: readonly string[] = Object.freeze([]); -/** The three copy keys, as the doors name them. */ -export type ActionCopyKey = 'confirmText' | 'successMessage' | 'errorMessage'; +/** The copy keys, as the doors name them. */ +export type ActionCopyKey = + | 'confirmText' + | 'successMessage' + | 'errorMessage' + | 'description' + | 'helpText' + | 'outcomeMessages'; + +/** The copy keys a page action row carries (`ui/component.zod.ts`): the first three, the runner's own toast and dialog copy. */ +export type ActionRowCopyKey = Extract; /** The sentence an example in a refusal shows for each key. */ const EXAMPLE: Record = { confirmText: 'Delete {{ record.name }}?', successMessage: 'Invitation sent to {{ result.email }}', errorMessage: 'The invitation could not be sent.', + description: 'Choose the new role for {{ record.name }}.', + helpText: 'Why {{ record.name }} is paused', + outcomeMessages: 'Invitation resent to {{ result.email }}', +}; + +/** How a refusal names the slot: an `outcomeMessages` value is one entry, a `helpText` a param's. */ +const SLOT_NAME: Record = { + confirmText: "An action's `confirmText`", + successMessage: "An action's `successMessage`", + errorMessage: "An action's `errorMessage`", + description: "An action's `description`", + helpText: "An action param's `helpText`", + outcomeMessages: "An action's `outcomeMessages` entry", }; /** The refusal of a value that is neither a template string nor a template envelope (nor, where taken, a locale map). */ function dialectOnly(key: ActionCopyKey, localeMap: boolean): string { - return `An action's \`${key}\` is a template-typed slot: it accepts a bare template string, an envelope ` + return `${SLOT_NAME[key]} is a template-typed slot: it accepts a bare template string, an envelope ` + `declaring \`dialect: 'template'\`${localeMap ? ', or an inline locale map ({ en, "zh-CN" })' : ''} only. ` + `Write \`'${EXAMPLE[key]}'\` or \`{ dialect: 'template', source: '${EXAMPLE[key]}' }\`.`; } @@ -185,6 +229,32 @@ export const ACTION_SUCCESS_MESSAGE_TEMPLATE_DESCRIBE = + '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}`; +/** `description`'s describe: the param dialog's line, over `confirmText`'s roots (#27948). */ +export const ACTION_DESCRIPTION_TEMPLATE_DESCRIBE = + "a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no " + + 'hole is shown as written. Holes are `{{ path }}` or `{{ path | formatter[:arg] }}` over `record`, the record the ' + + 'action runs against (the clicked row on a list, else the page\'s record); the values the dialog collects are not ' + + `bound, since the line shows before they exist. ${GRAMMAR} Example: 'Choose the new role for {{ record.name }}.'. A ` + + 'hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole ' + + `holding logic or an unknown formatter, and a blank sentence are named ${DOORS}`; + +/** A param's `helpText` describe: the hint under one dialog input, over `confirmText`'s roots (#27948). */ +export const ACTION_PARAM_HELP_TEXT_TEMPLATE_DESCRIBE = + "a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no " + + 'hole is shown as written. Holes are `{{ path }}` or `{{ path | formatter[:arg] }}` over `record`, the record the ' + + `action runs against; the dialog's own values are not bound. ${GRAMMAR} Example: 'Why {{ record.name }} is ` + + 'paused\'. 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}`; + +/** An `outcomeMessages` entry's describe: the success toast for one outcome, over `successMessage`'s roots (#27948). */ +export const ACTION_OUTCOME_MESSAGE_TEMPLATE_DESCRIBE = + "a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no " + + 'hole is shown as written. Holes are `{{ path }}` or `{{ path | formatter[:arg] }}` over `result`, the payload ' + + 'the action\'s handler answered (`{{ result.email }}`), and `record`, the record the action ran against — the ' + + `roots \`successMessage\` reads. ${GRAMMAR} Example: 'Invitation resent to {{ result.email }}'. A hole over any ` + + 'other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or ' + + `an unknown formatter, and a blank sentence are named ${DOORS}`; + /** `errorMessage`'s describe: a slot that fills no hole. */ export const ACTION_ERROR_MESSAGE_TEMPLATE_DESCRIBE = "a `template` slot (ADR-0032) that binds no root: a bare string is shorthand for `{ dialect: 'template', source }`, " diff --git a/packages/spec/src/ui/action-description.test.ts b/packages/spec/src/ui/action-description.test.ts index aebb99b2e00..417b8f2fd88 100644 --- a/packages/spec/src/ui/action-description.test.ts +++ b/packages/spec/src/ui/action-description.test.ts @@ -13,7 +13,9 @@ // matching bundle key — so the resolver was looking for a string nothing could // author. The mirror image of declared-but-unenforced. // -// These pins hold the key open in the exact shape `label` has, and hold the +// These pins hold the key open to the input shapes `label` takes (a string or +// an inline locale map; since #27948 a string is the `template` slot's +// shorthand and parses to its envelope), and hold the // three things this change must NOT do: relax the strict shape, widen the // INLINE action surface ahead of its renderer, or re-point the action PARAM's // `description` → `helpText` alias, which is a different surface and still @@ -34,7 +36,7 @@ import { ObjectTranslationDataSchema, TranslationDataSchema } from '../system/tr const base = { name: 'approval_reject', label: 'Reject', target: 'rejectApproval' } as const; describe('ActionSchema.description — the line the param dialog shows', () => { - it('accepts a plain string, matching `label`\'s I18nLabel contract', () => { + it('accepts a plain string, parsed to the `template` envelope it is shorthand for (#27948)', () => { const result = ActionSchema.safeParse({ ...base, description: 'Reject this request? Say why — the requester sees it.', @@ -42,9 +44,10 @@ describe('ActionSchema.description — the line the param dialog shows', () => { }); expect(result.success).toBe(true); - expect(result.success && result.data.description).toBe( - 'Reject this request? Say why — the requester sees it.', - ); + expect(result.success && result.data.description).toEqual({ + dialect: 'template', + source: 'Reject this request? Say why — the requester sees it.', + }); }); it('accepts the localized-map form, matching `label`\'s I18nLabel contract', () => { @@ -110,7 +113,7 @@ describe('ActionSchema.description — the line the param dialog shows', () => { }); expect(both.success).toBe(true); - expect(both.success && both.data.description).toBe('Reject this request?'); + expect(both.success && both.data.description).toEqual({ dialect: 'template', source: 'Reject this request?' }); expect(both.success && both.data.ai?.description).toContain('Reject a pending approval request'); // The ≥40-char LLM contract is unaffected: a top-level `description` does diff --git a/packages/spec/src/ui/action-outcome-messages.test.ts b/packages/spec/src/ui/action-outcome-messages.test.ts index d6f947ab5d9..703648f8a79 100644 --- a/packages/spec/src/ui/action-outcome-messages.test.ts +++ b/packages/spec/src/ui/action-outcome-messages.test.ts @@ -58,12 +58,19 @@ function located(result: { success: boolean; error?: { issues: Array<{ code: str } describe('ActionSchema.outcomeMessages — accepted on the two types with a success payload', () => { - it('accepts the map on a type:script action and carries it through parse untouched', () => { + it('accepts the map on a type:script action and carries every sentence through parse as written', () => { const r = ActionSchema.safeParse(DELETE_ENVIRONMENT); expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); - // Byte-for-byte: the spec interpolates nothing — the `${result.*}` tokens - // are the renderer's, and an inline locale map stays a map. - expect((r.data as { outcomeMessages: unknown }).outcomeMessages).toEqual(DELETE_ENVIRONMENT.outcomeMessages); + // Each entry is a `template` slot (#27948): a bare string parses to the + // `{ dialect: 'template', source }` envelope with its text as written — + // the spec interpolates nothing, the tokens are the renderer's — and an + // inline locale map stays a map. (The `${…}` tokens still PARSE; the + // doors refuse them, `action-copy-template` pins that.) + const asWritten = Object.fromEntries(Object.entries(DELETE_ENVIRONMENT.outcomeMessages).map(([outcome, copy]) => [ + outcome, + typeof copy === 'string' ? { dialect: 'template', source: copy } : copy, + ])); + expect((r.data as { outcomeMessages: unknown }).outcomeMessages).toEqual(asWritten); }); it('accepts the map on a type:api action', () => { diff --git a/packages/spec/src/ui/action.form.ts b/packages/spec/src/ui/action.form.ts index f4d2b79bd62..6d9d061ce27 100644 --- a/packages/spec/src/ui/action.form.ts +++ b/packages/spec/src/ui/action.form.ts @@ -134,7 +134,7 @@ export const actionForm = defineForm({ // // `outcomeMessages` is a record keyed by outcome name, so G1's `json`, // the face `patch` and `bodyExtra` carry. - { field: 'outcomeMessages', widget: 'json', visibleWhen: "data.type == 'api' || data.type == 'script'", helpText: "Success message per handler outcome, as JSON: each key is a snake_case `outcome` value the handler returns in its success payload, and each value is the label shown for that outcome, e.g. {\"archived\": \"Archived\", \"already_archived\": \"Already archived\"}. A label may interpolate ${result.*}. An outcome with no entry shows the success message; a key the handler never returns is never shown. Not allowed beside a result dialog or `operation: 'update'`." }, + { field: 'outcomeMessages', widget: 'json', visibleWhen: "data.type == 'api' || data.type == 'script'", helpText: "Success message per handler outcome, as JSON: each key is a snake_case `outcome` value the handler returns in its success payload, and each value is the label shown for that outcome, e.g. {\"archived\": \"Archived\", \"already_archived\": \"Already archived\"}. A label may hold {{ result.* }} holes. An outcome with no entry shows the success message; a key the handler never returns is never shown. Not allowed beside a result dialog or `operation: 'update'`." }, // `onSuccess` is an object of two keys, so G2's `composite`, the `body` // row's face above, one sub-row per key. { diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 375cb7369ce..77eca4ff6a4 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -27,6 +27,9 @@ import { I18nLabelSchema, InlineLocaleMapSchema } from './i18n.zod'; import { NON_BLANK_STRING } from '../shared/refinement-projection'; import { ACTION_CONFIRM_TEXT_TEMPLATE_DESCRIBE, + ACTION_DESCRIPTION_TEMPLATE_DESCRIBE, + ACTION_OUTCOME_MESSAGE_TEMPLATE_DESCRIBE, + ACTION_PARAM_HELP_TEXT_TEMPLATE_DESCRIBE, ACTION_SUCCESS_MESSAGE_TEMPLATE_DESCRIBE, actionCopyRefusals, actionCopyTemplateSlot, @@ -160,6 +163,24 @@ const actionParamOptionUndeclaredAnywhere = (key: 'icon' | 'disabled'): string = + `\`SelectOptionMetadata\` type, which no metadata path populates and no widget reads. An ` + `action param's options are \`{ label, value, visibleWhen }\`; drop the key.`; +/** + * [#24814, ADR-0032 Decisions 2–3] A registered action's `confirmText` / + * `successMessage` — a `template` slot over the roots the console's runner + * binds, one family with the page action rows' copy keys + * (`./action-copy-template.ts`), which also keeps the inline locale map + * `I18nLabelSchema` took. The `template` input is built HERE, at the declaring + * file, so the expression-surface census sees every position. + * + * [#27948, ruling A, step 2] The param dialog's `description` and each + * `outcomeMessages` entry joined the family with their locale map; a param's + * `helpText` joined it without one (it was a plain string, so a map was never + * its shape — {@link actionParamHelpText}). + */ +const actionCopy = (key: 'confirmText' | 'successMessage' | 'description' | 'outcomeMessages') => actionCopyTemplateSlot(templateExpressionInput(ExpressionSchema, actionCopyRefusals(key, { localeMap: true })), key, { localeMap: true }); + +/** [#27948] A param's `helpText`: the copy family's slot, with no locale map (it was a plain string). */ +const actionParamHelpText = () => actionCopyTemplateSlot(templateExpressionInput(ExpressionSchema, actionCopyRefusals('helpText')), 'helpText'); + /** * Action Parameter Schema * @@ -368,8 +389,11 @@ export const ActionParamSchema = lazySchema(() => strictObject( })).optional().meta({ title: 'Options' }), /** Placeholder override. */ placeholder: z.string().optional().meta({ title: 'Placeholder' }), - /** Help/description override. */ - helpText: z.string().optional().meta({ title: 'Help Text' }), + /** + * Help/description override — the hint under this input in the param + * dialog. A `template` slot over `confirmText`'s roots (#27948). + */ + helpText: actionParamHelpText().optional().meta({ title: 'Help Text' }).describe(`Hint shown under this input in the action's param dialog — ${ACTION_PARAM_HELP_TEXT_TEMPLATE_DESCRIBE}`), /** * Default value for the dialog input — prefilled into the control when the * dialog opens, and SUBMITTED VERBATIM if the user does not touch the field @@ -878,16 +902,6 @@ const ActionConditionInputSchema = z.union([z.boolean(), EvaluatedExpressionInpu error: (issue) => evaluatedExpressionUnionRefusal(issue.input), }); -/** - * [#24814, ADR-0032 Decisions 2–3] A registered action's `confirmText` / - * `successMessage` — a `template` slot over the roots the console's runner - * binds, one family with the page action rows' copy keys - * (`./action-copy-template.ts`), which also keeps the inline locale map - * `I18nLabelSchema` took. The `template` input is built HERE, at the declaring - * file, so the expression-surface census sees both positions. - */ -const actionCopy = (key: 'confirmText' | 'successMessage') => actionCopyTemplateSlot(templateExpressionInput(ExpressionSchema, actionCopyRefusals(key, { localeMap: true })), key, { localeMap: true }); - /** * [#26619] `disabledReason` — the sentence a greyed action shows to say WHY it * is unavailable, beside the `disabled` predicate that greys it. @@ -1100,7 +1114,7 @@ const actionObject = () => strictObject({ * (≥40 chars, required when `ai.exposed`); this one is human-facing dialog * copy and is never sent to a model. */ - description: I18nLabelSchema.optional().describe('Explanatory line shown under the title in the action\'s param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`.'), + description: actionCopy('description').optional().describe(`Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing \`ai.description\`. It is ${ACTION_DESCRIPTION_TEMPLATE_DESCRIBE} An inline locale map ({ en, "zh-CN" }) is accepted too, each value such a sentence.`), /** Target object this action belongs to (optional, snake_case) */ objectName: z.string().regex(/^[a-z_][a-z0-9_]*$/).optional().describe('Target object this action belongs to. When set, the action is auto-merged into the object\'s actions array by defineStack().'), @@ -1483,13 +1497,18 @@ const actionObject = () => strictObject({ * * **Selection** (the console's, after an `api` or `script` action * succeeds): the success payload's top-level `outcome` — the payload - * `${result.*}` reads, i.e. `${result.outcome}` — names the entry; failing + * `{{ result.* }}` reads, i.e. `{{ result.outcome }}` — names the entry; failing * a match, `successMessage`; failing that, the runner's default text. * - * **Interpolation**: each message, like `successMessage`, may interpolate - * `${result.X}` — the scope `onSuccess.navigate` declares - * (for `type: 'api'` the `target` call's response body, for - * `type: 'script'` the handler's return value). No second dialect. + * **Interpolation**: each message is a `template` slot like + * `successMessage` (#27948, ruling A): its `{{ }}` holes read `result` — + * for `type: 'api'` the `target` call's response body, for + * `type: 'script'` the handler's return value — and `record`. One dialect: + * a `${…}` token is refused at the doors. Stored copy is rewritten to + * `{{ }}` by hand: the semantic migration entry + * `action-dialog-outcome-copy-template-holes` (`os migrate meta` lists it) + * names the edit and registers no conversion, since a `${…}` token can hold + * an expression with no mechanical `{{ }}` spelling. * * **Where it is refused** (`refuseInertOutcomeMessages`, and the * declarative-update table): on a type with no server response to report @@ -1507,10 +1526,10 @@ const actionObject = () => strictObject({ * (objectui#11344) and is `live` now; the reader — the action runner picks * `outcomeMessages[outcome]` off the handler's return value, falls back to * `successMessage` and then to the runner's default text, and fills - * `${result.*}` in whichever it shows — is recorded by symbol in + * the holes of whichever it shows — is recorded by symbol in * `packages/spec/liveness/action.json`, row `outcomeMessages`. */ - outcomeMessages: z.record(SnakeCaseIdentifierSchema, I18nLabelSchema).optional().describe("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)."), + outcomeMessages: z.record(SnakeCaseIdentifierSchema, actionCopy('outcomeMessages')).optional().describe(`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 is ${ACTION_OUTCOME_MESSAGE_TEMPLATE_DESCRIBE} An inline locale map ({ en, "zh-CN" }) is accepted too, each value such a sentence. 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).`), // Runtime (ActionRunner) already honours this — declared here so authors can // set a friendly failure toast instead of surfacing the raw error string. errorMessage: I18nLabelSchema.optional().describe('Error message to show when the action fails (overrides the raw error).'), diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index da2aedcd58a..3c77db51315 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -78,7 +78,7 @@ import { ACTION_SUCCESS_MESSAGE_TEMPLATE_DESCRIBE, actionCopyRefusals, actionCopyTemplateSlot, - type ActionCopyKey, + type ActionRowCopyKey, } from './action-copy-template'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; // [#21464] `object-metric.aggregate.groupBy` is the chart aggregate's own @@ -3428,10 +3428,10 @@ const elementListHistory = (type: string) => * built HERE, at the declaring file, so the expression-surface census sees each * row's position. A factory, so each row's key carries its own instance. */ -const actionRowCopy = (key: ActionCopyKey) => actionCopyTemplateSlot(templateExpressionInput(ExpressionSchema, actionCopyRefusals(key)), key); +const actionRowCopy = (key: ActionRowCopyKey) => actionCopyTemplateSlot(templateExpressionInput(ExpressionSchema, actionCopyRefusals(key)), key); /** The describe of each row copy key — the row's wording, then the slot's grammar and roots. */ -const ACTION_ROW_COPY_DESCRIBE: Record = { +const ACTION_ROW_COPY_DESCRIBE: Record = { confirmText: `Confirmation question asked before the action runs — ${ACTION_CONFIRM_TEXT_TEMPLATE_DESCRIBE}`, successMessage: `Toast shown when the action succeeds — ${ACTION_SUCCESS_MESSAGE_TEMPLATE_DESCRIBE}`, errorMessage: `Toast shown when the action fails, in place of the raw error — ${ACTION_ERROR_MESSAGE_TEMPLATE_DESCRIBE}`, diff --git a/ui/packages/app-shell/src/utils/resolveActionParams.helpTextEnvelope-27948.test.ts b/ui/packages/app-shell/src/utils/resolveActionParams.helpTextEnvelope-27948.test.ts new file mode 100644 index 00000000000..f33988f403a --- /dev/null +++ b/ui/packages/app-shell/src/utils/resolveActionParams.helpTextEnvelope-27948.test.ts @@ -0,0 +1,49 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27948 (ruling A, step 2) — a param's `helpText` is a `template` + * slot, so the spec's authoring type (and a served param) can carry + * `{ dialect: 'template', source }` where it carried a string. `RawActionParam` + * said `string`, so the public authoring type stopped being assignable to it + * (the `Type Check UI` red), and an envelope carried through untouched would + * reach `ActionParamDialog` as an object. The resolver emits the text. + */ +import { describe, it, expect } from 'vitest'; +import type { ActionParam } from '@object-ui/types'; +import { resolveActionParams, type ResolveActionParamsContext, type RawActionParam } from '@object-ui/fields'; + +const ctx = (): ResolveActionParamsContext => ({ + objectName: 'crm_account', + objects: [{ name: 'crm_account', fields: { reason: { type: 'text', label: 'Reason', description: 'The field help' } } }], + fieldLabel: (_o, _f, fallback) => fallback, +}); + +const HINT = 'Why {{ record.name }} is paused'; +const envelope = { dialect: 'template' as const, source: HINT }; + +describe('resolveActionParams reads a template-envelope helpText (objectstack#27948)', () => { + it('the public authoring type is assignable, envelope included', () => { + const authored: ActionParam = { name: 'reason', type: 'text', helpText: envelope }; + const raw: RawActionParam = authored; + expect(raw.helpText).toBe(envelope); + }); + + it('an inline param emits the envelope\'s text, exactly as the bare string', () => { + const [fromEnvelope] = resolveActionParams([{ name: 'reason', type: 'text', helpText: envelope }], ctx()); + const [fromString] = resolveActionParams([{ name: 'reason', type: 'text', helpText: HINT }], ctx()); + expect(fromEnvelope.helpText).toBe(HINT); + expect(fromEnvelope).toEqual(fromString); + }); + + it('a field-backed param emits the envelope\'s text; one with no source falls back to the field\'s help', () => { + expect(resolveActionParams([{ field: 'reason', helpText: envelope }], ctx())[0].helpText).toBe(HINT); + expect(resolveActionParams([{ field: 'reason', helpText: { dialect: 'template' } }], ctx())[0].helpText) + .toBe(resolveActionParams([{ field: 'reason' }], ctx())[0].helpText); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx index 947707ef8c6..158bccf7c09 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx @@ -53,6 +53,7 @@ import { SelectValue, } from '@object-ui/components'; import type { ActionParam } from '@object-ui/types'; +import { actionCopySource, isActionCopyEnvelope } from '@object-ui/core'; import { paramDegradesWithoutTarget } from '../../../utils/paramToField.js'; import { resolveParamWidgetType } from '@object-ui/fields'; import type { MetadataPreviewProps } from '../preview-registry.js'; @@ -87,9 +88,18 @@ interface ResultDialog { fields?: ResultDialogField[]; } +/** + * The text a copy value shows: a string, an `I18nLabel` map's English (else + * first) value, or — for an action's `template` copy slots (`confirmText`, + * `successMessage`, and since objectstack#27948 `description`, a param's + * `helpText`, an `outcomeMessages` entry) — the `{ dialect: 'template', + * source }` envelope's `source`. Read as a map, the envelope gave the word + * `template`. + */ function localize(v: unknown): string { if (v == null) return ''; if (typeof v === 'string') return v; + if (isActionCopyEnvelope(v)) return actionCopySource(v) ?? ''; if (typeof v === 'object') { const o = v as Record; return o.en ?? o['en-US'] ?? Object.values(o)[0] ?? ''; @@ -456,7 +466,7 @@ function DialogMock({ {p.type && {p.type}} {renderFieldMock(p, fieldLabel, locale)} - {p.helpText &&
{p.helpText}
} + {localize(p.helpText) &&
{localize(p.helpText)}
} ); })} diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/ActionPreview.copyEnvelope-27948.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/ActionPreview.copyEnvelope-27948.test.tsx new file mode 100644 index 00000000000..fcf1f40c59c --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/ActionPreview.copyEnvelope-27948.test.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27948 (ruling A, step 2) — an action's copy slots are `template` + * slots, so a parsed definition carries `{ dialect: 'template', source }` + * where it carried a string: `confirmText` / `successMessage` since #24814, + * a param's `helpText` (and `description`, an `outcomeMessages` entry) now. + * + * The preview drew a param's `helpText` raw — an envelope as a React child, + * the #31 crash — and read `confirmText` / `successMessage` as an `I18nLabel` + * map, which shows the word "template". It reads the envelope's `source`. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; + +import { ActionPreview } from '../ActionPreview'; + +afterEach(cleanup); + +const envelope = (source: string) => ({ dialect: 'template', source }); + +function renderDraft(copy: (s: string) => unknown) { + return render( + , + ); +} + +describe('ActionPreview reads the template envelope on action copy (objectstack#27948)', () => { + it('draws each envelope\'s source, never the word "template"', () => { + renderDraft(envelope); + expect(screen.getAllByText('Why {{ record.name }} is paused').length).toBeGreaterThan(0); + expect(screen.getAllByText('Pause {{ record.name }}?').length).toBeGreaterThan(0); + expect(screen.getAllByText('Paused {{ record.name }}').length).toBeGreaterThan(0); + expect(screen.queryByText('template')).toBeNull(); + }); + + it('CONTROL: the bare strings draw the same text', () => { + renderDraft((s) => s); + expect(screen.getAllByText('Why {{ record.name }} is paused').length).toBeGreaterThan(0); + expect(screen.getAllByText('Pause {{ record.name }}?').length).toBeGreaterThan(0); + }); +}); diff --git a/ui/packages/core/src/actions/ActionRunner.ts b/ui/packages/core/src/actions/ActionRunner.ts index 1133798a1ed..0a7afdc2474 100644 --- a/ui/packages/core/src/actions/ActionRunner.ts +++ b/ui/packages/core/src/actions/ActionRunner.ts @@ -24,7 +24,7 @@ import type { ActionContext, ActionResult, RunnableActionType, UIActionSchema } from '@object-ui/types'; import type { Action as SpecActionInput } from '@objectstack/spec/ui'; import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator.js'; -import { actionCopySource, fillTemplateHoles, fillTransitionalCopy, hasTemplateHoles, type ActionCopyTemplate } from './actionCopyTemplate.js'; +import { actionCopySource, actionCopyText, fillTemplateHoles, fillTransitionalCopy, hasTemplateHoles, type ActionCopyTemplate } from './actionCopyTemplate.js'; import { hasDeclaredPredicate } from '../evaluator/declaredPredicate.js'; import { globalUndoManager } from './UndoManager.js'; import { warnOnDeprecatedObjectParams, warnOnUnknownActionKeys } from './actionKeys.js'; @@ -1711,10 +1711,13 @@ export class ActionRunner { // The dialog's `description` / `helpText` read the dispatch's record // as `record`: a row dispatch binds none in the runner's context - // (objectui#12967). + // (objectui#12967). Both are `template` slots, so a served value may + // be the envelope: the handler is handed their TEXT + // (`dialogCopyOf`), as it localizes a string or a locale map. + const dialogCopy = dialogCopyOf(action, paramDefs); const collected = await this.paramCollectionHandler( - paramDefs, - action, + dialogCopy.params, + dialogCopy.action, this.dialogCopyFiller(action), run, ); @@ -1958,9 +1961,10 @@ export class ActionRunner { ? (outcomeMessages as Record)[outcome] : undefined; const resultRoots = { result: payload, record: this.dispatchRecord(action) }; - if (typeof outcomeCopy === 'string' && outcomeCopy.trim() !== '') { + const outcomeText = actionCopySource(outcomeCopy); + if (outcomeText !== undefined && outcomeText.trim() !== '') { return fillTransitionalCopy( - outcomeCopy, + outcomeText, (source) => fillTemplateHoles(source, resultRoots, this.displayLocale?.(), this.tenantCurrency?.()), (source) => substituteScopeTokens(source, { result: payload }, (value) => value), ); @@ -3234,6 +3238,33 @@ export function readOnSuccessNavigation(value: unknown): OnSuccessNavigation | n return value as OnSuccessNavigation; } +/** + * The action and param list a param-collection handler is handed, with the + * two `template` slots the dialog draws — the action's `description` and each + * param's `helpText` (objectstack#27948, ruling A) — reduced from the + * envelope to its text (`actionCopyText`). The handler localizes them (a + * bundle entry over the literal, `pickLocalized` over a locale map) before + * `interpolate` fills their holes, and both take a string: an envelope handed + * on would localize to nothing and reach the dialog as an object. A string or + * a locale map is handed on as written, and so is everything else on the + * action. Copies only what changes: the runner's own `action` is the one the + * collected values are merged onto. + */ +function dialogCopyOf( + action: ActionDef, + params: ActionParamDef[], +): { action: ActionDef; params: ActionParamDef[] } { + const description = actionCopyText(action.description); + const dialogAction = description === action.description + ? action + : { ...action, description: description as ActionDef['description'] }; + const dialogParams = params.map((param) => { + const helpText = actionCopyText(param.helpText as unknown); + return helpText === param.helpText ? param : { ...param, helpText: helpText as string | undefined }; + }); + return { action: dialogAction, params: dialogParams }; +} + /** * The runner's ONE `${scope.path}` token grammar — the substitution behind * `interpolateTarget` (url / api targets and the `onSuccess.navigate` hop) and diff --git a/ui/packages/core/src/actions/__tests__/ActionRunner.copyEnvelope-27948.test.ts b/ui/packages/core/src/actions/__tests__/ActionRunner.copyEnvelope-27948.test.ts new file mode 100644 index 00000000000..54ebc5c0b6b --- /dev/null +++ b/ui/packages/core/src/actions/__tests__/ActionRunner.copyEnvelope-27948.test.ts @@ -0,0 +1,94 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27948 (ruling A, step 2) — the param dialog's `description`, + * each param's `helpText` and an `outcomeMessages` entry become ADR-0032 + * `template` slots, so a parsed action carries + * `{ dialect: 'template', source }` where it carried a string. + * + * The runner read all three as strings: the dialog drew `[object Object]` + * (or the host's `String(…)` of it) and the outcome toast fell back to the + * generic success copy — the objectui#13367 trap. Pinned here: an envelope + * reads exactly as the bare string it stands for, through the real runner and + * the template engine, on what the host is HANDED — the action and params the + * param-collection handler receives (each slot already its text) and the text + * of the success toast. + */ +import { describe, it, expect, vi } from 'vitest'; +import { ActionRunner, type ActionDef, type ActionResult } from '../ActionRunner'; + +const ROW = { id: 'acct_1', display_name: 'Acme' }; +const envelope = (source: string) => ({ dialect: 'template' as const, source }); + +const withDialogCopy = (wrap: (s: string) => unknown): ActionDef => ({ + name: 'pause_account', + label: 'Pause', + description: wrap('Pause {{ record.display_name }}?') as ActionDef['description'], + actionParams: [{ name: 'reason', label: 'Reason', type: 'text', helpText: wrap('Why pause ${record.display_name}?') as string }], + onClick: () => {}, +}); + +/** What the param-collection handler is handed, and what it draws through `interpolate`. */ +async function handed(def: ActionDef) { + const runner = new ActionRunner({ objectName: 'crm_account', record: { ...ROW } }); + let seen: { description: unknown; helpText: unknown; drawn: { description: string; helpText: string } } | undefined; + runner.setParamCollectionHandler(async (params, action, interpolate) => { + seen = { + description: action?.description, + helpText: params[0].helpText, + drawn: { + description: interpolate!(String(action?.description)), + helpText: interpolate!(String(params[0].helpText)), + }, + }; + return null; + }); + await runner.execute(def); + expect(seen).toBeDefined(); + return seen!; +} + +describe('param dialog copy written as the template envelope (objectstack#27948)', () => { + it('the handler receives each slot as its text, and draws what the bare string draws', async () => { + const fromEnvelope = await handed(withDialogCopy(envelope)); + expect(fromEnvelope.description).toBe('Pause {{ record.display_name }}?'); + expect(fromEnvelope.helpText).toBe('Why pause ${record.display_name}?'); + expect(fromEnvelope.drawn).toEqual({ description: 'Pause Acme?', helpText: 'Why pause Acme?' }); + expect(fromEnvelope).toEqual(await handed(withDialogCopy((s) => s))); + }); + + it('CONTROL: a locale-map description reaches the handler unchanged, for the host to localize', async () => { + const map = { en: 'Pause it?', 'zh-CN': '暂停?' }; + const seen = await handed({ ...withDialogCopy((s) => s), description: map as unknown as string }); + expect(seen.description).toBe(map); + }); +}); + +describe('an outcomeMessages entry written as the template envelope (objectstack#27948)', () => { + const answer: ActionResult = { success: true, data: { outcome: 'paused', name: 'prod' } }; + const toastFor = async (paused: unknown): Promise => { + const runner = new ActionRunner({ objectName: 'crm_account' }); + const toast = vi.fn(); + runner.setToastHandler(toast); + runner.registerHandler('custom', async () => answer); + await runner.execute({ name: 'pause_environment', type: 'custom' as never, outcomeMessages: { paused } } as ActionDef); + const calls = toast.mock.calls.filter(([, options]) => options?.type === 'success'); + expect(calls).toHaveLength(1); + return calls[0][0] as string; + }; + + it('fills the envelope\'s holes exactly as the bare string\'s', async () => { + expect(await toastFor(envelope('Environment {{ result.name }} paused'))).toBe('Environment prod paused'); + expect(await toastFor(envelope('Environment ${result.name} paused'))).toBe('Environment prod paused'); + }); + + it('an envelope with no source falls through to the next rung, as a blank entry does', async () => { + expect(await toastFor({ dialect: 'template' })).toBe(await toastFor('')); + }); +}); diff --git a/ui/packages/core/src/actions/actionCopyTemplate.ts b/ui/packages/core/src/actions/actionCopyTemplate.ts index 4b5223305a0..873607da60d 100644 --- a/ui/packages/core/src/actions/actionCopyTemplate.ts +++ b/ui/packages/core/src/actions/actionCopyTemplate.ts @@ -51,6 +51,25 @@ export function actionCopySource(copy: unknown): string | undefined { return undefined; } +/** + * A copy value with a `template` envelope reduced to its text, for a reader + * that takes a string or an `I18nLabel` locale map: the envelope's `source` + * (`undefined` when it carries none), anything else — a string, a locale map, + * `undefined` — handed back unchanged. + * + * The slots an action's param dialog and success toast read beside + * `confirmText` — `description`, each param's `helpText`, an + * `outcomeMessages` entry — are `template` slots too (objectstack#27948, + * ruling A, step 2), so a served value can be the envelope where it was a + * string. The readers that localize them (a bundle lookup over the literal, + * `pickLocalized` over a locale map) take a string, so the envelope is + * reduced here first; a locale map stays one, as its own reader resolves it. + */ +export function actionCopyText(copy: T): Exclude | string | undefined { + if (isActionCopyEnvelope(copy)) return actionCopySource(copy); + return copy as Exclude; +} + /** A `{{ … }}` hole, as the template engine delimits one. */ const TEMPLATE_HOLE = /\{\{[^}]*\}\}/; diff --git a/ui/packages/fields/src/resolveActionParams.ts b/ui/packages/fields/src/resolveActionParams.ts index e9acb2230f0..8e26b92cd2e 100644 --- a/ui/packages/fields/src/resolveActionParams.ts +++ b/ui/packages/fields/src/resolveActionParams.ts @@ -47,7 +47,7 @@ * rejects it, the server's `.strict()` parse rejects it, and this resolver * names it via {@link RESOLVED_ONLY_PARAM_KEYS} rather than reading it. */ -import { EXPANDABLE_FIELD_TYPES, toPredicateRecord } from '@object-ui/core'; +import { EXPANDABLE_FIELD_TYPES, actionCopySource, toPredicateRecord } from '@object-ui/core'; import type { ActionParamDef, ActionParamOption } from '@object-ui/core'; // The form's widget fold. Imported from the barrel this module is re-exported // by — a cycle in the SOURCE graph only, and a call-time one: nothing at this @@ -176,7 +176,13 @@ export interface RawActionParam { */ options?: RawActionParamOption[]; placeholder?: string; - helpText?: string; + /** + * The hint under the input. A `template` slot since objectstack#27948: the + * bare string, or the parsed `{ dialect: 'template', source }` envelope a + * served param carries; read through `actionCopySource`, so the resolved + * param's `helpText` is always the text. + */ + helpText?: unknown; defaultValue?: unknown; /** When true, seed defaultValue from the row record using the field name. */ defaultFromRow?: boolean; @@ -609,7 +615,7 @@ export function resolveActionParam( required: param.required ?? false, options: authoredOptions, placeholder: param.placeholder, - helpText: param.helpText, + helpText: actionCopySource(param.helpText), defaultValue: rowDefault ?? param.defaultValue, visible: normaliseVisible(param.visible), // This output is built key by key, so an authored key that is not @@ -669,7 +675,7 @@ export function resolveActionParam( required: param.required ?? false, options: authoredOptions, placeholder: param.placeholder, - helpText: param.helpText, + helpText: actionCopySource(param.helpText), defaultValue: rowDefault ?? param.defaultValue, visible: normaliseVisible(param.visible), carryOver: param.carryOver, @@ -834,7 +840,7 @@ export function resolveActionParam( // form shares (`fieldHelpText`, objectui#12213): the spec's // `inlineHelpText`, then `description`, an empty string reading as absent. // A served `help` key is not read. - helpText: param.helpText ?? fieldHelpText(field), + helpText: actionCopySource(param.helpText) ?? fieldHelpText(field), defaultValue: rowDefault ?? param.defaultValue ?? field.defaultValue, visible: normaliseVisible(param.visible), // A declaration of the PARAM, never inherited from the field. diff --git a/ui/packages/react/src/hooks/__tests__/useActionTextLocalizer.outcomeEnvelope-27948.test.tsx b/ui/packages/react/src/hooks/__tests__/useActionTextLocalizer.outcomeEnvelope-27948.test.tsx new file mode 100644 index 00000000000..9ba43a5e720 --- /dev/null +++ b/ui/packages/react/src/hooks/__tests__/useActionTextLocalizer.outcomeEnvelope-27948.test.tsx @@ -0,0 +1,109 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27948 (ruling A, step 2) — an `outcomeMessages` entry becomes a + * `template` slot like `successMessage`, so a parsed action can carry + * `{ dialect: 'template', source }` where it carried a string. + * + * Read as an `I18nLabel` map, `pickLocalized` collapsed that envelope to its + * first string value — the word `template` — and the toast said "template". + * The localizer now keeps the envelope (a bundle entry replaces its `source`), + * and the runner fills its `{{ }}` holes. Measured through the real path: a + * real `I18nProvider`, a real ``, the text the host's + * `onToast` receives. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import React from 'react'; +import { I18nProvider, useObjectTranslation } from '@object-ui/i18n'; +import type { ActionDef, ActionResult, ToastHandler } from '@object-ui/core'; +import { ActionProvider, useAction } from '../../context/ActionContext'; +import { useActionTextLocalizer } from '../useActionTextLocalizer'; + +afterEach(() => cleanup()); + +const ANSWER: ActionResult = { success: true, data: { outcome: 'archived', name: 'prod' } }; +const ENVELOPE = { dialect: 'template', source: 'Environment {{ result.name }} archived' }; + +const ACTION = { + name: 'delete_environment', + type: 'script', + label: 'Delete environment', + outcomeMessages: { archived: ENVELOPE }, +}; + +function harness(language: 'en' | 'zh-CN', onToast: ToastHandler) { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + ANSWER }}> + {children} + + + ); + return renderHook( + () => ({ action: useAction(), localize: useActionTextLocalizer(), translation: useObjectTranslation() }), + { wrapper }, + ); +} + +function successToast(onToast: ReturnType>): string { + const calls = onToast.mock.calls.filter(([, options]) => options?.type === 'success'); + expect(calls).toHaveLength(1); + return calls[0][0]; +} + +describe('an outcomeMessages entry written as the template envelope (objectstack#27948)', () => { + it('no bundle entry — the envelope comes back as written and the toast fills its hole, never "template"', async () => { + const onToast = vi.fn(); + const { result } = harness('en', onToast); + const localized = result.current.localize('environment', ACTION); + expect(localized.outcomeMessages).toEqual({ archived: ENVELOPE }); + + await act(async () => { + await result.current.action.execute(localized as ActionDef); + }); + expect(successToast(onToast)).toBe('Environment prod archived'); + }); + + it('a bundle entry replaces the source and keeps the envelope; the toast reads the translation', async () => { + const onToast = vi.fn(); + const { result } = harness('zh-CN', onToast); + result.current.translation.i18n.addResourceBundle( + 'zh-CN', + 'translation', + { + cloud: { + objects: { + environment: { + _actions: { delete_environment: { outcomeMessages: { archived: '环境 {{ result.name }} 已归档' } } }, + }, + }, + }, + }, + true, + true, + ); + const localized = result.current.localize('environment', ACTION); + expect(localized.outcomeMessages).toEqual({ archived: { dialect: 'template', source: '环境 {{ result.name }} 已归档' } }); + + await act(async () => { + await result.current.action.execute(localized as ActionDef); + }); + expect(successToast(onToast)).toBe('环境 prod 已归档'); + }); + + it('CONTROL: a string entry and an I18nLabel map entry read as before', () => { + const { result } = harness('zh-CN', vi.fn()); + const localized = result.current.localize('environment', { + ...ACTION, + outcomeMessages: { archived: 'Archived {{ result.name }}', destroyed: { en: 'Destroyed', 'zh-CN': '已删除' } }, + }); + expect(localized.outcomeMessages).toEqual({ archived: 'Archived {{ result.name }}', destroyed: '已删除' }); + }); +}); diff --git a/ui/packages/react/src/hooks/useActionTextLocalizer.ts b/ui/packages/react/src/hooks/useActionTextLocalizer.ts index fe61d81db4a..e78310b6dca 100644 --- a/ui/packages/react/src/hooks/useActionTextLocalizer.ts +++ b/ui/packages/react/src/hooks/useActionTextLocalizer.ts @@ -83,9 +83,10 @@ * the token in after the action succeeds, through the same `${result.*}` * scope `onSuccess.navigate` reads — one interpolation, in one place. * - **The `template` envelope keeps its shape** (objectui#13367, the renderer - * half of objectstack#24814). `confirmText`, `successMessage` and - * `errorMessage` may arrive as `{ dialect: 'template', source }` instead of - * a string. The envelope is not an `I18nLabel` map — read as one it would + * half of objectstack#24814). `confirmText`, `successMessage`, + * `errorMessage` and each `outcomeMessages` entry (objectstack#27948) may + * arrive as `{ dialect: 'template', source }` instead of a string. The + * envelope is not an `I18nLabel` map — read as one it would * collapse to its first string value, the word `template` — so its `source` * is what the bundle's fallback and answer replace, and the runner receives * the envelope and fills its `{{ }}` holes (`ActionRunner`'s copy reads). @@ -218,9 +219,15 @@ export function useActionTextLocalizer(): ActionTextLocalizer { // real bag?" predicate in this package answers whether to read it. const authoredOutcomes: unknown = action.outcomeMessages; if (isConfigBag(authoredOutcomes)) { - const outcomeMessages: Record = {}; + // Each entry is a `template` slot like `successMessage` + // (objectstack#27948, ruling A): an envelope keeps its shape and has + // its `source` offered to the bundle, never collapsed as a locale map. + const outcomeMessages: Record = {}; for (const [outcome, copy] of Object.entries(authoredOutcomes)) { - const text = actionOutcome(keyObject, name, outcome, pickLocalized(copy, language)); + const text = resolveCopy( + collapseCopy(copy, language), + (fallback) => actionOutcome(keyObject, name, outcome, fallback), + ); if (text !== undefined) outcomeMessages[outcome] = text; } out.outcomeMessages = outcomeMessages;