diff --git a/.changeset/12757-action-text-localizer-copy.md b/.changeset/12757-action-text-localizer-copy.md new file mode 100644 index 0000000000..2b4f311f2d --- /dev/null +++ b/.changeset/12757-action-text-localizer-copy.md @@ -0,0 +1,9 @@ +--- +'@object-ui/react': patch +--- + +`useActionTextLocalizer` resolves every `I18nLabel` copy an action declares (objectui#12757). It used to collapse a per-locale map only for `label` and `successMessage`, and only when the action had a `name`. A `confirmText` or `errorMessage` map, or any map on a nameless action, such as an inline `element:button` action, reached the action runner as an object, so the confirm prompt received an object and the toasts fell back to the runner's generic copy. + +The localizer now collapses `label`, `confirmText`, `successMessage` and `errorMessage` to the active language on every action, named or not. A translation-bundle entry for a named action still wins over the collapsed copy. `errorMessage` has no bundle key, so the collapse is all it gets. An action whose copy is already plain text comes back unchanged. + +Clause-②: no. The hook's signature and the package entry are unchanged; no export, prop or i18n key is added or removed. diff --git a/.changeset/12757-element-button-i18n-copy.md b/.changeset/12757-element-button-i18n-copy.md new file mode 100644 index 0000000000..bab4ad3d87 --- /dev/null +++ b/.changeset/12757-element-button-i18n-copy.md @@ -0,0 +1,9 @@ +--- +'@object-ui/components': patch +--- + +A page's `element:button` shows its inline action's copy in the user's language (objectui#12757). The spec types an inline action's `label`, `confirmText`, `successMessage` and `errorMessage` as `I18nLabel`: a string or a per-locale map keyed by language tag (`en`, `zh-CN`, …). The button handed a map to the action runner as is, and the runner, which has no language, skipped it: the success toast read the generic "completed successfully" copy, the error toast read the generic failure copy, and the confirm prompt and the param dialog's title received an object instead of text. + +The button now resolves its action through `useActionTextLocalizer`, the localizer every other action surface already uses, before it runs it. A map reads the entry for the user's language, and a translation-bundle entry for a named action still wins over the authored copy. Copy written as a plain string reaches the runner unchanged. + +Clause-②: no. No prop, export or i18n key is added or removed; the button's props and the package entry are unchanged. diff --git a/packages/components/src/__tests__/element-button-i18nCopy-12757.test.tsx b/packages/components/src/__tests__/element-button-i18nCopy-12757.test.tsx new file mode 100644 index 0000000000..4e09d46122 --- /dev/null +++ b/packages/components/src/__tests__/element-button-i18nCopy-12757.test.tsx @@ -0,0 +1,140 @@ +/** + * 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. + */ + +/** + * objectui#12757 — `element:button` resolves its inline action's `I18nLabel` + * copy in the user's locale before the runner sees it. + * + * The spec types an inline action's `label`, `confirmText`, `successMessage` + * and `errorMessage` as `I18nLabel` — a string or a per-locale map. The runner + * has no language, so it skips a map: the success toast fell back to the + * runner's generic copy, and the confirm prompt and the param dialog's title + * (the action's `label`) received an object. Every other action surface hands + * the runner its copy through `useActionTextLocalizer`; these rows pin that + * `element:button` does too, at what the user reads — the toast, the confirm + * prompt — and at the def the handler receives. + * + * The action is nameless, like the producer's (objectstack-ai/cloud#2909's + * invite button): an inline action need not carry a `name`, and without one + * there is no bundle key, so the per-locale map is the only copy there is. + * + * Each row asserts the handler ran exactly once first (the positive control), + * so a wrong toast is never "the harness executed nothing". + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import type { ActionContext, ActionDef, ActionResult, ConfirmationHandler, ToastHandler } from '@object-ui/core'; +import { ActionProvider } from '@object-ui/react'; +import { I18nProvider } from '@object-ui/i18n'; +import '../renderers/basic/elements'; + +let api: Mock<(action: ActionDef, ctx: ActionContext) => Promise>; +let toast: Mock; +let confirm: Mock; + +beforeEach(() => { + api = vi.fn(async () => ({ success: true })); + toast = vi.fn(); + confirm = vi.fn(async () => true); +}); + +function renderButtonIn(language: string, action: Record) { + const C = ComponentRegistry.get('element:button'); + if (!C) throw new Error('element:button not registered'); + return render( + + + + + , + ); +} + +const dispatched = () => api.mock.calls[0][0]; + +/** The producer's shape: a nameless inline `api` action whose copy is a locale map. */ +const INVITE = { + type: 'api', + target: '/api/v1/cloud/invitations', + label: { en: 'Invite member', 'zh-CN': '邀请成员' }, + successMessage: { en: 'Invitation sent', 'zh-CN': '邀请已发送' }, +}; + +describe('element:button resolves its inline action I18nLabel copy (objectui#12757)', () => { + it('a locale-map successMessage toasts the zh-CN string under a zh-CN locale', async () => { + renderButtonIn('zh-CN', INVITE); + + fireEvent.click(screen.getByRole('button', { name: '邀请成员' })); + + await waitFor(() => expect(api).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(toast).toHaveBeenCalledTimes(1)); + expect(toast.mock.calls[0][0]).toBe('邀请已发送'); + expect(toast.mock.calls[0][1]).toEqual(expect.objectContaining({ type: 'success' })); + }); + + it('label, confirmText and errorMessage maps reach the runner as zh-CN strings', async () => { + api.mockImplementation(async () => ({ success: false, error: 'HTTP 500' })); + renderButtonIn('zh-CN', { + ...INVITE, + confirmText: { en: 'Send the invitation?', 'zh-CN': '确认发送邀请吗?' }, + errorMessage: { en: 'Invitation failed', 'zh-CN': '邀请失败' }, + }); + + fireEvent.click(screen.getByRole('button', { name: '邀请成员' })); + + await waitFor(() => expect(api).toHaveBeenCalledTimes(1)); + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm.mock.calls[0][0]).toBe('确认发送邀请吗?'); + // The label is what titles the param dialog the runtime opens. + expect(dispatched().label).toBe('邀请成员'); + await waitFor(() => expect(toast).toHaveBeenCalledTimes(1)); + expect(toast.mock.calls[0][0]).toBe('邀请失败'); + expect(toast.mock.calls[0][1]).toEqual(expect.objectContaining({ type: 'error' })); + }); + + it('the same map reads the English entry under an en locale', async () => { + renderButtonIn('en', INVITE); + + fireEvent.click(screen.getByRole('button', { name: 'Invite member' })); + + await waitFor(() => expect(api).toHaveBeenCalledTimes(1)); + expect(dispatched().label).toBe('Invite member'); + await waitFor(() => expect(toast).toHaveBeenCalledTimes(1)); + expect(toast.mock.calls[0][0]).toBe('Invitation sent'); + }); + + it('CONTROL: plain-string copy reaches the runner unchanged', async () => { + renderButtonIn('zh-CN', { + type: 'api', + target: '/api/v1/cloud/invitations', + label: 'Invite member', + confirmText: 'Send the invitation?', + successMessage: 'Invitation sent to ${result.email}', + errorMessage: 'Invitation failed', + }); + + fireEvent.click(screen.getByRole('button', { name: '邀请成员' })); + + await waitFor(() => expect(api).toHaveBeenCalledTimes(1)); + expect(confirm.mock.calls[0][0]).toBe('Send the invitation?'); + expect(dispatched()).toEqual(expect.objectContaining({ + label: 'Invite member', + confirmText: 'Send the invitation?', + successMessage: 'Invitation sent to ${result.email}', + errorMessage: 'Invitation failed', + })); + }); +}); diff --git a/packages/components/src/renderers/basic/elements.tsx b/packages/components/src/renderers/basic/elements.tsx index cd427aacb4..9d68e397b8 100644 --- a/packages/components/src/renderers/basic/elements.tsx +++ b/packages/components/src/renderers/basic/elements.tsx @@ -35,6 +35,7 @@ import { resolveInlineAriaProps, useAdapter, useAction, + useActionTextLocalizer, useDataInvalidation, useElementDataSource, useFilterScope, @@ -329,13 +330,21 @@ function ElementButtonRenderer({ schema }: { schema: any }) { const icon = props.icon ? : null; const { execute } = useAction(); + const localizeActionTexts = useActionTextLocalizer(); const [running, setRunning] = React.useState(false); - const action = props.action; + const declaredAction = props.action; const handleClick = React.useCallback(async () => { - if (!action || running) return; + if (!declaredAction || running) return; setRunning(true); try { + // The inline action's copy (`label`, `confirmText`, `successMessage`, + // `errorMessage`) is an `I18nLabel` in the spec, so it is resolved in the + // user's locale by the same localizer every other action surface uses, + // before it reaches the runner and the param dialog, which have no + // language (objectui#12757). A page carries no host object, so only the + // action's own name keys the bundle. + const action = localizeActionTexts(undefined, declaredAction); // Mirror action:button's param routing: an array of {name,type,…} defs is // forwarded for in-dialog collection. A plain object is passed as values // only for `type: 'api'` (the objectstack#5777 payload window); on any @@ -395,7 +404,7 @@ function ElementButtonRenderer({ schema }: { schema: any }) { } finally { setRunning(false); } - }, [action, execute, running]); + }, [declaredAction, execute, localizeActionTexts, running]); return (