diff --git a/.changeset/28158-action-param-dialog-submit-text.md b/.changeset/28158-action-param-dialog-submit-text.md new file mode 100644 index 00000000000..8f7e0f8452e --- /dev/null +++ b/.changeset/28158-action-param-dialog-submit-text.md @@ -0,0 +1,20 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/components': patch +'@object-ui/core': patch +'@object-ui/i18n': patch +'@object-ui/types': patch +'@objectstack/cli': patch +'@objectstack/platform-objects': patch +'@objectstack/spec': patch +--- + +feat(console): an action's param dialog names its own submit verb — `submitText` is drawn on the submit button in place of "Confirm" + +Clause-②: yes (widening) — `@object-ui/types` `UIActionSchema` and `@object-ui/core` `ActionDef` gain the optional member `submitText`, typed by derivation from the spec's `ActionSchema`; `@object-ui/i18n` `useObjectLabel()` gains the resolver `actionSubmitText`. Nothing is narrowed. +Metadata-protocol: none + +- **The console.** The dialog an action opens to collect its `params` draws the action's `submitText` on its submit button ("Send", "Log Call"). Both param-collection handlers resolve it the way they resolve `description`: the translation bundle's `objects.OBJECT._actions.ACTION.submitText` (`globalActions.ACTION.submitText` 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 verb keeps "Confirm", and the button still says "Uploading…" while a file param uploads. `action:button`, `action:icon`, `action:group` and `action:menu` forward the key to the runner. +- **Studio.** The action form offers the key ("Submit button label"), translated in every shipped locale. +- **`os i18n extract`.** Scaffolds `objects.OBJECT._actions.ACTION.submitText` (or `globalActions.ACTION.submitText`) for an action with a non-empty `params` — the only action `translateAction` overlays it onto — and no slot for any other action. +- **Spec.** The liveness ledger row for `ActionSchema.submitText` is `live`, with its author warning dropped; the key's description no longer says no renderer shows it. diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index b6982a578b9..ddc9e850495 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -490,7 +490,7 @@ const result = ApiMethod.parse(data); | **patch** | `Record` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). | | **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`). | -| **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" (declared ahead of its console reader: no renderer shows it yet) — 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. | +| **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. | | **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 0ae32a9e826..027fa3d685d 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -333,7 +333,7 @@ const result = MetadataBulkResultSchema.parse(data); | **patch** | `Record` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). | | **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`). | -| **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" (declared ahead of its console reader: no renderer shows it yet) — 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. | +| **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. | | **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 1cdf019ee9e..cd6a46c704e 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -130,7 +130,7 @@ Translation data for a single field | **confirmText** | `string` | optional | Translated confirmation prompt | | **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`; no renderer shows it yet) | +| **submitText** | `string` | optional | Translated label of the submit button in the action's param dialog (the action must declare `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 | @@ -233,7 +233,7 @@ Translation data for a single object | **confirmText** | `string` | optional | Translated confirmation prompt | | **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`; no renderer shows it yet) | +| **submitText** | `string` | optional | Translated label of the submit button in the action's param dialog (the action must declare `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 | @@ -408,7 +408,7 @@ Translation data for a single object | **confirmText** | `string` | optional | Translated confirmation prompt | | **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`; no renderer shows it yet) | +| **submitText** | `string` | optional | Translated label of the submit button in the action's param dialog (the action must declare `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 | @@ -645,7 +645,7 @@ Translation data for a single object | **confirmText** | `string` | optional | Translated confirmation prompt | | **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`; no renderer shows it yet) | +| **submitText** | `string` | optional | Translated label of the submit button in the action's param dialog (the action must declare `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 | @@ -851,7 +851,7 @@ Translation data for a single object | **confirmText** | `string` | optional | Translated confirmation prompt | | **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`; no renderer shows it yet) | +| **submitText** | `string` | optional | Translated label of the submit button in the action's param dialog (the action must declare `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 e198223488e..b5ceb4a3023 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -43,7 +43,7 @@ const result = ActionSchema.parse(data); | **patch** | `Record` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). | | **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`). | -| **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" (declared ahead of its console reader: no renderer shows it yet) — 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. | +| **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. | | **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 666d74b932a..a78806461be 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -87,13 +87,14 @@ * objects.._actions..confirmText * objects.._actions..successMessage * objects.._actions..disabledReason + * objects.._actions..submitText (only beside a non-empty `params`) * objects.._actions..outcomeMessages. * objects.._actions..params..label / .helpText / .placeholder * objects.._actions..params..options. * objects.._actions..resultDialog.title / .description / .acknowledge * objects.._actions..resultDialog.fields. * globalActions..label / .description / .confirmText / .successMessage - * / .disabledReason + * / .disabledReason / .submitText (beside a non-empty `params`) * globalActions..outcomeMessages. * globalActions..params..* / .resultDialog.* (same shape as object actions) * apps..label / .description @@ -748,6 +749,25 @@ function pushOptional( else out.push({ path, ...evidence, source, ...extra }); } +/** + * Emit `submitText`, the param dialog's submit button label, under an action's + * translation root — OPTIONAL-not-derived like `description` (an action that + * names no verb shows the console's own "Confirm"), and only for an action with + * a non-empty `params`: that is the only action `translateAction` overlays the + * key onto, because no other action opens the dialog the button sits in. A + * paramless action gets no slot, so a translation filed there stays dangling. + */ +function pushActionSubmitText( + out: ExpectedEntry[], + actionRoot: string[], + action: any, + source: ExpectedEntry['source'], + objectName: string | undefined, +): void { + if (!Array.isArray(action?.params) || action.params.length === 0) return; + pushOptional(out, [...actionRoot, 'submitText'], action.submitText, source, { objectName }); +} + /** * Emit `params..{label,helpText,placeholder}` and * `params..options.` entries under an action's translation root. @@ -1506,6 +1526,7 @@ export function collectExpectedEntries( 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 }); + pushActionSubmitText(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); @@ -1603,6 +1624,7 @@ export function collectExpectedEntries( // reason shows the console's own default sentence, so there is no gap to // seed for it. pushOptional(out, [...root, 'disabledReason'], action.disabledReason, kind, { objectName }); + pushActionSubmitText(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-submit-text.test.ts b/packages/cli/test/i18n-extract-action-submit-text.test.ts new file mode 100644 index 00000000000..940522f7747 --- /dev/null +++ b/packages/cli/test/i18n-extract-action-submit-text.test.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// The extractor half of `action.submitText`, the verb on the submit button of +// the dialog an action opens to collect its `params`. +// +// `translateAction` overlays `objects.OBJECT._actions.ACTION.submitText` +// (`globalActions.ACTION.submitText` for an object-less action) only onto an +// action with a non-empty `params`, because no other action opens that dialog. +// `os i18n extract` scaffolds the slot on exactly that population: beside a +// non-empty `params` it is an optional slot like `description`, 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: 'subject', label: 'Subject', type: 'text' }]; + +describe('the extractor emits action-level `submitText` beside a non-empty `params`', () => { + it('emits it for a TOP-LEVEL object action that declares one', () => { + const keys = paths({ + actions: [{ name: 'send_email', objectName: 'crm_contact', label: 'Send Email', params: PARAMS, submitText: 'Send' }], + }); + + expect(keys).toContain('objects.crm_contact._actions.send_email.submitText'); + }); + + it('emits it for a GLOBAL action that declares one', () => { + const keys = paths({ + actions: [{ name: 'log_call', label: 'Log Call', params: PARAMS, submitText: 'Log Call' }], + }); + + expect(keys).toContain('globalActions.log_call.submitText'); + }); + + it('emits it for an action declared INLINE on the object', () => { + const keys = paths({ + objects: [ + { + name: 'crm_contact', + label: 'Contact', + actions: [{ name: 'send_email', label: 'Send Email', params: PARAMS, submitText: 'Send' }], + }, + ], + }); + + expect(keys).toContain('objects.crm_contact._actions.send_email.submitText'); + }); + + it('records a seed-less slot for an action with params that names no verb — the dialog keeps "Confirm"', () => { + const config = { + objects: [{ name: 'crm_contact', label: 'Contact', actions: [{ name: 'send_email', label: 'Send Email', params: PARAMS }] }], + }; + const key = 'objects.crm_contact._actions.send_email.submitText'; + 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?.crm_contact?._actions?.send_email).not.toHaveProperty('submitText'); + expect(computeI18nCoverage({ ...config, translations: [{ 'zh-CN': {} }] }).issues.map((i) => i.key)) + .not.toContain(key); + }); + + it('emits NO slot for an action without params — no dialog opens, so there is no button to name', () => { + for (const params of [undefined, []]) { + const keys = paths({ + objects: [ + { + name: 'crm_contact', + label: 'Contact', + actions: [{ name: 'archive', label: 'Archive', ...(params ? { params } : {}) }], + }, + ], + actions: [{ name: 'refresh_all', label: 'Refresh', ...(params ? { params } : {}) }], + }); + + expect(keys).toContain('objects.crm_contact._actions.archive.label'); + expect(keys).not.toContain('objects.crm_contact._actions.archive.submitText'); + expect(keys).toContain('globalActions.refresh_all.label'); + expect(keys).not.toContain('globalActions.refresh_all.submitText'); + } + }); + + it('carries the declared verb into the bundle, and gates it until it is translated', () => { + const config: any = { + objects: [ + { + name: 'crm_contact', + label: 'Contact', + actions: [{ name: 'send_email', label: 'Send Email', params: PARAMS, submitText: 'Send' }], + }, + ], + translations: [ + { 'zh-CN': { objects: { crm_contact: { label: '联系人', _actions: { send_email: { label: '发送邮件', params: { subject: { label: '主题' } } } } } } } }, + ], + }; + const key = 'objects.crm_contact._actions.send_email.submitText'; + + const entry = collectExpectedEntries(config).find((e) => e.path.join('.') === key); + expect(entry?.source).toBe('action'); + expect(entry?.inline).toBe('Send'); + + const { bundles } = extractTranslations(config, { defaultLocale: 'en', fill: 'default' }); + expect(bundles.en.objects?.crm_contact?._actions?.send_email.submitText).toBe('Send'); + + expect(computeI18nCoverage(config).issues.map((i) => i.key)).toContain(key); + config.translations[0]['zh-CN'].objects.crm_contact._actions.send_email.submitText = '发送'; + expect(computeI18nCoverage(config).issues.map((i) => i.key)).not.toContain(key); + }); +}); + +describe('one fixture action, all three surfaces (submitText end-to-end)', () => { + const authored = { + name: 'send_email', + objectName: 'crm_contact', + label: 'Send Email', + type: 'script', + target: 'sendEmail', + params: PARAMS, + submitText: 'Send', + }; + + 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.submitText).toBe('Send'); + + const { bundles } = extractTranslations({ actions: [authored] }, { + defaultLocale: 'en', + locales: ['zh-CN'], + fill: 'empty', + }); + const bundle: any = bundles['zh-CN']; + expect(bundle.objects.crm_contact._actions.send_email).toHaveProperty('submitText'); + + bundle.objects.crm_contact._actions.send_email.submitText = '发送'; + const translated = ObjectTranslationDataSchema.safeParse(bundle.objects.crm_contact); + expect(translated.success).toBe(true); + expect(translated.success && translated.data._actions?.send_email?.submitText).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 f35d1152eb3..37723dfda96 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 @@ -1807,6 +1807,10 @@ export const enMetadataForms: NonNullable = { label: "Description", helpText: "Explanatory line under the title of this action's param dialog. On an action that collects params, the confirm question goes here rather than in confirmText — one dialog, not two. Not ai.description, which is the text an AI agent reads." }, + 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\"." + }, confirmText: { label: "Confirm Text", helpText: "Confirmation message (e.g., \"Are you sure?\")" 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 909efbfefda..0dd92b413a3 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 @@ -1807,6 +1807,10 @@ export const esESMetadataForms: NonNullable = label: "Descripción", helpText: "Línea explicativa bajo el título del diálogo de parámetros de esta acción. En una acción que recoge parámetros, la pregunta de confirmación va aquí y no en confirmText: un diálogo, no dos. No es ai.description, que es el texto que lee un agente de IA." }, + submitText: { + 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\"." + }, 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/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 95a73757ade..d485db6f16a 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 @@ -1807,6 +1807,10 @@ export const jaJPMetadataForms: NonNullable = label: "説明", helpText: "このアクションのパラメーターダイアログのタイトル下に表示される説明文。パラメーターを収集するアクションでは、確認の問いを confirmText ではなくここに書きます(ダイアログは 2 つではなく 1 つ)。AI エージェントが読む ai.description とは別物です。" }, + submitText: { + label: "送信ボタンのラベル", + helpText: "このアクションのパラメーターダイアログにある送信ボタンのラベル。ボタンが行う操作を表す動詞です(例:「送信」「通話を記録」)。パラメーターを収集するアクションでのみ使用します。空のままにすると「確認」と表示されます。" + }, confirmText: { label: "確認文", helpText: "確認メッセージ(例: \"Are you sure?\")" 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 8824ea79420..51547361252 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 @@ -1245,7 +1245,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 696 since #28255: the field form's `percentDisplay` row (a percent // field's read-only face, bar or plain) — one row label, authored in all // three locales. - expect(translated.length, `${locale} positive control`).toBe(696); + // 697 since #28158: the action form's `submitText` row (the verb on the + // param dialog's submit button) — one row label, authored in all three + // locales. + expect(translated.length, `${locale} positive control`).toBe(697); } // ⭐ 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 acf7ecf78e1..49682fb7368 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 @@ -1807,6 +1807,10 @@ export const zhCNMetadataForms: NonNullable = label: "描述", helpText: "显示在此动作参数对话框标题下方的说明文字。对于会收集参数的动作,确认问题应写在这里而不是 confirmText 中——一个对话框,而不是两个。它不是 ai.description,后者是 AI 智能体读取的文字。" }, + submitText: { + label: "提交按钮文字", + helpText: "此动作参数对话框中提交按钮的文字,即按钮执行的动作(例如“发送”“记录通话”)。仅用于需要收集参数的动作。留空则显示“确认”。" + }, confirmText: { label: "确认文本", helpText: "执行前的确认提示(如 \"确定要执行吗?\")" diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 904026d9bac..719b74620df 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -17,12 +17,11 @@ "note": "Added #7367 — the RENDERER half predates the key by design: the consumer chain above shipped while `ActionSchema` (strictObject) refused `description`, so no producer could reach it (the mirror image of declared-but-unenforced). Live from the moment this key exists: the dialog reads `action.description` directly, no second input required. Mechanism for the 2026-08-10 ruling on #7278 — an action with `params` carries its confirm question here instead of in `confirmText`, so one decision is one dialog. Moved out of spec source, #23434: packages/spec/src/ui/action.zod.ts (this key's docblock) and packages/spec/src/system/translation.zod.ts (the translation slot's comment) cited this chain by line, sha-less. Re-read at objectui @b9814b334 on 2026-10-15: the object-then-global fallback both described, and this entry's evidence with them, is gone — objectui#11439 retired it, so the client resolves one bundle entry the way packages/spec/src/system/i18n-resolver.ts#actionTranslationNode does." }, "submitText": { - "status": "planned", - "verifiedAt": "2026-10-30", - "authorWarn": true, - "authorHint": "Keep it, but nothing reads it yet: no console draws an action's `submitText`, so the param dialog's button still says \"Confirm\" until the console's param dialog reads the key.", - "evidence": "packages/spec/src/system/i18n-resolver.ts#translateAction overlays the translated `objects.{o}._actions.{a}.submitText` (or `globalActions.{a}.submitText`) onto the served action when it declares `params`; packages/spec/src/system/translation.zod.ts declares the translation slot; packages/lint/src/validate-translation-references.ts#checkActionSubmitText refuses a translation under an action with no `params`.", - "note": "PLANNED until the console half of #27944 lands (spec PR first, console card second). The framework half is live — parse, blank refusal, the refusal without `params`, translation slot, translateAction overlay, the dangling-translation lint — but no renderer reads the key yet: ui/packages/app-shell/src/views/ActionParamDialog.tsx still draws `actionDialog.confirm`, 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 \"Confirm\", not their verb. 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, #21127)." + "status": "live", + "verifiedAt": "2026-10-31", + "evidenceScope": "in-repo", + "evidence": "ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx#paramCollectionHandler and ui/packages/app-shell/src/views/RecordDetailView.tsx#paramCollectionHandler (the two param-collection handlers: the bundle's `objects.{o}._actions.{a}.submitText`, or `globalActions.{a}.submitText` for an object-less action, through `actionSubmitText`, then the authored `I18nLabel` collapsed to the active language); ui/packages/app-shell/src/views/ActionParamDialog.tsx (draws it on the submit button, `actionDialog.confirm` when absent, `actionDialog.uploading` while a file uploads); 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); ui/packages/core/src/actions/ActionRunner.ts#ActionDef (the key derived from the spec); packages/spec/src/system/i18n-resolver.ts#translateAction (the served overlay); packages/lint/src/validate-translation-references.ts#checkActionSubmitText (a translation under an action with no `params` is refused)", + "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)." }, "objectName": { "status": "live", diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index 0ebe1aa66f9..d44ea8eb3e9 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` | 49 | 0 | 0 | 1 | 1 | 51 | +| `action` | 50 | 0 | 0 | 1 | 0 | 51 | 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 4cc7051f14a..eb971505f84 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -365,17 +365,6 @@ const LEDGER: ReadonlyArray = [ key: 'conditionalFormatting', why: "planned, not yet rendered — a field's cell formatting rules (`[{ condition, style }]`, the list view's own rule element) are validated at parse and their CEL is judged at the authoring doors (it parses, it reads only `value` and `record`), but no renderer styles a cell from them until objectui's cell renderers read them, after the spec publishes. A form control offered today would let an author set a style nothing paints", }, - // #27944 — the param dialog's submit label lands spec-first the same way: - // `planned` in the liveness ledger, drawn by no renderer until the console's - // param dialog reads it. The console change that flips the ledger row - // deletes this row and offers the key in `action.form.ts`. - { - kind: 'omit', - type: 'action', - path: ROOT_PATH, - key: 'submitText', - why: "planned, not yet rendered — the param dialog's submit button label is validated at parse (an I18nLabel, never blank, only beside a non-empty `params`) and translated by `translateAction`, but the console's param dialog still draws its default \"Confirm\" until it reads the key. A form control offered today would let an author set a button label nothing shows", - }, // [ADR-0144 D5] A record-mode amount's home-currency projection lands // spec-first the same way: declared and judged at parse, written by nothing // until the engine's write-time projection lands in the same release (D9). diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index cb47191b326..dcc458e0584 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -220,7 +220,7 @@ const actionTranslationSchema = (surface: string) => strictObject({ // `ActionSchema.submitText` (#27944) — the param dialog's submit button // label; overlaid by `translateAction` like `description`, the same // dialog's line under its title, for an action that declares `params`. - submitText: z.string().optional().describe('Translated label of the submit button in the action\'s param dialog (the action must declare `params`; no renderer shows it yet)'), + submitText: z.string().optional().describe('Translated label of the submit button in the action\'s param dialog (the action must declare `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 924e7234cca..f4d2b79bd62 100644 --- a/packages/spec/src/ui/action.form.ts +++ b/packages/spec/src/ui/action.form.ts @@ -114,6 +114,11 @@ export const actionForm = defineForm({ // map resolves to its own branch and opens the raw JSON editor rather // than being read as text. { field: 'description', widget: 'textarea', helpText: "Explanatory line under the title of this action's param dialog. On an action that collects params, the confirm question goes here rather than in confirmText — one dialog, not two. Not ai.description, which is the text an AI agent reads." }, + // `submitText` is an I18nLabel too, the same node as `description` above, + // so it takes the same plain row and the renderer derives the same face. + // 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".' }, { 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 013cf91c3c3..375cb7369ce 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1419,7 +1419,7 @@ const actionObject = () => strictObject({ * The submit button label of the dialog `params` opens — the contract is * on {@link actionSubmitText}. */ - submitText: actionSubmitText().optional().describe('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" (declared ahead of its console reader: no renderer shows it yet) — 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.'), + submitText: actionSubmitText().optional().describe('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.'), /** 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.flowRowGuard-13397.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowRowGuard-13397.test.tsx index b17124339b2..7cadadd364c 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 @@ -54,6 +54,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => { actionParamText: (_o: unknown, _a: unknown, _p: unknown, _attr: unknown, fallback: string) => fallback, 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, }), 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 3adbe5f1189..ccf7b31b5c3 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 @@ -57,6 +57,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => ({ 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, }), 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 888e724eaf9..b2630b8d5e8 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 @@ -60,6 +60,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => ({ 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) => bundleDescription ?? fallback, + actionSubmitText: (_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 3b6d987ed57..be19e3e172a 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 @@ -58,6 +58,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => ({ 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, }), 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 2955e5e07ff..b0e0b4b36e7 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 @@ -38,6 +38,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => ({ 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, }), 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 new file mode 100644 index 00000000000..4ae97709000 --- /dev/null +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.submitText-28158.test.tsx @@ -0,0 +1,159 @@ +/** + * 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 + * `submitText` — the verb on the param dialog's submit button — the way it + * resolves `description` (objectstack#28158): + * + * 1. the translation bundle's `objects.OBJECT._actions.ACTION.submitText` + * (`globalActions.ACTION.submitText` for an object-less action), through + * `useObjectLabel().actionSubmitText`; + * 2. else the authored inline locale map, collapsed to the active language; + * 3. else the authored string. + * + * The bundle lookup itself (which node an action reads) is `@object-ui/i18n`'s + * and is pinned there (`useObjectLabel-actionSubmitText-28158.test.tsx`). Here + * the resolver is a stand-in that records what it is asked, so this file pins + * the HANDLER's half: which key object and name it asks with, that the + * fallback it offers is already one string in the active language, and that + * the answer reaches the dialog. `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: (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 submit verb (objectstack#28158)', () => { + it('hands the dialog the authored string', async () => { + await collectParams({ name: 'send_email', label: 'Send Email', objectName: 'crm_contact', submitText: 'Send' }); + expect(paramDialogState.submitText).toBe('Send'); + expect(asked).toEqual([{ objectName: 'crm_contact', actionName: 'send_email', fallback: 'Send' }]); + }); + + 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', submitText: 'Send' }); + expect(paramDialogState.submitText).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', + submitText: { en: 'Send', 'zh-CN': '发出' }, + }); + expect(paramDialogState.submitText).toBe('发出'); + expect(asked[0].fallback).toBe('发出'); + }); + + it('asks the global node for an object-less action', async () => { + bundle['global|log_call'] = '记录通话'; + await collectParams({ name: 'log_call', label: 'Log Call', submitText: 'Log Call' }); + expect(asked[0]).toMatchObject({ objectName: undefined, actionName: 'log_call' }); + expect(paramDialogState.submitText).toBe('记录通话'); + }); + + it('hands the dialog nothing when the action names no verb and the bundle has none — the dialog says Confirm', async () => { + await collectParams({ name: 'send_email', label: 'Send Email', objectName: 'crm_contact' }); + expect(paramDialogState.submitText).toBeUndefined(); + expect(asked[0].fallback).toBeUndefined(); + }); +}); diff --git a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 6a989142cb3..b280c03e7f4 100644 --- a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -25,7 +25,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; -import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import { useObjectLabel, useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import { ActionProvider, useGlobalUndo, useMetadata, type ActionProviderProps } from '@object-ui/react'; import { toast } from 'sonner'; import type { @@ -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, actionResultDialog } = useObjectLabel(); + const { fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel, actionDescription, actionSubmitText, 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 @@ -342,6 +342,11 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons const overrideNotice = typeof action?.overrideNotice === 'string' && action.overrideNotice ? action.overrideNotice : undefined; + // The submit button's verb (`submitText`): the bundle's + // `_actions..submitText` (`globalActions..submitText` for an + // 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); setParamState({ open: true, params: localized, @@ -356,6 +361,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons description: overrideNotice ? [overrideNotice, declaredDescription].filter(Boolean).join('\n\n') : declaredDescription, + submitText, interpolate, resolve, // The runner's `run`, so Confirm runs the action from inside the @@ -363,7 +369,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons run, }); })(); }); - }, [objectName, objectDef, objects, metadata, fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel]); + }, [objectName, objectDef, objects, metadata, fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel, actionSubmitText, 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/ActionParamDialog.submitText-28158.test.tsx b/ui/packages/app-shell/src/views/ActionParamDialog.submitText-28158.test.tsx new file mode 100644 index 00000000000..06bea2d92ee --- /dev/null +++ b/ui/packages/app-shell/src/views/ActionParamDialog.submitText-28158.test.tsx @@ -0,0 +1,93 @@ +/** + * 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 `submitText` on its submit button + * (`ActionSchema.submitText`, objectstack#28158): the verb the button performs + * ("Send", "Log Call") in place of the generic Confirm. The caller hands it the + * already-resolved text; the dialog falls back to `actionDialog.confirm` when + * there is none, and says `actionDialog.uploading` while a file param uploads, + * whatever the verb. No i18n provider is mounted, so `t()` answers the key — + * which is what lets these assertions tell the fallback from the verb. + * + * The upload widget is stubbed the way `ActionParamDialog.uploading.test.tsx` + * stubs it, so the mid-upload window is deterministic. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ActionParamDef } from '@object-ui/core'; + +vi.mock('@object-ui/fields', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getLazyFieldWidget: (type: string) => { + if (type === 'file') { + return function FakeUploadWidget({ onUploadingChange }: { onUploadingChange?: (u: boolean) => void }) { + return ( + + ); + }; + } + return actual.getLazyFieldWidget(type); + }, + }; +}); + +// Import AFTER the mock is registered. +const { ActionParamDialog } = await import('./ActionParamDialog'); + +const TEXT_PARAMS: ActionParamDef[] = [{ name: 'subject', label: 'Subject', type: 'text' }]; + +function openDialog(params: ActionParamDef[], submitText?: string) { + const resolve = vi.fn(); + render( + {}} + />, + ); + return resolve; +} + +describe('ActionParamDialog — the submit button names the action\'s verb (objectstack#28158)', () => { + it('draws the action\'s `submitText` on the submit button', async () => { + openDialog(TEXT_PARAMS, 'Send'); + const button = await screen.findByRole('button', { name: 'Send' }); + expect(button).toBeTruthy(); + expect(screen.queryByText('actionDialog.confirm')).toBeNull(); + }); + + it('falls back to the generic Confirm when the action names no verb', async () => { + openDialog(TEXT_PARAMS); + expect(await screen.findByRole('button', { name: 'actionDialog.confirm' })).toBeTruthy(); + }); + + it('falls back to Confirm for an empty string, never an empty button', async () => { + openDialog(TEXT_PARAMS, ''); + expect(await screen.findByRole('button', { name: 'actionDialog.confirm' })).toBeTruthy(); + }); + + it('the verb button submits like Confirm does', async () => { + const resolve = openDialog(TEXT_PARAMS, 'Send'); + fireEvent.change(await screen.findByLabelText(/Subject/), { target: { value: 'Hello' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + expect(resolve).toHaveBeenCalledWith({ subject: 'Hello' }); + }); + + it('says Uploading while a file param uploads, whatever the verb', async () => { + openDialog([{ name: 'doc', label: 'Doc', type: 'file' }], 'Send'); + fireEvent.click(await screen.findByTestId('start-upload')); + + const uploading = screen.getByText('actionDialog.uploading').closest('button')!; + expect(uploading).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Send' })).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/ActionParamDialog.tsx b/ui/packages/app-shell/src/views/ActionParamDialog.tsx index bb5693535b7..11f12eda40b 100644 --- a/ui/packages/app-shell/src/views/ActionParamDialog.tsx +++ b/ui/packages/app-shell/src/views/ActionParamDialog.tsx @@ -96,6 +96,14 @@ export interface ParamDialogState { * so the dialog reads as the task, not a generic param prompt. */ title?: string; description?: string; + /** + * Label of the submit button — the action's declared `submitText`, already + * resolved by the caller (the translation bundle's + * `_actions..submitText` first, then the authored text collapsed to + * the active language). Absent, the button says the generic "Confirm"; while + * a file is uploading it says "Uploading…" either way. + */ + submitText?: string; /** * Fills the `${...}` holes of each param's `helpText` — the runner's * `ParamCollectionHandler` `interpolate` argument, passed through. It runs on @@ -913,7 +921,7 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp aria-busy={submitting || undefined} > {submitting &&