diff --git a/.changeset/28879-action-dialog-cancel-text.md b/.changeset/28879-action-dialog-cancel-text.md new file mode 100644 index 00000000000..3b8d752b6b7 --- /dev/null +++ b/.changeset/28879-action-dialog-cancel-text.md @@ -0,0 +1,24 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/components': patch +'@object-ui/core': patch +'@object-ui/i18n': patch +'@object-ui/react': patch +'@object-ui/types': patch +'@objectstack/cli': patch +'@objectstack/platform-objects': patch +'@objectstack/spec': patch +--- + +feat(console): an action's confirm and param dialogs name their dismiss button — `cancelText` is drawn in place of "Cancel" + +Clause-②: yes (widening) +Metadata-protocol: none + +`@object-ui/types` `UIActionSchema` and `@object-ui/core` `ActionDef` gain the optional member `cancelText`, typed by derivation from the spec's `ActionSchema`; `@object-ui/i18n` `useObjectLabel()` gains the resolver `actionCancelText`. Nothing is narrowed. + +- **The console.** The dismiss button of the dialog an action opens before it runs draws the action's `cancelText`: on the confirm dialog of an action with `confirmText`, and on the param dialog of an action with `params`. Both paths resolve it the same way: the translation bundle's `objects.OBJECT._actions.ACTION.cancelText` (`globalActions.ACTION.cancelText` for an object-less action) first, then the authored inline locale map collapsed to the active language, then the authored string. An action that names no label keeps "Cancel". `action:button`, `action:icon`, `action:group` and `action:menu` forward the key to the runner, which hands it to the confirm dialog. +- **Setup → Invitations.** **Cancel Invitation** now asks with **Keep invitation** beside its confirm button, translated in every shipped locale. +- **Studio.** The action form offers the key ("Cancel button label"), translated in every shipped locale. +- **`os i18n extract`.** Scaffolds `objects.OBJECT._actions.ACTION.cancelText` (or `globalActions.ACTION.cancelText`) for an action that declares `confirmText` or a non-empty `params`, the actions `translateAction` overlays it onto, and no slot for any other action. +- **Spec.** The liveness ledger row for `ActionSchema.cancelText` is `live`, with its author warning dropped; the key's and the translation slot's descriptions no longer say no renderer shows it. diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 8f9cc8c91a3..f384eb4f9bc 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -538,7 +538,7 @@ params: - It is not `confirmText`. `confirmText` is the message of the confirm dialog a param-less action opens; `submitText` is the button of the param dialog an action with params opens instead. - A blank string (`''` or whitespace only) is refused at parse. Omit the key to keep **Confirm**. - `submitLabel` and `confirmLabel` (the button's spelling on a view's `bulkActionDefs`) are refused with a rename to `submitText`. -- The console does not draw it yet: until its param dialog reads the key, the button says **Confirm**, and `os validate` warns an author who declares it. +- The console draws it on the param dialog's submit button. Without it, the button says **Confirm**, and while a file param uploads it says **Uploading…** either way. ### Naming the dismiss button: `cancelText` @@ -558,7 +558,7 @@ cancelText: Keep invitation - **Either pre-run dialog.** It labels the dismiss button of the confirm dialog a param-less action with `confirmText` opens, and of the param dialog an action with a non-empty `params` opens (beside that dialog's `submitText`). An action that declares neither opens no dialog, so `cancelText` there is refused at parse, and a translation of it under such an action is reported by `os validate` (`translation-target-unknown`). - A blank string (`''` or whitespace only) is refused at parse. Omit the key to keep **Cancel**. - `cancelLabel` (the button's spelling on the console's `form` and `alert-dialog` renderers) is refused with a rename to `cancelText`. -- The console does not draw it yet: until its confirm and param dialogs read the key, the button says **Cancel**, and `os validate` warns an author who declares it. +- The console draws it on the dismiss button of both dialogs, from the translation bundle first and then the authored text. Without it, the button says **Cancel**. `sys_invitation`'s **Cancel Invitation** action declares **Keep invitation**. ## AI Tool Exposure (ADR-0011) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index b844585ad1b..753c44d051e 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -491,7 +491,7 @@ const result = ApiMethod.parse(data); | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). A param name (its `name`, or the `field` a field-backed param reads) may repeat only for per-gate variants of ONE input (declarations that differ in `visible` / `requiresFeature`, e.g. one optional and one required); the params check treats the name as `required` only when EVERY declaration of it requires it, and judges its value by the FIRST declaration's shape (`type`, `multiple`, `options`), so keep that shape identical across them. | | **submitText** | `string \| Record` | optional | Label of the submit button in the action's param dialog, the verb the button performs (e.g. "Send", "Log Call"), in place of the default "Confirm" — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.submitText` (`globalActions.ACTION.submitText` for an object-less action). Only on an action with a non-empty `params`: without one no param dialog opens, so it is refused. Not `confirmText`, which is the message of a param-less action's confirm dialog. A blank label is refused. | -| **cancelText** | `string \| Record` | optional | Label of the dismiss button on the dialog the action opens before it runs — the confirm dialog of a param-less action that declares `confirmText`, or the param dialog of an action with a non-empty `params` — in place of the default "Cancel" (declared ahead of its console reader: no renderer shows it yet). Name what dismissing keeps where the action's own verb is "Cancel …" or "Close …" (e.g. "Keep invitation" beside "Cancel Invitation"). The default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.cancelText` (`globalActions.ACTION.cancelText` for an object-less action). Only on an action that opens one of those dialogs: with neither `confirmText` nor a non-empty `params` no dialog opens, so it is refused. The dismiss twin of `submitText`, as on `object-form`. A blank label is refused. | +| **cancelText** | `string \| Record` | optional | Label of the dismiss button on the dialog the action opens before it runs — the confirm dialog of a param-less action that declares `confirmText`, or the param dialog of an action with a non-empty `params` — in place of the default "Cancel". Name what dismissing keeps where the action's own verb is "Cancel …" or "Close …" (e.g. "Keep invitation" beside "Cancel Invitation"). The default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.cancelText` (`globalActions.ACTION.cancelText` for an object-less action). Only on an action that opens one of those dialogs: with neither `confirmText` nor a non-empty `params` no dialog opens, so it is refused. The dismiss twin of `submitText`, as on `object-form`. A blank label is refused. | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. | | **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. 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. | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 32288581606..acd5d581777 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -336,7 +336,7 @@ const result = MetadataBulkResultSchema.parse(data); | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). A param name (its `name`, or the `field` a field-backed param reads) may repeat only for per-gate variants of ONE input (declarations that differ in `visible` / `requiresFeature`, e.g. one optional and one required); the params check treats the name as `required` only when EVERY declaration of it requires it, and judges its value by the FIRST declaration's shape (`type`, `multiple`, `options`), so keep that shape identical across them. | | **submitText** | `string \| Record` | optional | Label of the submit button in the action's param dialog, the verb the button performs (e.g. "Send", "Log Call"), in place of the default "Confirm" — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.submitText` (`globalActions.ACTION.submitText` for an object-less action). Only on an action with a non-empty `params`: without one no param dialog opens, so it is refused. Not `confirmText`, which is the message of a param-less action's confirm dialog. A blank label is refused. | -| **cancelText** | `string \| Record` | optional | Label of the dismiss button on the dialog the action opens before it runs — the confirm dialog of a param-less action that declares `confirmText`, or the param dialog of an action with a non-empty `params` — in place of the default "Cancel" (declared ahead of its console reader: no renderer shows it yet). Name what dismissing keeps where the action's own verb is "Cancel …" or "Close …" (e.g. "Keep invitation" beside "Cancel Invitation"). The default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.cancelText` (`globalActions.ACTION.cancelText` for an object-less action). Only on an action that opens one of those dialogs: with neither `confirmText` nor a non-empty `params` no dialog opens, so it is refused. The dismiss twin of `submitText`, as on `object-form`. A blank label is refused. | +| **cancelText** | `string \| Record` | optional | Label of the dismiss button on the dialog the action opens before it runs — the confirm dialog of a param-less action that declares `confirmText`, or the param dialog of an action with a non-empty `params` — in place of the default "Cancel". Name what dismissing keeps where the action's own verb is "Cancel …" or "Close …" (e.g. "Keep invitation" beside "Cancel Invitation"). The default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.cancelText` (`globalActions.ACTION.cancelText` for an object-less action). Only on an action that opens one of those dialogs: with neither `confirmText` nor a non-empty `params` no dialog opens, so it is refused. The dismiss twin of `submitText`, as on `object-form`. A blank label is refused. | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. | | **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. 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. | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 13bc12e42b0..9d7885a2d04 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -131,7 +131,7 @@ Translation data for a single field | **successMessage** | `string` | optional | Translated success toast/message | | **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | | **submitText** | `string` | optional | Translated label of the submit button in the action's param dialog (the action must declare `params`) | -| **cancelText** | `string` | optional | Translated label of the dismiss button on the action's confirm or param dialog (the action must declare `confirmText` or `params`; no renderer shows it yet) | +| **cancelText** | `string` | optional | Translated label of the dismiss button on the action's confirm or param dialog (the action must declare `confirmText` or `params`) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -235,7 +235,7 @@ Translation data for a single object | **successMessage** | `string` | optional | Translated success toast/message | | **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | | **submitText** | `string` | optional | Translated label of the submit button in the action's param dialog (the action must declare `params`) | -| **cancelText** | `string` | optional | Translated label of the dismiss button on the action's confirm or param dialog (the action must declare `confirmText` or `params`; no renderer shows it yet) | +| **cancelText** | `string` | optional | Translated label of the dismiss button on the action's confirm or param dialog (the action must declare `confirmText` or `params`) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -412,7 +412,7 @@ Translation data for a single object | **successMessage** | `string` | optional | Translated success toast/message | | **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | | **submitText** | `string` | optional | Translated label of the submit button in the action's param dialog (the action must declare `params`) | -| **cancelText** | `string` | optional | Translated label of the dismiss button on the action's confirm or param dialog (the action must declare `confirmText` or `params`; no renderer shows it yet) | +| **cancelText** | `string` | optional | Translated label of the dismiss button on the action's confirm or param dialog (the action must declare `confirmText` or `params`) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -651,7 +651,7 @@ Translation data for a single object | **successMessage** | `string` | optional | Translated success toast/message | | **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | | **submitText** | `string` | optional | Translated label of the submit button in the action's param dialog (the action must declare `params`) | -| **cancelText** | `string` | optional | Translated label of the dismiss button on the action's confirm or param dialog (the action must declare `confirmText` or `params`; no renderer shows it yet) | +| **cancelText** | `string` | optional | Translated label of the dismiss button on the action's confirm or param dialog (the action must declare `confirmText` or `params`) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -860,7 +860,7 @@ Translation data for a single object | **successMessage** | `string` | optional | Translated success toast/message | | **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | | **submitText** | `string` | optional | Translated label of the submit button in the action's param dialog (the action must declare `params`) | -| **cancelText** | `string` | optional | Translated label of the dismiss button on the action's confirm or param dialog (the action must declare `confirmText` or `params`; no renderer shows it yet) | +| **cancelText** | `string` | optional | Translated label of the dismiss button on the action's confirm or param dialog (the action must declare `confirmText` or `params`) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 0817ba24829..e1abaf0d5b4 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -44,7 +44,7 @@ const result = ActionSchema.parse(data); | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). A param name (its `name`, or the `field` a field-backed param reads) may repeat only for per-gate variants of ONE input (declarations that differ in `visible` / `requiresFeature`, e.g. one optional and one required); the params check treats the name as `required` only when EVERY declaration of it requires it, and judges its value by the FIRST declaration's shape (`type`, `multiple`, `options`), so keep that shape identical across them. | | **submitText** | `string \| Record` | optional | Label of the submit button in the action's param dialog, the verb the button performs (e.g. "Send", "Log Call"), in place of the default "Confirm" — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.submitText` (`globalActions.ACTION.submitText` for an object-less action). Only on an action with a non-empty `params`: without one no param dialog opens, so it is refused. Not `confirmText`, which is the message of a param-less action's confirm dialog. A blank label is refused. | -| **cancelText** | `string \| Record` | optional | Label of the dismiss button on the dialog the action opens before it runs — the confirm dialog of a param-less action that declares `confirmText`, or the param dialog of an action with a non-empty `params` — in place of the default "Cancel" (declared ahead of its console reader: no renderer shows it yet). Name what dismissing keeps where the action's own verb is "Cancel …" or "Close …" (e.g. "Keep invitation" beside "Cancel Invitation"). The default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.cancelText` (`globalActions.ACTION.cancelText` for an object-less action). Only on an action that opens one of those dialogs: with neither `confirmText` nor a non-empty `params` no dialog opens, so it is refused. The dismiss twin of `submitText`, as on `object-form`. A blank label is refused. | +| **cancelText** | `string \| Record` | optional | Label of the dismiss button on the dialog the action opens before it runs — the confirm dialog of a param-less action that declares `confirmText`, or the param dialog of an action with a non-empty `params` — in place of the default "Cancel". Name what dismissing keeps where the action's own verb is "Cancel …" or "Close …" (e.g. "Keep invitation" beside "Cancel Invitation"). The default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.cancelText` (`globalActions.ACTION.cancelText` for an object-less action). Only on an action that opens one of those dialogs: with neither `confirmText` nor a non-empty `params` no dialog opens, so it is refused. The dismiss twin of `submitText`, as on `object-form`. A blank label is refused. | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. | | **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. 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. | diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 3f93873f74e..33cb3382ee6 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -88,6 +88,7 @@ * objects.._actions..successMessage * objects.._actions..disabledReason * objects.._actions..submitText (only beside a non-empty `params`) + * objects.._actions..cancelText (only beside `confirmText` or a non-empty `params`) * objects.._actions..outcomeMessages. * objects.._actions..params..label / .helpText / .placeholder * objects.._actions..params..options. @@ -95,6 +96,7 @@ * objects.._actions..resultDialog.fields. * globalActions..label / .description / .confirmText / .successMessage * / .disabledReason / .submitText (beside a non-empty `params`) + * / .cancelText (beside `confirmText` or a non-empty `params`) * globalActions..outcomeMessages. * globalActions..params..* / .resultDialog.* (same shape as object actions) * apps..label / .description @@ -771,6 +773,29 @@ function pushActionSubmitText( pushOptional(out, [...actionRoot, 'submitText'], action.submitText, source, { objectName }); } +/** + * Emit `cancelText`, the dismiss button label of the dialog an action opens + * before it runs, under the action's translation root — OPTIONAL-not-derived + * like `submitText` (an action that names no label shows the console's own + * "Cancel"), and only for an action that opens one of the two dialogs carrying + * that button: the confirm dialog of a declared `confirmText`, or the param + * dialog of a non-empty `params`. Those are the actions `translateAction` + * overlays the key onto; any other action gets no slot, so a translation filed + * there stays dangling. + */ +function pushActionCancelText( + out: ExpectedEntry[], + actionRoot: string[], + action: any, + source: ExpectedEntry['source'], + objectName: string | undefined, +): void { + const opensDialog = action?.confirmText != null + || (Array.isArray(action?.params) && action.params.length > 0); + if (!opensDialog) return; + pushOptional(out, [...actionRoot, 'cancelText'], action.cancelText, source, { objectName }); +} + /** * Emit `params..{label,helpText,placeholder}` and * `params..options.` entries under an action's translation root. @@ -1530,6 +1555,7 @@ export function collectExpectedEntries( pushOptional(out, [...aroot, 'successMessage'], actionCopy(action.successMessage), 'action', { objectName }); pushOptional(out, [...aroot, 'disabledReason'], action.disabledReason, 'action', { objectName }); pushActionSubmitText(out, aroot, action, 'action', objectName); + pushActionCancelText(out, aroot, action, 'action', objectName); pushActionOutcomeMessages(out, aroot, action, 'action', objectName); pushActionParams(out, ['objects', objectName, '_actions', aname], action, 'action', objectName); pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName); @@ -1628,6 +1654,7 @@ export function collectExpectedEntries( // seed for it. pushOptional(out, [...root, 'disabledReason'], action.disabledReason, kind, { objectName }); pushActionSubmitText(out, root, action, kind, objectName); + pushActionCancelText(out, root, action, kind, objectName); pushActionOutcomeMessages(out, root, action, kind, objectName); pushActionParams(out, root, action, kind, objectName); pushActionResultDialog(out, root, action, kind, objectName); diff --git a/packages/cli/test/i18n-extract-action-cancel-text.test.ts b/packages/cli/test/i18n-extract-action-cancel-text.test.ts new file mode 100644 index 00000000000..6385c8a3e5b --- /dev/null +++ b/packages/cli/test/i18n-extract-action-cancel-text.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// The extractor half of `action.cancelText`, the label of the dismiss button on +// the dialog an action opens before it runs. +// +// `translateAction` overlays `objects.OBJECT._actions.ACTION.cancelText` +// (`globalActions.ACTION.cancelText` for an object-less action) only onto an +// action that opens one of the two dialogs carrying that button: the confirm +// dialog of a declared `confirmText`, or the param dialog of a non-empty +// `params`. `os i18n extract` scaffolds the slot on exactly that population: +// there it is an optional slot like `submitText`, and anywhere else there is no +// slot at all. The last test walks ONE fixture action through all three +// surfaces — `ActionSchema` accepts it, the extractor emits its key, the +// translation schema accepts the bundle written at that key — because each half +// passing alone does not prove the addresses agree. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage'; +import { ActionSchema } from '@objectstack/spec/ui'; +import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; + +const paths = (config: any) => collectExpectedEntries(config).map((e) => e.path.join('.')); + +const PARAMS = [{ name: 'reason', label: 'Reason', type: 'text' }]; + +describe('the extractor emits action-level `cancelText` beside `confirmText` or a non-empty `params`', () => { + it('emits it for a TOP-LEVEL object action with a confirm dialog', () => { + const keys = paths({ + actions: [{ + name: 'cancel_invitation', objectName: 'sys_invitation', label: 'Cancel Invitation', + confirmText: 'Cancel this invitation?', cancelText: 'Keep invitation', + }], + }); + + expect(keys).toContain('objects.sys_invitation._actions.cancel_invitation.cancelText'); + }); + + it('emits it for a GLOBAL action with a param dialog', () => { + const keys = paths({ + actions: [{ name: 'close_period', label: 'Close Period', params: PARAMS, cancelText: 'Keep open' }], + }); + + expect(keys).toContain('globalActions.close_period.cancelText'); + }); + + it('emits it for an action declared INLINE on the object', () => { + const keys = paths({ + objects: [ + { + name: 'sys_invitation', + label: 'Invitation', + actions: [{ name: 'cancel_invitation', label: 'Cancel Invitation', confirmText: 'Sure?', cancelText: 'Keep invitation' }], + }, + ], + }); + + expect(keys).toContain('objects.sys_invitation._actions.cancel_invitation.cancelText'); + }); + + it('records a seed-less slot for a dialog action that names no label — the dialog keeps "Cancel"', () => { + const config = { + objects: [{ name: 'sys_invitation', label: 'Invitation', actions: [{ name: 'resend', label: 'Resend', confirmText: 'Resend it?' }] }], + }; + const key = 'objects.sys_invitation._actions.resend.cancelText'; + const entry = collectExpectedEntries(config).find((e) => e.path.join('.') === key); + + expect(entry).toBeDefined(); + expect(entry?.inline).toBeUndefined(); + expect(entry?.sourceValue).toBeUndefined(); + + const { bundles } = extractTranslations(config, { defaultLocale: 'en', fill: 'default' }); + expect(bundles.en.objects?.sys_invitation?._actions?.resend).not.toHaveProperty('cancelText'); + expect(computeI18nCoverage({ ...config, translations: [{ 'zh-CN': {} }] }).issues.map((i) => i.key)) + .not.toContain(key); + }); + + it('emits NO slot for an action that opens no dialog — neither `confirmText` nor a non-empty `params`', () => { + for (const params of [undefined, []]) { + const keys = paths({ + objects: [ + { + name: 'sys_invitation', + label: 'Invitation', + actions: [{ name: 'archive', label: 'Archive', ...(params ? { params } : {}) }], + }, + ], + actions: [{ name: 'refresh_all', label: 'Refresh', ...(params ? { params } : {}) }], + }); + + expect(keys).toContain('objects.sys_invitation._actions.archive.label'); + expect(keys).not.toContain('objects.sys_invitation._actions.archive.cancelText'); + expect(keys).toContain('globalActions.refresh_all.label'); + expect(keys).not.toContain('globalActions.refresh_all.cancelText'); + } + }); + + it('carries the declared label into the bundle, and gates it until it is translated', () => { + const config: any = { + objects: [ + { + name: 'sys_invitation', + label: 'Invitation', + actions: [{ name: 'cancel_invitation', label: 'Cancel Invitation', confirmText: 'Sure?', cancelText: 'Keep invitation' }], + }, + ], + translations: [ + { 'zh-CN': { objects: { sys_invitation: { label: '邀请', _actions: { cancel_invitation: { label: '取消邀请', confirmText: '确定?' } } } } } }, + ], + }; + const key = 'objects.sys_invitation._actions.cancel_invitation.cancelText'; + + const entry = collectExpectedEntries(config).find((e) => e.path.join('.') === key); + expect(entry?.source).toBe('action'); + expect(entry?.inline).toBe('Keep invitation'); + + const { bundles } = extractTranslations(config, { defaultLocale: 'en', fill: 'default' }); + expect(bundles.en.objects?.sys_invitation?._actions?.cancel_invitation.cancelText).toBe('Keep invitation'); + + expect(computeI18nCoverage(config).issues.map((i) => i.key)).toContain(key); + config.translations[0]['zh-CN'].objects.sys_invitation._actions.cancel_invitation.cancelText = '保留邀请'; + expect(computeI18nCoverage(config).issues.map((i) => i.key)).not.toContain(key); + }); +}); + +describe('one fixture action, all three surfaces (cancelText end-to-end)', () => { + const authored = { + name: 'cancel_invitation', + objectName: 'sys_invitation', + label: 'Cancel Invitation', + type: 'script', + target: 'cancelInvitation', + confirmText: 'Cancel this invitation?', + cancelText: 'Keep invitation', + }; + + it('parses as metadata, extracts to a key, and that key round-trips a bundle', () => { + const parsed = ActionSchema.safeParse(authored); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.cancelText).toBe('Keep invitation'); + + const { bundles } = extractTranslations({ actions: [authored] }, { + defaultLocale: 'en', + locales: ['zh-CN'], + fill: 'empty', + }); + const bundle: any = bundles['zh-CN']; + expect(bundle.objects.sys_invitation._actions.cancel_invitation).toHaveProperty('cancelText'); + + bundle.objects.sys_invitation._actions.cancel_invitation.cancelText = '保留邀请'; + const translated = ObjectTranslationDataSchema.safeParse(bundle.objects.sys_invitation); + expect(translated.success).toBe(true); + expect(translated.success && translated.data._actions?.cancel_invitation?.cancelText).toBe('保留邀请'); + }); +}); 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 b2dea4377af..007c270574a 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 @@ -1815,6 +1815,10 @@ export const enMetadataForms: NonNullable = { label: "Submit button label", helpText: "Label of the submit button in this action's param dialog, the verb the button performs (e.g. \"Send\", \"Log Call\"). Only for an action that collects params. Leave empty to show \"Confirm\"." }, + cancelText: { + label: "Cancel button label", + helpText: "Label of the dismiss button on the dialog this action opens before it runs, its confirm dialog or its param dialog. Name what dismissing keeps where the action's own verb is \"Cancel …\" (e.g. \"Keep invitation\"). Only for an action that asks for confirmation or collects params. Leave empty to show \"Cancel\"." + }, confirmText: { label: "Confirm Text", helpText: "Confirmation message (e.g., \"Are you sure?\")" 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 c367f412d99..dc45de1430d 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -844,7 +844,8 @@ export const enObjects: NonNullable = { cancel_invitation: { label: "Cancel Invitation", confirmText: "Cancel this invitation? The recipient will no longer be able to accept it.", - successMessage: "Invitation canceled" + successMessage: "Invitation canceled", + cancelText: "Keep invitation" }, resend_invitation: { label: "Resend Invitation", 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 ac725b6e2fa..cc3200cbe44 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 @@ -1815,6 +1815,10 @@ export const esESMetadataForms: NonNullable = label: "Etiqueta del botón de envío", helpText: "Etiqueta del botón de envío en el diálogo de parámetros de esta acción, el verbo que ejecuta el botón (p. ej., \"Enviar\", \"Registrar llamada\"). Solo para una acción que recoge parámetros. Déjalo vacío para mostrar \"Confirmar\"." }, + cancelText: { + label: "Etiqueta del botón de cancelar", + helpText: "Etiqueta del botón para descartar el diálogo que abre esta acción antes de ejecutarse, su diálogo de confirmación o su diálogo de parámetros. Cuando el verbo de la propia acción es \"Cancelar …\", indica lo que se conserva al descartar (p. ej., \"Conservar invitación\"). Solo para una acción que pide confirmación o recoge parámetros. Déjalo vacío para mostrar \"Cancelar\"." + }, confirmText: { label: "Texto de confirmación", helpText: "Mensaje de confirmación (p. ej., \"Are you sure?\")" 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 714697bb73f..95ab342ac57 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 @@ -844,7 +844,8 @@ export const esESObjects: NonNullable = { cancel_invitation: { label: "Cancelar invitación", confirmText: "¿Cancelar esta invitación? El destinatario ya no podrá aceptarla.", - successMessage: "Invitación cancelada" + successMessage: "Invitación cancelada", + cancelText: "Conservar invitación" }, resend_invitation: { label: "Reenviar invitación", 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 9b67141450b..66c656c855c 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 @@ -1815,6 +1815,10 @@ export const jaJPMetadataForms: NonNullable = label: "送信ボタンのラベル", helpText: "このアクションのパラメーターダイアログにある送信ボタンのラベル。ボタンが行う操作を表す動詞です(例:「送信」「通話を記録」)。パラメーターを収集するアクションでのみ使用します。空のままにすると「確認」と表示されます。" }, + cancelText: { + label: "キャンセルボタンのラベル", + helpText: "このアクションの実行前に開くダイアログ(確認ダイアログまたはパラメーターダイアログ)にある閉じるボタンのラベル。アクション自体が「…をキャンセル」である場合は、閉じると何が残るかを書きます(例:「招待を保持」)。確認を求めるか、パラメーターを収集するアクションでのみ使用します。空のままにすると「キャンセル」と表示されます。" + }, confirmText: { label: "確認文", helpText: "確認メッセージ(例: \"Are you sure?\")" 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 4463d80d690..72a9fea6c91 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 @@ -844,7 +844,8 @@ export const jaJPObjects: NonNullable = { cancel_invitation: { label: "招待をキャンセル", confirmText: "この招待をキャンセルしますか?受信者は承認できなくなります。", - successMessage: "招待をキャンセルしました" + successMessage: "招待をキャンセルしました", + cancelText: "招待を保持" }, resend_invitation: { label: "招待を再送", diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 77308bfccd7..c0dd9c47fa0 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1250,7 +1250,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // locales. // 698 since #28574 (ADR-0146 D10): `object_extension` joined the registry // with its type-level display pair, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(698); + // 699 since #28879: the action form's `cancelText` row (the dismiss + // label of the action's confirm and param dialogs) — one row label, + // authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(699); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the 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 e279783d79e..e72385d29e8 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 @@ -1815,6 +1815,10 @@ export const zhCNMetadataForms: NonNullable = label: "提交按钮文字", helpText: "此动作参数对话框中提交按钮的文字,即按钮执行的动作(例如“发送”“记录通话”)。仅用于需要收集参数的动作。留空则显示“确认”。" }, + cancelText: { + label: "取消按钮文字", + helpText: "此动作执行前弹出的对话框(确认框或参数对话框)中取消按钮的文字。动作本身就是“取消……”时,写明取消后保留的是什么(例如“保留邀请”)。仅用于需要确认或收集参数的动作。留空则显示“取消”。" + }, confirmText: { label: "确认文本", helpText: "执行前的确认提示(如 \"确定要执行吗?\")" 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 753ee95416f..3f9b879c367 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 @@ -844,7 +844,8 @@ export const zhCNObjects: NonNullable = { cancel_invitation: { label: "取消邀请", confirmText: "要取消该邀请吗?接收人将无法再接受该邀请。", - successMessage: "邀请已取消" + successMessage: "邀请已取消", + cancelText: "保留邀请" }, resend_invitation: { label: "重新发送邀请", diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index b654a51b15a..01eee93d51f 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -126,6 +126,9 @@ export const SysInvitation = ObjectSchema.create({ requiresFeature: 'organization', requiresMembershipReach: 'cancel_invitation', confirmText: 'Cancel this invitation? The recipient will no longer be able to accept it.', + // The dismiss button names what dismissing keeps: beside a "Cancel + // Invitation" confirm button, a bare "Cancel" reads as the same verb. + cancelText: 'Keep invitation', successMessage: 'Invitation canceled', refreshAfter: true, // ADR-0011 identity-actions note, bucket C: exposed, and the diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 76ca476ebc4..4e6bdf703d7 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -24,12 +24,11 @@ "note": "LIVE since #28158 (the console half of #27944). An action that collects `params` names its own submit verb (\"Send\", \"Log Call\"): the param dialog's submit button draws the translated or authored `submitText` and falls back to \"Confirm\" when the action declares none. While a file param uploads, the button says \"Uploading…\" either way. The Studio action form offers the key (`action.form.ts`). `authorWarn` / `authorHint` were dropped in the same edit (a warned `live` row reds this gate)." }, "cancelText": { - "status": "planned", + "status": "live", "verifiedAt": "2026-11-02", - "authorWarn": true, - "authorHint": "Keep it, but nothing reads it yet: no console draws an action's `cancelText`, so the dismiss button of its confirm or param dialog still says \"Cancel\" until the console's two action dialogs read the key.", - "evidence": "packages/spec/src/ui/action.zod.ts#cancelText (the parse: an I18nLabel, never blank, only on an action that declares `confirmText` or a non-empty `params`); packages/spec/src/system/i18n-resolver.ts#translateAction overlays the translated `objects.{o}._actions.{a}.cancelText` (or `globalActions.{a}.cancelText`) onto the served action when it opens either dialog; packages/spec/src/system/translation.zod.ts declares the translation slot; packages/lint/src/validate-translation-references.ts#checkActionCancelText refuses a translation under an action that opens neither dialog.", - "note": "PLANNED until the console half of #28519 lands (spec PR first, console card second, the #27944 split). The framework half is live, but no renderer reads the key yet. The confirm dialog: ui/packages/app-shell/src/views/ActionConfirmDialog.tsx already draws `options.cancelText` over `actionConfirm.cancel`, but ui/packages/core/src/actions/ActionRunner.ts#confirmOptions hands it only `title` / `confirmText` / `destructive`, never the action's `cancelText`, and `ActionDef` does not declare the key. The param dialog: ui/packages/app-shell/src/views/ActionParamDialog.tsx draws `actionDialog.cancel` unconditionally, fed by the two param-collection handlers (ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx#paramCollectionHandler, ui/packages/app-shell/src/views/RecordDetailView.tsx#paramCollectionHandler). `authorWarn` is set because an author who writes the key today sees \"Cancel\", not their label. The console PR flips this row to `live` with those read points, and drops `authorWarn` / `authorHint` in that same edit (a warned `live` row reds this gate)." + "evidenceScope": "in-repo", + "evidence": "ui/packages/react/src/hooks/useActionTextLocalizer.ts (the confirm path: the bundle's `objects.{o}._actions.{a}.cancelText`, or `globalActions.{a}.cancelText` for an object-less action, through `actionCancelText`, then the authored `I18nLabel` collapsed to the active language, only when the action declares one); ui/packages/core/src/actions/ActionRunner.ts#confirmOptions (hands the resolved string to the confirm handler as `options.cancelText`) and ui/packages/core/src/actions/ActionRunner.ts#ActionDef (the key derived from the spec); ui/packages/app-shell/src/views/ActionConfirmDialog.tsx (draws `options.cancelText` over `actionConfirm.cancel`); ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx#paramCollectionHandler and ui/packages/app-shell/src/views/RecordDetailView.tsx#paramCollectionHandler (the param path, resolved the same way); ui/packages/app-shell/src/views/ActionParamDialog.tsx (draws it on the dismiss button, `actionDialog.cancel` when absent); ui/packages/components/src/renderers/action/action-button.tsx, ui/packages/components/src/renderers/action/action-icon.tsx, ui/packages/components/src/renderers/action/action-group.tsx, ui/packages/components/src/renderers/action/action-menu.tsx (the four action renderers forward it to the runner); packages/spec/src/system/i18n-resolver.ts#translateAction (the served overlay); packages/lint/src/validate-translation-references.ts#checkActionCancelText (a translation under an action that opens neither dialog is refused)", + "note": "LIVE since #28879 (the console half of #28519). An action that asks first or collects `params` names its dialog's dismiss button (\"Keep invitation\" beside \"Cancel Invitation\"): the confirm dialog and the param dialog draw the translated or authored `cancelText` and fall back to \"Cancel\" when the action declares none. `sys_invitation`'s `cancel_invitation` is the first consumer. The Studio action form offers the key (`action.form.ts`). `authorWarn` / `authorHint` were dropped in the same edit (a warned `live` row reds this gate)." }, "objectName": { "status": "live", diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index 74c1b0c2637..0fabbcf63fa 100644 --- a/packages/spec/liveness/state-counts/action.md +++ b/packages/spec/liveness/state-counts/action.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `action` | 50 | 0 | 0 | 1 | 3 | 54 | +| `action` | 51 | 0 | 0 | 1 | 2 | 54 | diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 418f5bcf2df..10220b34c84 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -396,18 +396,7 @@ const LEDGER: ReadonlyArray = [ key: 'pressed', why: "planned, not yet rendered — the reader's pressed-state predicate is validated at parse (CEL only, no boolean literal) and judged at the authoring doors in the `record` scope, but no action surface draws the pressed face until the console's header buttons read it. A form control offered today would let an author write a predicate nothing shows", }, - // #28519 — the dismiss label of an action's confirm and param dialogs lands - // spec-first the way `submitText` did: `planned` in the liveness ledger, - // drawn by neither dialog until the console reads it. The console change - // that flips the ledger row deletes this row and offers the key in - // `action.form.ts`, beside `submitText`. - { - kind: 'omit', - type: 'action', - path: ROOT_PATH, - key: 'cancelText', - why: "planned, not yet rendered — the dialogs' dismiss button label is validated at parse (an I18nLabel, never blank, only on an action that declares `confirmText` or a non-empty `params`) and translated by `translateAction`, but the console's confirm and param dialogs still draw their default \"Cancel\" until they read the key. A form control offered today would let an author set a button label nothing shows", - }, + // #27945 — a lookup's pick autofill lands spec-first the same way: `planned` // in the liveness ledger, read by no form or grid until the ui card's lookup // widget writes the declared targets on pick. diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 8bd285c74b4..858a780f280 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -229,7 +229,7 @@ const actionTranslationSchema = (surface: string) => strictObject({ // dialog the action opens, its confirm dialog or its param dialog; overlaid // by `translateAction` like `submitText`, for an action that declares // `confirmText` or `params`. - cancelText: z.string().optional().describe('Translated label of the dismiss button on the action\'s confirm or param dialog (the action must declare `confirmText` or `params`; no renderer shows it yet)'), + cancelText: z.string().optional().describe('Translated label of the dismiss button on the action\'s confirm or param dialog (the action must declare `confirmText` or `params`)'), // `ActionSchema.outcomeMessages` (#21095) — one translated message per // declared outcome, keyed by the same snake_case outcome name, so a bundle // key that could never match a handler's `outcome` is refused here rather diff --git a/packages/spec/src/ui/action.form.ts b/packages/spec/src/ui/action.form.ts index 6d9d061ce27..67836f47392 100644 --- a/packages/spec/src/ui/action.form.ts +++ b/packages/spec/src/ui/action.form.ts @@ -119,6 +119,10 @@ export const actionForm = defineForm({ // Not gated on `params`: the parse refuses the key beside an empty // `params` by name, so a misplaced label is refused, never kept unshown. { field: 'submitText', label: 'Submit button label', helpText: 'Label of the submit button in this action\'s param dialog, the verb the button performs (e.g. "Send", "Log Call"). Only for an action that collects params. Leave empty to show "Confirm".' }, + // `cancelText` is the dismiss twin of the row above, the same I18nLabel + // node. Not gated on `confirmText` / `params` either: the parse refuses + // the key on an action that opens neither dialog, by name. + { field: 'cancelText', label: 'Cancel button label', helpText: 'Label of the dismiss button on the dialog this action opens before it runs, its confirm dialog or its param dialog. Name what dismissing keeps where the action\'s own verb is "Cancel …" (e.g. "Keep invitation"). Only for an action that asks for confirmation or collects params. Leave empty to show "Cancel".' }, { field: 'confirmText', helpText: 'Confirmation message (e.g., "Are you sure?")' }, { field: 'successMessage', helpText: 'Success message after completion' }, // `errorMessage` is the twin of the row above: same node (I18nLabel), same diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 517b0e94bc9..ea136c36dbf 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1483,7 +1483,7 @@ const actionObject = () => strictObject({ * The dismiss button label of the dialog `confirmText` or `params` opens — * the contract is on {@link actionCancelText}. */ - cancelText: actionCancelText().optional().describe('Label of the dismiss button on the dialog the action opens before it runs — the confirm dialog of a param-less action that declares `confirmText`, or the param dialog of an action with a non-empty `params` — in place of the default "Cancel" (declared ahead of its console reader: no renderer shows it yet). Name what dismissing keeps where the action\'s own verb is "Cancel …" or "Close …" (e.g. "Keep invitation" beside "Cancel Invitation"). The default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.cancelText` (`globalActions.ACTION.cancelText` for an object-less action). Only on an action that opens one of those dialogs: with neither `confirmText` nor a non-empty `params` no dialog opens, so it is refused. The dismiss twin of `submitText`, as on `object-form`. A blank label is refused.'), + cancelText: actionCancelText().optional().describe('Label of the dismiss button on the dialog the action opens before it runs — the confirm dialog of a param-less action that declares `confirmText`, or the param dialog of an action with a non-empty `params` — in place of the default "Cancel". Name what dismissing keeps where the action\'s own verb is "Cancel …" or "Close …" (e.g. "Keep invitation" beside "Cancel Invitation"). The default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.cancelText` (`globalActions.ACTION.cancelText` for an object-less action). Only on an action that opens one of those dialogs: with neither `confirmText` nor a non-empty `params` no dialog opens, so it is refused. The dismiss twin of `submitText`, as on `object-form`. A blank label is refused.'), /** Visual Style */ variant: z.enum(['primary', 'secondary', 'danger', 'ghost', 'link']).optional().describe('Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent)'), diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.cancelText-28879.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.cancelText-28879.test.tsx new file mode 100644 index 00000000000..09a0562e16a --- /dev/null +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.cancelText-28879.test.tsx @@ -0,0 +1,158 @@ +/** + * 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. + */ + +/** + * `useConsoleActionRuntime`'s param-collection handler resolves the action's + * `cancelText` — the label of the param dialog's dismiss button — the way it + * resolves `submitText` (objectstack#28879): + * + * 1. the translation bundle's `objects.OBJECT._actions.ACTION.cancelText` + * (`globalActions.ACTION.cancelText` for an object-less action), through + * `useObjectLabel().actionCancelText`; + * 2. else the authored inline locale map, collapsed to the active language; + * 3. else the authored string. + * + * The bundle lookup itself is `@object-ui/i18n`'s and is pinned there + * (`useObjectLabel-actionCancelText-28879.test.tsx`). Here the resolver is a + * stand-in that records what it is asked, so this file pins the HANDLER's half. + * `RecordDetailView`'s handler, the second reader of the same seam, has its own + * copy of these pins. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, act } from '@testing-library/react'; +import React from 'react'; + +vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() })); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'User', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => vi.fn(), +})); + +/** The bundle, keyed `KEY_OBJECT|ACTION` (`global` for no key object). */ +let bundle: Record = {}; +const asked: Array<{ objectName: string | undefined; actionName: string | undefined; fallback: unknown }> = []; +const LANGUAGE = 'zh-CN'; + +// Partial mock: `@object-ui/components` reaches for `createSafeTranslation` at +// import time, so a total double breaks the module graph. +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useObjectLabel: () => ({ + fieldLabel: (_o: any, _n: any, l: any) => l, + fieldOptionLabel: (_o: any, _f: any, _v: any, l: any) => l, + actionParamText: (_o: any, _a: any, _p: any, _attr: any, fallback: any) => fallback, + actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback, + actionDescription: (_o: any, _a: any, fallback: any) => fallback, + actionSubmitText: (_o: any, _a: any, fallback: any) => fallback, + actionCancelText: (objectName: string | undefined, actionName: string | undefined, fallback?: string) => { + asked.push({ objectName, actionName, fallback }); + return bundle[`${objectName ?? 'global'}|${actionName}`] ?? (fallback || undefined); + }, + }), + useObjectTranslation: () => ({ + t: (key: string, options?: any) => String(options?.defaultValue ?? key), + language: LANGUAGE, + }), +})); + +vi.mock('../useActionModal', () => ({ + useActionModal: () => ({ + modalHandler: vi.fn(async () => ({ success: true })), + modalElement: null, + closeModal: () => {}, + resolveModalTarget: vi.fn(async () => null), + }), +})); + +let paramDialogState: any = null; +vi.mock('../../views/ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('../../views/ActionParamDialog', () => ({ + ActionParamDialog: ({ state }: any) => { + if (state?.open) paramDialogState = state; + return null; + }, +})); +vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('sonner', () => { + const fn: any = vi.fn(); + fn.error = vi.fn(); + fn.success = vi.fn(); + return { toast: fn }; +}); + +import { useConsoleActionRuntime } from '../useConsoleActionRuntime'; + +const PARAMS = [{ name: 'subject', label: 'Subject', type: 'text' }] as any[]; + +function Harness({ onReady }: { onReady: (fn: any) => void }) { + const runtime = useConsoleActionRuntime({ dataSource: {}, objects: [] }); + const ready = React.useRef(false); + if (!ready.current) { + ready.current = true; + onReady(runtime.actionProviderProps.onParamCollection); + } + return <>{runtime.dialogs}; +} + +async function collectParams(dispatch: Record) { + paramDialogState = null; + let collect: any; + render( { collect = fn; }} />); + await act(async () => { + void collect(PARAMS, dispatch as any); + await Promise.resolve(); + }); + expect(paramDialogState).toBeTruthy(); +} + +beforeEach(() => { + bundle = {}; + asked.length = 0; + paramDialogState = null; +}); + +describe('useConsoleActionRuntime — the param dialog\'s dismiss label (objectstack#28879)', () => { + it('hands the dialog the authored string', async () => { + await collectParams({ name: 'send_email', label: 'Send Email', objectName: 'crm_contact', cancelText: 'Keep draft' }); + expect(paramDialogState.cancelText).toBe('Keep draft'); + expect(asked).toEqual([{ objectName: 'crm_contact', actionName: 'send_email', fallback: 'Keep draft' }]); + }); + + it('prefers the bundle entry over the authored string', async () => { + bundle['crm_contact|send_email'] = '保留草稿'; + await collectParams({ name: 'send_email', label: 'Send Email', objectName: 'crm_contact', cancelText: 'Keep draft' }); + expect(paramDialogState.cancelText).toBe('保留草稿'); + }); + + it('collapses an inline locale map to the active language BEFORE offering it as the fallback', async () => { + await collectParams({ + name: 'send_email', + label: 'Send Email', + objectName: 'crm_contact', + cancelText: { en: 'Keep draft', 'zh-CN': '先不发' }, + }); + expect(paramDialogState.cancelText).toBe('先不发'); + expect(asked[0].fallback).toBe('先不发'); + }); + + it('asks the global node for an object-less action', async () => { + bundle['global|close_period'] = '保持打开'; + await collectParams({ name: 'close_period', label: 'Close Period', cancelText: 'Keep open' }); + expect(asked[0]).toMatchObject({ objectName: undefined, actionName: 'close_period' }); + expect(paramDialogState.cancelText).toBe('保持打开'); + }); + + it('hands the dialog nothing when the action names no label and the bundle has none — the dialog says Cancel', async () => { + await collectParams({ name: 'send_email', label: 'Send Email', objectName: 'crm_contact' }); + expect(paramDialogState.cancelText).toBeUndefined(); + expect(asked[0].fallback).toBeUndefined(); + }); +}); diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.createUserEmailRequiredMark-28280.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.createUserEmailRequiredMark-28280.test.tsx index 918cab308bd..2eb8c54d326 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.createUserEmailRequiredMark-28280.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.createUserEmailRequiredMark-28280.test.tsx @@ -55,6 +55,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => ({ actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback, actionDescription: (_o: any, _a: any, fallback: any) => fallback, actionSubmitText: (_o: any, _a: any, fallback: any) => fallback, + actionCancelText: (_o: any, _a: any, fallback: any) => fallback, }), })); vi.mock('../useActionModal', () => ({ diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.createUserRequiredField-28289.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.createUserRequiredField-28289.test.tsx index ebab10fbda8..8ef5f39b2d6 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.createUserRequiredField-28289.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.createUserRequiredField-28289.test.tsx @@ -47,6 +47,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => ({ actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback, actionDescription: (_o: any, _a: any, fallback: any) => fallback, actionSubmitText: (_o: any, _a: any, fallback: any) => fallback, + actionCancelText: (_o: any, _a: any, fallback: any) => fallback, }), })); vi.mock('../useActionModal', () => ({ diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowRowGuard-13397.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowRowGuard-13397.test.tsx index 7cadadd364c..6df56217fb3 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowRowGuard-13397.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowRowGuard-13397.test.tsx @@ -55,6 +55,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => { actionParamOptionLabel: (_o: unknown, _a: unknown, _p: unknown, _v: unknown, fallback: string) => fallback, actionDescription: (_o: unknown, _a: unknown, fallback: string) => fallback, actionSubmitText: (_o: unknown, _a: unknown, fallback: string) => fallback, + actionCancelText: (_o: unknown, _a: unknown, fallback: string) => fallback, }), useObjectTranslation: () => ({ t: zhT, language: 'zh' }), }; diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.oneToastPerRefusal-13340.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.oneToastPerRefusal-13340.test.tsx index ccf7b31b5c3..2eca9748015 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.oneToastPerRefusal-13340.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.oneToastPerRefusal-13340.test.tsx @@ -58,6 +58,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => ({ actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback, actionDescription: (_o: any, _a: any, fallback: any) => fallback, actionSubmitText: (_o: any, _a: any, fallback: any) => fallback, + actionCancelText: (_o: any, _a: any, fallback: any) => fallback, }), useObjectTranslation: () => ({ t: (key: string, options?: any) => String(options?.defaultValue ?? key), diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.overrideNotice.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.overrideNotice.test.tsx index b2630b8d5e8..69a722b07c6 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.overrideNotice.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.overrideNotice.test.tsx @@ -61,6 +61,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => ({ actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback, actionDescription: (_o: any, _a: any, fallback: any) => bundleDescription ?? fallback, actionSubmitText: (_o: any, _a: any, fallback: any) => fallback, + actionCancelText: (_o: any, _a: any, fallback: any) => fallback, }), useObjectTranslation: () => ({ t: (key: string, options?: any) => diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.paramDialogTitle.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.paramDialogTitle.test.tsx index be19e3e172a..54c69b31cc0 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.paramDialogTitle.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.paramDialogTitle.test.tsx @@ -59,6 +59,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => ({ actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback, actionDescription: (_o: any, _a: any, fallback: any) => fallback, actionSubmitText: (_o: any, _a: any, fallback: any) => fallback, + actionCancelText: (_o: any, _a: any, fallback: any) => fallback, }), useObjectTranslation: () => ({ t: (key: string, options?: any) => String(options?.defaultValue ?? key), diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.paramRefusal-13161.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.paramRefusal-13161.test.tsx index b0e0b4b36e7..340a08605d7 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.paramRefusal-13161.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.paramRefusal-13161.test.tsx @@ -39,6 +39,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => ({ actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback, actionDescription: (_o: any, _a: any, fallback: any) => fallback, actionSubmitText: (_o: any, _a: any, fallback: any) => fallback, + actionCancelText: (_o: any, _a: any, fallback: any) => fallback, }), useObjectTranslation: () => ({ t: (key: string, options?: any) => String(options?.defaultValue ?? key), diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.submitText-28158.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.submitText-28158.test.tsx index 4ae97709000..ca62ca75f4a 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.submitText-28158.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.submitText-28158.test.tsx @@ -56,6 +56,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => ({ asked.push({ objectName, actionName, fallback }); return bundle[`${objectName ?? 'global'}|${actionName}`] ?? (fallback || undefined); }, + actionCancelText: (_o: any, _a: any, fallback: any) => fallback, }), useObjectTranslation: () => ({ t: (key: string, options?: any) => String(options?.defaultValue ?? key), diff --git a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 57c57abeda2..596eab1cb6b 100644 --- a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -170,7 +170,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons // `requiredPermissions` action closed on exactly the deployments this // doctrine exists to protect (objectui#4656). const { systemPermissions } = usePermissions(); - const { fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel, actionDescription, actionSubmitText, actionResultDialog } = useObjectLabel(); + const { fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel, actionDescription, actionSubmitText, actionCancelText, actionResultDialog } = useObjectLabel(); // Entitlement 403s render as a dialog, not a toast — its copy is localized // here rather than taken from the server (objectui#2458 / cloud#959). // `language` also resolves inline per-locale action-param labels below, and @@ -346,6 +346,9 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons // object-less action) first, then the authored `I18nLabel` collapsed to // the active language. Absent, the dialog keeps its default "Confirm". const submitText = actionSubmitText(objForI18n, action?.name, pickLocalized(action?.submitText, language) || undefined); + // The dismiss button's label (`cancelText`), resolved the same way. + // Absent, the dialog keeps its default "Cancel". + const cancelText = actionCancelText(objForI18n, action?.name, pickLocalized(action?.cancelText, language) || undefined); setParamState({ open: true, params: localized, @@ -361,6 +364,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons ? [overrideNotice, declaredDescription].filter(Boolean).join('\n\n') : declaredDescription, submitText, + cancelText, interpolate, resolve, // The runner's `run`, so Confirm runs the action from inside the @@ -368,7 +372,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons run, }); })(); }); - }, [objectName, objectDef, objects, metadata, fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel, actionSubmitText, language]); + }, [objectName, objectDef, objects, metadata, fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel, actionSubmitText, actionCancelText, language]); const currentUser = user ? { id: user.id, name: user.name, avatar: user.image, isPlatformAdmin: (user as any)?.isPlatformAdmin ?? false, systemPermissions } diff --git a/ui/packages/app-shell/src/views/ActionConfirmDialog.cancelText-28879.test.tsx b/ui/packages/app-shell/src/views/ActionConfirmDialog.cancelText-28879.test.tsx new file mode 100644 index 00000000000..d044536bd03 --- /dev/null +++ b/ui/packages/app-shell/src/views/ActionConfirmDialog.cancelText-28879.test.tsx @@ -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#28879 — the confirm dialog's dismiss button draws the action's + * `cancelText`, which the runner hands it as `options.cancelText` + * (`ActionRunner.confirmCancelText-28879.test.ts` pins that half). Absent or + * empty, the generic `actionConfirm.cancel` stands. No i18n provider is + * mounted, so `t()` answers the key, which tells the fallback from the label. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ActionConfirmDialog } from './ActionConfirmDialog'; + +function openDialog(options?: { title?: string; confirmText?: string; cancelText?: string }) { + const resolve = vi.fn(); + render( + {}} + />, + ); + return resolve; +} + +describe('ActionConfirmDialog — the dismiss button names what dismissing keeps (objectstack#28879)', () => { + it('draws `options.cancelText` beside the action-named confirm button', async () => { + openDialog({ title: 'Cancel Invitation', confirmText: 'Cancel Invitation', cancelText: 'Keep invitation' }); + expect(await screen.findByRole('button', { name: 'Keep invitation' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Cancel Invitation' })).toBeTruthy(); + expect(screen.queryByText('actionConfirm.cancel')).toBeNull(); + }); + + it('CONTROL: without a cancelText the generic Cancel stands', async () => { + openDialog({ title: 'Cancel Invitation', confirmText: 'Cancel Invitation' }); + expect(await screen.findByRole('button', { name: 'actionConfirm.cancel' })).toBeTruthy(); + }); + + it('the labelled button dismisses: the confirm resolves false', async () => { + const resolve = openDialog({ cancelText: 'Keep invitation' }); + fireEvent.click(await screen.findByRole('button', { name: 'Keep invitation' })); + expect(resolve).toHaveBeenCalledWith(false); + }); +}); diff --git a/ui/packages/app-shell/src/views/ActionParamDialog.cancelText-28879.test.tsx b/ui/packages/app-shell/src/views/ActionParamDialog.cancelText-28879.test.tsx new file mode 100644 index 00000000000..780cfacc4ae --- /dev/null +++ b/ui/packages/app-shell/src/views/ActionParamDialog.cancelText-28879.test.tsx @@ -0,0 +1,59 @@ +/** + * 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. + */ + +/** + * ActionParamDialog draws the action's `cancelText` on its dismiss button + * (`ActionSchema.cancelText`, objectstack#28879) in place of the generic + * Cancel. The caller hands it the already-resolved text; the dialog falls back + * to `actionDialog.cancel` when there is none. No i18n provider is mounted, so + * `t()` answers the key, which is what lets these assertions tell the fallback + * from the label. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ActionParamDef } from '@object-ui/core'; +import { ActionParamDialog } from './ActionParamDialog'; + +const TEXT_PARAMS: ActionParamDef[] = [{ name: 'subject', label: 'Subject', type: 'text' }]; + +function openDialog(cancelText?: string) { + const resolve = vi.fn(); + render( + {}} + />, + ); + return resolve; +} + +describe('ActionParamDialog — the dismiss button names what dismissing keeps (objectstack#28879)', () => { + it('draws the action\'s `cancelText` on the dismiss button', async () => { + openDialog('Keep draft'); + expect(await screen.findByRole('button', { name: 'Keep draft' })).toBeTruthy(); + expect(screen.queryByText('actionDialog.cancel')).toBeNull(); + // The submit button keeps its own label. + expect(screen.getByRole('button', { name: 'Send' })).toBeTruthy(); + }); + + it('falls back to the generic Cancel when the action names no label', async () => { + openDialog(); + expect(await screen.findByRole('button', { name: 'actionDialog.cancel' })).toBeTruthy(); + }); + + it('falls back to Cancel for an empty string, never an empty button', async () => { + openDialog(''); + expect(await screen.findByRole('button', { name: 'actionDialog.cancel' })).toBeTruthy(); + }); + + it('the labelled button dismisses like Cancel does: the dialog resolves null', async () => { + const resolve = openDialog('Keep draft'); + fireEvent.click(await screen.findByRole('button', { name: 'Keep draft' })); + expect(resolve).toHaveBeenCalledWith(null); + }); +}); diff --git a/ui/packages/app-shell/src/views/ActionParamDialog.tsx b/ui/packages/app-shell/src/views/ActionParamDialog.tsx index 29087c77262..66baf53a8bd 100644 --- a/ui/packages/app-shell/src/views/ActionParamDialog.tsx +++ b/ui/packages/app-shell/src/views/ActionParamDialog.tsx @@ -113,6 +113,13 @@ export interface ParamDialogState { * a file is uploading it says "Uploading…" either way. */ submitText?: string; + /** + * Label of the dismiss button — the action's declared `cancelText`, already + * resolved by the caller the way `submitText` is (the translation bundle's + * `_actions..cancelText` first, then the authored text collapsed to + * the active language). Absent, the button says the generic "Cancel". + */ + cancelText?: string; /** * Fills the `${...}` holes of each param's `helpText` — the runner's * `ParamCollectionHandler` `interpolate` argument, passed through. It runs on @@ -948,7 +955,7 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp )} - +