Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/12757-action-text-localizer-copy.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .changeset/12757-element-button-i18n-copy.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<ActionResult>>;
let toast: Mock<ToastHandler>;
let confirm: Mock<ConfirmationHandler>;

beforeEach(() => {
api = vi.fn(async () => ({ success: true }));
toast = vi.fn();
confirm = vi.fn(async () => true);
});

function renderButtonIn(language: string, action: Record<string, unknown>) {
const C = ComponentRegistry.get('element:button');
if (!C) throw new Error('element:button not registered');
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<ActionProvider handlers={{ api }} onToast={toast} onConfirm={confirm}>
<C
schema={{
type: 'element:button',
properties: { label: { en: 'Invite member', 'zh-CN': '邀请成员' }, action },
} as never}
/>
</ActionProvider>
</I18nProvider>,
);
}

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',
}));
});
});
17 changes: 13 additions & 4 deletions packages/components/src/renderers/basic/elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
resolveInlineAriaProps,
useAdapter,
useAction,
useActionTextLocalizer,
useDataInvalidation,
useElementDataSource,
useFilterScope,
Expand Down Expand Up @@ -329,13 +330,21 @@ function ElementButtonRenderer({ schema }: { schema: any }) {
const icon = props.icon ? <LazyIcon name={props.icon} className="h-4 w-4" /> : 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
Expand Down Expand Up @@ -395,7 +404,7 @@ function ElementButtonRenderer({ schema }: { schema: any }) {
} finally {
setRunning(false);
}
}, [action, execute, running]);
}, [declaredAction, execute, localizeActionTexts, running]);

return (
<Button
Expand All @@ -404,7 +413,7 @@ function ElementButtonRenderer({ schema }: { schema: any }) {
size={size}
disabled={props.disabled || running}
className={cn(schema?.className)}
onClick={action ? handleClick : undefined}
onClick={declaredAction ? handleClick : undefined}
{...resolveInlineAriaProps(props.aria, locale)}
>
{iconPosition === 'left' && icon}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* 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.
*
* `useActionTextLocalizer` (objectui#12757) — every declared `I18nLabel` copy
* leaves the hook as a string.
*
* The spec types `label`, `confirmText`, `successMessage` and `errorMessage`
* as `I18nLabel`. The hook collapsed `label` and `successMessage` only, and
* only on a NAMED action, so a nameless inline action (`element:button`'s)
* and any action's `confirmText` / `errorMessage` map reached the runner, which
* has no language, as an object.
*/

import { describe, it, expect } from 'vitest';
import { renderHook } from '@testing-library/react';
import React from 'react';
import { I18nProvider, useObjectTranslation } from '@object-ui/i18n';
import { useActionTextLocalizer } from '../useActionTextLocalizer';

const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(I18nProvider, {
config: { defaultLanguage: 'zh-CN', detectBrowserLanguage: false },
children,
});

function setup() {
const { result } = renderHook(
() => ({ localize: useActionTextLocalizer(), i18n: useObjectTranslation().i18n }),
{ wrapper },
);
return result;
}

const COPY = {
label: { en: 'Invite member', 'zh-CN': '邀请成员' },
confirmText: { en: 'Send the invitation?', 'zh-CN': '确认发送邀请吗?' },
successMessage: { en: 'Invitation sent', 'zh-CN': '邀请已发送' },
errorMessage: { en: 'Invitation failed', 'zh-CN': '邀请失败' },
};

const ZH = {
label: '邀请成员',
confirmText: '确认发送邀请吗?',
successMessage: '邀请已发送',
errorMessage: '邀请失败',
};

describe('useActionTextLocalizer collapses every declared I18nLabel copy (objectui#12757)', () => {
it('a nameless action: all four maps collapse to the active language', () => {
const result = setup();
const out = result.current.localize(undefined, { type: 'api', target: '/x', ...COPY });
expect(out).toEqual({ type: 'api', target: '/x', ...ZH });
});

it('a named action with no bundle entry: all four maps collapse to the active language', () => {
const result = setup();
const out = result.current.localize(undefined, { name: 'invite_member', type: 'api', ...COPY });
expect(out).toEqual({ name: 'invite_member', type: 'api', ...ZH });
});

it('a bundle entry still outranks the collapsed map', () => {
const result = setup();
result.current.i18n.addResourceBundle(
result.current.i18n.language,
'translation',
{ cloud: { globalActions: { invite_member: { confirmText: '真的发送吗?' } } } },
true,
true,
);
const out = result.current.localize(undefined, { name: 'invite_member', type: 'api', ...COPY });
expect(out.confirmText).toBe('真的发送吗?');
expect(out.successMessage).toBe('邀请已发送');
});

it('CONTROL: a nameless action whose copy is already text comes back as the same object', () => {
const result = setup();
const action = { type: 'api', label: 'Invite', confirmText: 'Sure?', successMessage: 'Sent', errorMessage: 'Failed' };
expect(result.current.localize(undefined, action)).toBe(action);
});
});
44 changes: 29 additions & 15 deletions packages/react/src/hooks/useActionTextLocalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
* AUTHORED text. That is `useObjectLabel`'s own fallback contract; nothing
* here overrides it.
* - **A nameless action is not translatable.** Without `action.name` there is
* no `_actions.<name>` key to look up, so the literals pass through.
* no `_actions.<name>` key to look up, so the literals pass through —
* collapsed to the active language like every other caller's (below), since
* an inline action (`element:button`) is often nameless (objectui#12757).
* - **The entry is keyed on the action's own object** (objectui#11439): its
* declared `objectName`, else the host object the caller draws it on — the
* object `translateObject` in `@objectstack/spec` stamps on an embedded
Expand All @@ -60,13 +62,14 @@
* declares resolves `_actions.<name>.outcomeMessages.<outcome>`; 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`, `successMessage` 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` 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.
* string. `errorMessage` has no bundle key (the spec's action translation
* declares none), so the collapse is all it gets (objectui#12757).
* - **The success copy stays a template.** A `${result.*}` token, in a bundle
* entry or in the authored text, passes through this hook untouched: the
* action has not run yet, so there is no result to read. The runner fills
Expand All @@ -88,7 +91,8 @@ export interface ActionTextLocalizerOptions {

/**
* Returns a copy of `action` with `label` — plus `confirmText`,
* `successMessage` and `outcomeMessages` when the action declares them —
* `successMessage`, `errorMessage` and `outcomeMessages` 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
* action's own declared `objectName` wins over it when present.
Expand All @@ -99,6 +103,9 @@ export type ActionTextLocalizer = <T extends Record<string, any>>(
options?: ActionTextLocalizerOptions,
) => T;

/** The declared `I18nLabel` copy, besides `label`, that reaches the runner. */
const DECLARED_COPY_KEYS = ['confirmText', 'successMessage', 'errorMessage'] as const;

export function useActionTextLocalizer(): ActionTextLocalizer {
const { actionLabel, actionConfirm, actionSuccess, actionOutcome } = useObjectLabel();
const { language } = useObjectTranslation();
Expand All @@ -119,11 +126,22 @@ export function useActionTextLocalizer(): ActionTextLocalizer {
: undefined;
const displayLabel = authoredLabel || name || options?.fallbackLabel || '';

// No name, no translation key: the literals are the answer. The label is
// Every declared `I18nLabel` copy collapses the same way, so neither the
// bundle fallbacks below nor the nameless return hand the runner a map.
const collapsed: Record<string, any> = { ...action, label: displayLabel };
let changed = displayLabel !== action.label;
for (const key of DECLARED_COPY_KEYS) {
const declaredCopy: unknown = action[key];
if (declaredCopy === undefined) continue;
collapsed[key] = pickLocalized(declaredCopy, language);
if (collapsed[key] !== declaredCopy) changed = true;
}

// No name, no translation key: the literals are the answer. They are
// still normalized (map to string, last-resort placeholder applied) so
// every caller reads one shape.
if (!name) {
return displayLabel === (action as any).label ? action : { ...action, label: displayLabel };
return (changed ? collapsed : action) as T;
}

// The key object: the action's declared `objectName`, else the host the
Expand All @@ -134,18 +152,14 @@ export function useActionTextLocalizer(): ActionTextLocalizer {
const keyObject = (typeof declared === 'string' && declared) || objectName;

const out: Record<string, any> = {
...action,
...collapsed,
label: actionLabel(keyObject, name, displayLabel),
};
if ((action as any).confirmText !== undefined) {
out.confirmText = actionConfirm(keyObject, name, (action as any).confirmText);
out.confirmText = actionConfirm(keyObject, name, collapsed.confirmText);
}
if ((action as any).successMessage !== undefined) {
out.successMessage = actionSuccess(
keyObject,
name,
pickLocalized((action as any).successMessage, language),
);
out.successMessage = actionSuccess(keyObject, name, collapsed.successMessage);
}
// The map is an authored bag (outcome → copy), so the one "is this a
// real bag?" predicate in this package answers whether to read it.
Expand Down
Loading