From f7554578234799b70926660bf66ae545f1248261 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 04:18:19 +0000 Subject: [PATCH 1/3] feat(console): an action's confirm and param dialogs draw its cancelText on the dismiss button Claude-Session: https://claude.ai/code/session_01QsR2jQtTxhjJ5tQUg79qSg Co-Authored-By: Claude --- content/docs/protocol/objectui/actions.mdx | 4 +- packages/cli/src/utils/i18n-extract.ts | 27 ++ .../i18n-extract-action-cancel-text.test.ts | 155 +++++++++++ .../src/identity/sys-invitation.object.ts | 3 + packages/spec/liveness/action.json | 9 +- .../metadata-form-zod-reconciliation.test.ts | 13 +- packages/spec/src/system/translation.zod.ts | 2 +- packages/spec/src/ui/action.form.ts | 4 + packages/spec/src/ui/action.zod.ts | 2 +- ...oleActionRuntime.cancelText-28879.test.tsx | 158 +++++++++++ ...createUserEmailRequiredMark-28280.test.tsx | 1 + ...ime.createUserRequiredField-28289.test.tsx | 1 + ...eActionRuntime.flowRowGuard-13397.test.tsx | 1 + ...nRuntime.oneToastPerRefusal-13340.test.tsx | 1 + ...nsoleActionRuntime.overrideNotice.test.tsx | 1 + ...oleActionRuntime.paramDialogTitle.test.tsx | 1 + ...eActionRuntime.paramRefusal-13161.test.tsx | 1 + ...oleActionRuntime.submitText-28158.test.tsx | 1 + .../src/hooks/useConsoleActionRuntime.tsx | 8 +- ...ionConfirmDialog.cancelText-28879.test.tsx | 49 ++++ ...ctionParamDialog.cancelText-28879.test.tsx | 59 ++++ .../app-shell/src/views/ActionParamDialog.tsx | 9 +- ...dDetailView.paramCancelText-28879.test.tsx | 263 ++++++++++++++++++ ...etailView.paramRuntimeParity-6431.test.tsx | 5 + .../app-shell/src/views/RecordDetailView.tsx | 8 +- .../action-forward-precedence.test.tsx | 8 +- .../src/renderers/action/action-button.tsx | 2 + .../src/renderers/action/action-group.tsx | 2 + .../src/renderers/action/action-icon.tsx | 2 + .../src/renderers/action/action-menu.tsx | 2 + ui/packages/core/src/actions/ActionRunner.ts | 24 +- ...tionRunner.confirmCancelText-28879.test.ts | 76 +++++ ui/packages/core/src/actions/actionKeys.ts | 9 +- ...bjectLabel-actionCancelText-28879.test.tsx | 77 +++++ .../useObjectLabel-identity-5564.test.tsx | 6 +- ui/packages/i18n/src/useObjectLabel.ts | 17 ++ ...ionTextLocalizer.cancelText-28879.test.tsx | 103 +++++++ .../react/src/hooks/useActionTextLocalizer.ts | 26 +- ui/packages/types/src/ui-action.ts | 10 + 39 files changed, 1110 insertions(+), 40 deletions(-) create mode 100644 packages/cli/test/i18n-extract-action-cancel-text.test.ts create mode 100644 ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.cancelText-28879.test.tsx create mode 100644 ui/packages/app-shell/src/views/ActionConfirmDialog.cancelText-28879.test.tsx create mode 100644 ui/packages/app-shell/src/views/ActionParamDialog.cancelText-28879.test.tsx create mode 100644 ui/packages/app-shell/src/views/RecordDetailView.paramCancelText-28879.test.tsx create mode 100644 ui/packages/core/src/actions/__tests__/ActionRunner.confirmCancelText-28879.test.ts create mode 100644 ui/packages/i18n/src/__tests__/useObjectLabel-actionCancelText-28879.test.tsx create mode 100644 ui/packages/react/src/hooks/__tests__/useActionTextLocalizer.cancelText-28879.test.tsx 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/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/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/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 )} - +