diff --git a/.changeset/29249-inline-action-description-locale.md b/.changeset/29249-inline-action-description-locale.md new file mode 100644 index 00000000000..252b0dbe69b --- /dev/null +++ b/.changeset/29249-inline-action-description-locale.md @@ -0,0 +1,18 @@ +--- +'@object-ui/react': patch +--- + +fix(react): an inline action's `description` locale map shows in the user's language in the param dialog + +Clause-②: no +Metadata-protocol: none + +An inline action on a page (`element:button` with `properties.action`) can declare a `description`, the line its param dialog shows under the title. It may be written as a per-locale map such as `{ en: '…', 'zh-CN': '…' }`. Before this change the action text localizer left that map alone, so the dialog received an object instead of a sentence. + +What changes: + +- `useActionTextLocalizer` now collapses a declared `description` to the active language, the same way it already treats `confirmText`, `successMessage` and `errorMessage`. A plain string comes back unchanged, and a `{ dialect: 'template', source }` envelope comes back as itself. +- It does not read a translation bundle entry for `description`. The param-dialog host still resolves `_actions..description` over the string it is handed. +- An action that declares no `description` gains no key, so its dialog keeps the generic "Please provide the required information to continue." line. + +What to do: nothing. Nothing a page, view or action declares changes. diff --git a/ui/packages/components/src/__tests__/element-button-paramDialogDescription-29249.test.tsx b/ui/packages/components/src/__tests__/element-button-paramDialogDescription-29249.test.tsx new file mode 100644 index 00000000000..4980e03fa30 --- /dev/null +++ b/ui/packages/components/src/__tests__/element-button-paramDialogDescription-29249.test.tsx @@ -0,0 +1,156 @@ +/** + * 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#29249 — an inline `element:button` action's `description` + * reaches the param dialog in the user's language. + * + * `InlineActionSchema` picks `description` (an `I18nLabel`: a string or a + * per-locale map). The button hands its action to `useActionTextLocalizer` + * before the runner, so a map collapses to the active language there; the + * runner and the param-collection host have no language of their own. + * + * The path is the real one up to the param-collection seam: the registered + * `element:button`, the real localizer, the real `ActionRunner` behind + * `ActionProvider`. The dialog is this file's host, drawn with this package's + * `Dialog` the way the console's `ActionParamDialog` (`@object-ui/app-shell`, + * which this package cannot import) draws its line: the action's description, + * else the generic `actionDialog.description`. + * + * Each row first asserts the dialog opened with the action's own title (the + * positive control), so a missing line is never "no dialog opened". + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import type { ActionContext, ActionDef, ActionResult, ParamCollectionHandler } from '@object-ui/core'; +import { ActionProvider } from '@object-ui/react'; +import { I18nProvider, useObjectTranslation } from '@object-ui/i18n'; +import { Dialog, DialogContent, DialogTitle, DialogDescription } from '../ui/dialog'; +import '../renderers/basic/elements'; + +let api: Mock<(action: ActionDef, ctx: ActionContext) => Promise>; +let handed: Array; + +beforeEach(() => { + api = vi.fn(async () => ({ success: true })); + handed = []; +}); + +/** The producer's shape: a nameless inline `api` action that collects params. */ +const INVITE = { + type: 'api', + target: '/api/v1/cloud/invitations', + label: { en: 'Invite member', 'zh-CN': '邀请成员' }, + params: [{ name: 'email', label: 'Email', type: 'email', required: true }], +}; + +const DESCRIPTION = { + en: 'We e-mail them an invitation link.', + 'zh-CN': '我们会向对方发送一封邀请邮件。', +}; + +/** + * The registered renderer, read once at module scope (a component created + * during render remounts on every render). An unregistered `element:button` + * fails the whole file here, before any row runs. + */ +function registeredElementButton() { + const C = ComponentRegistry.get('element:button'); + if (!C) throw new Error('element:button not registered'); + return C; +} +const ElementButton = registeredElementButton(); + +/** + * The param-collection host: opens a dialog from what the runner hands it and + * leaves the promise pending, as a dialog awaiting its Confirm does. + */ +function ParamHost({ action }: { action: Record }) { + const { t } = useObjectTranslation(); + const [dialog, setDialog] = React.useState<{ title: string; description: string } | null>(null); + const onParamCollection = React.useCallback((_params, dispatched) => { + handed.push(dispatched); + // A non-string reaching the host is drawn as nothing, so the assertions + // below see the gap instead of React refusing an object child. + const title = dispatched?.label; + const description = dispatched?.description; + setDialog({ + title: typeof title === 'string' ? title : '', + description: typeof description === 'string' ? description : '', + }); + return new Promise(() => {}); + }, []); + return ( + + + {dialog && ( + + + {dialog.title} + {dialog.description || t('actionDialog.description')} + + + )} + + ); +} + +function renderIn(language: string, action: Record) { + return render( + + + , + ); +} + +describe('element:button — the param dialog shows the inline description in the active language (objectstack#29249)', () => { + it('a zh-CN/en map shows the zh-CN string under a zh-CN locale', async () => { + renderIn('zh-CN', { ...INVITE, description: DESCRIPTION }); + + fireEvent.click(screen.getByRole('button', { name: '邀请成员' })); + + const dialog = await screen.findByRole('dialog'); + expect(screen.getByRole('heading', { name: '邀请成员' })).toBeTruthy(); + expect(dialog.textContent).toContain('我们会向对方发送一封邀请邮件。'); + // The runner was handed one string, never the map. + expect(handed).toHaveLength(1); + expect(handed[0]?.description).toBe('我们会向对方发送一封邀请邮件。'); + // Nothing runs until the dialog's own Confirm. + expect(api).not.toHaveBeenCalled(); + }); + + it('the same map shows the English string under an en locale', async () => { + renderIn('en', { ...INVITE, description: DESCRIPTION }); + + fireEvent.click(screen.getByRole('button', { name: 'Invite member' })); + + const dialog = await screen.findByRole('dialog'); + expect(screen.getByRole('heading', { name: 'Invite member' })).toBeTruthy(); + expect(dialog.textContent).toContain('We e-mail them an invitation link.'); + expect(handed[0]?.description).toBe('We e-mail them an invitation link.'); + }); + + it('no description: the dialog keeps its generic line', async () => { + renderIn('zh-CN', INVITE); + + fireEvent.click(screen.getByRole('button', { name: '邀请成员' })); + + const dialog = await screen.findByRole('dialog'); + expect(screen.getByRole('heading', { name: '邀请成员' })).toBeTruthy(); + expect(dialog.textContent).toContain('请填写以下信息以继续操作。'); + expect(handed[0]?.description).toBeUndefined(); + }); +}); diff --git a/ui/packages/react/src/hooks/__tests__/useActionTextLocalizer.description-29249.test.tsx b/ui/packages/react/src/hooks/__tests__/useActionTextLocalizer.description-29249.test.tsx new file mode 100644 index 00000000000..5ee764fd242 --- /dev/null +++ b/ui/packages/react/src/hooks/__tests__/useActionTextLocalizer.description-29249.test.tsx @@ -0,0 +1,113 @@ +/** + * 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#29249 — an action's `description`, the line the param dialog + * draws under its title, is declared `I18nLabel` copy like `confirmText`: an + * inline per-locale map collapses to the active language before the runner + * and the dialog, which have no language, receive it. + * + * Declared-only and collapse-only, like `errorMessage`: an action that + * declares no `description` gains no key (the dialog keeps its generic line), + * and the bundle's `_actions..description` is not read here — the + * param-collection host resolves that entry over the string handed on. + * + * Measured under a real `I18nProvider`, reading what the localizer returns. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, renderHook } from '@testing-library/react'; +import React from 'react'; +import { I18nProvider, useObjectTranslation } from '@object-ui/i18n'; +import { useActionTextLocalizer } from '../useActionTextLocalizer'; + +afterEach(() => cleanup()); + +const DESCRIPTION = { + en: 'We e-mail them an invitation link.', + 'zh-CN': '我们会向对方发送一封邀请邮件。', +}; + +/** The producer's shape: a nameless inline `api` action that collects params. */ +const INVITE = { + type: 'api', + target: '/api/v1/cloud/invitations', + label: { en: 'Invite member', 'zh-CN': '邀请成员' }, + params: [{ name: 'email', label: 'Email', type: 'email', required: true }], +}; + +function harness(language: string, bundle?: Record) { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + const { result } = renderHook( + () => ({ localize: useActionTextLocalizer(), translation: useObjectTranslation() }), + { wrapper }, + ); + if (bundle) result.current.translation.i18n.addResourceBundle(language, 'translation', bundle, true, true); + return result; +} + +describe('useActionTextLocalizer — description (objectstack#29249)', () => { + it('a nameless inline action: the locale map collapses to the zh-CN string', () => { + const result = harness('zh-CN'); + const localized = result.current.localize(undefined, { ...INVITE, description: DESCRIPTION }); + expect(localized.description).toBe('我们会向对方发送一封邀请邮件。'); + }); + + it('the same map reads the English entry under an en locale', () => { + const result = harness('en'); + const localized = result.current.localize(undefined, { ...INVITE, description: DESCRIPTION }); + expect(localized.description).toBe('We e-mail them an invitation link.'); + }); + + it('a named action collapses the map too, and the bundle entry is not read here', () => { + const result = harness('zh-CN', { + sys: { globalActions: { invite_member: { label: '邀请同事', description: '来自翻译包的说明' } } }, + }); + const localized = result.current.localize(undefined, { + ...INVITE, + name: 'invite_member', + description: DESCRIPTION, + }); + // Positive control: the same bundle node IS read for `label`, so the + // harness reached a named action's bundle path. + expect(localized.label).toBe('邀请同事'); + // Collapse-only: the host's `actionDescription` owns the bundle read. + expect(localized.description).toBe('我们会向对方发送一封邀请邮件。'); + }); + + it('a `template` envelope comes back as itself, never collapsed as a locale map', () => { + const result = harness('zh-CN'); + const envelope = { dialect: 'template', source: 'Invite {{ record.name }} to the team.' }; + const action = { ...INVITE, label: 'Invite member', description: envelope }; + const localized = result.current.localize(undefined, action); + expect(localized.description).toBe(envelope); + expect(localized.description).toEqual({ dialect: 'template', source: 'Invite {{ record.name }} to the team.' }); + }); + + it('a plain string comes back unchanged', () => { + const result = harness('zh-CN'); + const localized = result.current.localize(undefined, { + ...INVITE, + description: 'We e-mail them an invitation link.', + }); + expect(localized.description).toBe('We e-mail them an invitation link.'); + }); + + it('an action with no description gains no key, nameless or named', () => { + const result = harness('zh-CN', { + sys: { globalActions: { invite_member: { description: '来自翻译包的说明' } } }, + }); + const nameless = result.current.localize(undefined, INVITE); + const named = result.current.localize(undefined, { ...INVITE, name: 'invite_member' }); + expect('description' in nameless).toBe(false); + expect('description' in named).toBe(false); + }); +}); diff --git a/ui/packages/react/src/hooks/useActionTextLocalizer.ts b/ui/packages/react/src/hooks/useActionTextLocalizer.ts index 272221bc573..ccebd9289eb 100644 --- a/ui/packages/react/src/hooks/useActionTextLocalizer.ts +++ b/ui/packages/react/src/hooks/useActionTextLocalizer.ts @@ -62,14 +62,22 @@ * declares resolves `_actions..outcomeMessages.`; a bundle * entry for an outcome the action does not declare is never read, so a * bundle cannot add copy for an answer the author never wrote one for. - * - **`label`, `confirmText`, `successMessage`, `errorMessage` and each - * `outcomeMessages` entry are reduced through `pickLocalized` first**, - * because each is an `I18nLabel` — a string or an inline per-locale map. The map is collapsed to the active language + * - **`label`, `confirmText`, `successMessage`, `errorMessage`, `description` + * and each `outcomeMessages` entry are reduced through `pickLocalized` + * first**, because each is an `I18nLabel` — a string or an inline per-locale map. The map is collapsed to the active language * BEFORE it is offered to the bundle as the fallback, so a per-locale * literal and a bundle entry cannot disagree about what "the authored text" * is, and the action runner, which has no language, only ever receives a * string. `errorMessage` has no bundle key (the spec's action translation * declares none), so the collapse is all it gets (objectui#12757). + * - **`description` is collapse-only too** (objectstack#29249): the line the + * param dialog draws under its title is an `I18nLabel` on an inline action + * (`InlineActionSchema` picks it), so a locale map collapses to the active + * language here, before the runner and the dialog, which have no language, + * receive it. Its bundle entry (`_actions..description`) is NOT read + * here: the param-collection host resolves it (`actionDescription` in + * `useConsoleActionRuntime`), over the string this hook hands on. An action + * that declares none gains no key, so the dialog keeps its generic line. * - **`disabledReason` follows the same declared-only rule** * (objectstack#26619): the reason a greyed action gives resolves * `_actions..disabledReason` only when the action declares one, after @@ -90,7 +98,8 @@ * scope `onSuccess.navigate` reads — one interpolation, in one place. * - **The `template` envelope keeps its shape** (objectui#13367, the renderer * half of objectstack#24814). `confirmText`, `successMessage`, - * `errorMessage` and each `outcomeMessages` entry (objectstack#27948) may + * `errorMessage`, `description` and each `outcomeMessages` entry + * (objectstack#27948) may * arrive as `{ dialect: 'template', source }` instead of a string. The * envelope is not an `I18nLabel` map — read as one it would * collapse to its first string value, the word `template` — so its `source` @@ -113,10 +122,11 @@ export interface ActionTextLocalizerOptions { /** * Returns a copy of `action` with `label` — plus `confirmText`, - * `successMessage`, `errorMessage`, `outcomeMessages`, `disabledReason` and - * `cancelText` when the action declares them — + * `successMessage`, `errorMessage`, `description`, `outcomeMessages`, + * `disabledReason` and `cancelText` when the action declares them — * resolved from the active locale's bundle, falling back to the authored - * literals. `objectName` is the host object the action is drawn on; the + * literals (`errorMessage` and `description` are collapsed to the active + * language only; neither reads the bundle here). `objectName` is the host object the action is drawn on; the * action's own declared `objectName` wins over it when present. */ export type ActionTextLocalizer = >( @@ -126,7 +136,7 @@ export type ActionTextLocalizer = >( ) => T; /** The declared `I18nLabel` copy, besides `label`, that reaches the runner. */ -const DECLARED_COPY_KEYS = ['confirmText', 'successMessage', 'errorMessage'] as const; +const DECLARED_COPY_KEYS = ['confirmText', 'successMessage', 'errorMessage', 'description'] as const; /** * Collapse one declared copy value to the active language: an `I18nLabel`