From de57e7b4ecde17c493ef2d408cdcac16473d449a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 21:26:20 +0000 Subject: [PATCH 1/4] fix(components,react): element:button resolves its inline action's I18nLabel copy element:button now hands its inline action to the runner through useActionTextLocalizer, like every other action surface, and the localizer collapses every declared I18nLabel copy (label, confirmText, successMessage, errorMessage) to the active language, on a nameless action too. A locale-map successMessage now toasts the user's language instead of the runner's generic copy. Claude-Session: https://claude.ai/code/session_01UNQYdaeXCqyhJF48NnmykY Co-authored-by: Claude --- .../element-button-i18nCopy-12757.test.tsx | 140 ++++++++++++++++++ .../src/renderers/basic/elements.tsx | 17 ++- ...TextLocalizer.i18nLabelCopy-12757.test.tsx | 84 +++++++++++ .../react/src/hooks/useActionTextLocalizer.ts | 43 ++++-- 4 files changed, 265 insertions(+), 19 deletions(-) create mode 100644 packages/components/src/__tests__/element-button-i18nCopy-12757.test.tsx create mode 100644 packages/react/src/hooks/__tests__/useActionTextLocalizer.i18nLabelCopy-12757.test.tsx 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 (