Skip to content

Commit 28d0dbb

Browse files
fix(react): an inline action's description locale map shows in the user's language in the param dialog (#29452)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbTgVLHkF4YWMWPeXEgkyBS3)_ Fixes #29249 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:页面上内联动作(`element:button`)的 `description` 写成多语言 map 时,控制台在弹出参数对话框之前先把它折成当前语言的那一句。没写 `description` 的动作不受影响,对话框照旧显示「请填写以下信息以继续操作。」。 - 为什么改:cloud#3281 您裁定 A,契约半张(#29369)已落地,允许内联动作写 `description`。这是 UI 半张:不折的话,云端「邀请成员」对话框收到的是一个对象而不是一句话,邀请人看不到用自己语言写的说明。 - 风险与回滚:只在动作文案本地化器里多认一个键,与 `confirmText`、`errorMessage` 同一条处理;纯字符串和 `template` 信封原样返回,不读翻译包。回滚即 revert 本 PR。 - 先看:`ui/packages/react/src/hooks/useActionTextLocalizer.ts`(`DECLARED_COPY_KEYS` 一行与文档注释),`ui/packages/components/src/__tests__/element-button-paramDialogDescription-29249.test.tsx`。 - 两分钟验证:在 `ui/` 下运行 `pnpm exec vitest run packages/react/src/hooks/__tests__/useActionTextLocalizer.description-29249.test.tsx packages/components/src/__tests__/element-button-paramDialogDescription-29249.test.tsx`,预期 2 个文件、9 条全部通过。 - 需要你做的:无,席位落地。 ## What changes - `ui/packages/react/src/hooks/useActionTextLocalizer.ts`: `DECLARED_COPY_KEYS` gains `description`, so a declared `description` goes through `collapseCopy` like `confirmText`, `successMessage` and `errorMessage`. An `I18nLabel` locale map becomes the active language's string; a `{ dialect: 'template', source }` envelope and a plain string come back as themselves. The `ActionTextLocalizer` type docblock and the module docblock's bullet list now name `description` with the other declared copy, and a new bullet records that it is collapse-only. - Collapse-only, per the ruling: no bundle read for `description` in this hook. The param-collection host (`useConsoleActionRuntime`) still resolves `_actions.NAME.description` through `actionDescription` over the string it is handed. - An action with no `description` gains no key, so the dialog keeps its generic line. - `.changeset/29249-inline-action-description-locale.md`: `@object-ui/react: patch`. - No exported type or signature changes; `DECLARED_COPY_KEYS` is module-private. ## Tests - New `ui/packages/react/src/hooks/__tests__/useActionTextLocalizer.description-29249.test.tsx` (6 cases, real `I18nProvider`): a nameless inline action's zh-CN/en map gives the zh-CN string under zh-CN and the English string under en; a named action collapses the map and does not read a bundle `description` entry (positive control: the same bundle node's `label` is read); a `template` envelope comes back as the same object; a plain string comes back unchanged; no `description` adds no key, nameless or named. - New `ui/packages/components/src/__tests__/element-button-paramDialogDescription-29249.test.tsx` (3 cases): the registered `element:button`, the real localizer and the real `ActionRunner` behind `ActionProvider` open the param dialog. A zh-CN/en `description` map shows the zh-CN string under zh-CN and the English string under en, and the runner hands the host one string. With no `description` the dialog shows 「请填写以下信息以继续操作。」. Each case first asserts the dialog opened with the action's title. - Reverse check: with `description` removed from `DECLARED_COPY_KEYS` (via `scripts/ablation-replace.mjs`, restored to the HEAD blob, `git diff HEAD` empty), the five map cases go red and the four controls (envelope, plain string, no description x2) stay green. ## Acceptance notes - The dialog in the components test is the test's own host, drawn with this package's `Dialog`, because `@object-ui/components` cannot import `@object-ui/app-shell`. It draws the line the way `ActionParamDialog` does (`state.description || t('actionDialog.description')`, `ActionParamDialog.tsx` line 691). - Named inline actions: `element:button` forwards `name`, and the console's param handler calls `actionDescription(objForI18n, action?.name, action?.description)` for every dispatch (`useConsoleActionRuntime.tsx` line 337), so a named inline action's bundle `description` is resolved there. Measured by reading; no finding. - The same collapse now applies wherever the localizer runs (`DeclaredActionsBar`, `ObjectView`, `RecordDetailView`, `RelatedRecordActionsBridge`, `containers.tsx`, `record-quick-actions`, `record-related-list`), so a registered action's locale-map `description` reaches its dialog as a string too. The contract review of #29369 named that as the same pre-existing gap. - The comment above the localizer call in `elements.tsx` still lists `label`, `confirmText`, `successMessage`, `errorMessage` as the localized copy. It is outside this card's file surface and was left as is; comment only. ## Verification <details><summary>Local runs</summary> Final head `47f9d6de` (a merge of `origin/main` `7b1ae338` onto the fix `cf64ea1b`; main touched none of the four files). - `cd ui && pnpm exec vitest run --maxWorkers=2 packages/react/ packages/components/src/__tests__/element-button packages/components/src/__tests__/elementButtonActionMembers-8071.test.tsx packages/components/src/renderers/action/__tests__/action-forward-parity.test.tsx` at `47f9d6de`: Test Files 139 passed (139), Tests 1562 passed (1562). That is the whole `@object-ui/react` suite (133 files at `cf64ea1b`, 1538 tests) plus the components `element:button` and forward-parity files. - `pnpm exec turbo run type-check --filter=@object-ui/react --filter=@object-ui/components` at `47f9d6de`: 45/45 tasks, exit 0. Both new test files are in their package's `tsconfig.test.json` program (`--listFilesOnly`). No casts in either new file. - Reverse check at `cf64ea1b`: `node scripts/ablation-replace.mjs` replaced the `DECLARED_COPY_KEYS` line without `'description'` (anchor 1 -> 0, blob `ccebd928` -> `b7c8dbed`); the two new files ran 5 failed, 4 passed (the five map cases red; envelope, plain string and both no-description cases green); restored blob equals HEAD, `git diff HEAD` empty. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 families at `cf64ea1b`; all 34 ran with exit 0 (including `node scripts/check-sdui-manifest.mjs` and `pnpm --dir ui exec vitest run scripts/`), and `--ran` reconciles 34 derived, 34 run, 0 NOT-MEASURED. The ratchet and body gates were run again at `47f9d6de` (see the report on the card). - Changed lines: 303 (+295 / -8), under the 3000 threshold. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUba5gmu4hkimKCSC6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f7e408 commit 28d0dbb

4 files changed

Lines changed: 305 additions & 8 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/react': patch
3+
---
4+
5+
fix(react): an inline action's `description` locale map shows in the user's language in the param dialog
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
What changes:
13+
14+
- `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.
15+
- It does not read a translation bundle entry for `description`. The param-dialog host still resolves `_actions.<name>.description` over the string it is handed.
16+
- An action that declares no `description` gains no key, so its dialog keeps the generic "Please provide the required information to continue." line.
17+
18+
What to do: nothing. Nothing a page, view or action declares changes.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#29249 — an inline `element:button` action's `description`
11+
* reaches the param dialog in the user's language.
12+
*
13+
* `InlineActionSchema` picks `description` (an `I18nLabel`: a string or a
14+
* per-locale map). The button hands its action to `useActionTextLocalizer`
15+
* before the runner, so a map collapses to the active language there; the
16+
* runner and the param-collection host have no language of their own.
17+
*
18+
* The path is the real one up to the param-collection seam: the registered
19+
* `element:button`, the real localizer, the real `ActionRunner` behind
20+
* `ActionProvider`. The dialog is this file's host, drawn with this package's
21+
* `Dialog` the way the console's `ActionParamDialog` (`@object-ui/app-shell`,
22+
* which this package cannot import) draws its line: the action's description,
23+
* else the generic `actionDialog.description`.
24+
*
25+
* Each row first asserts the dialog opened with the action's own title (the
26+
* positive control), so a missing line is never "no dialog opened".
27+
*/
28+
29+
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
30+
import { render, screen, fireEvent } from '@testing-library/react';
31+
import React from 'react';
32+
import { ComponentRegistry } from '@object-ui/core';
33+
import type { ActionContext, ActionDef, ActionResult, ParamCollectionHandler } from '@object-ui/core';
34+
import { ActionProvider } from '@object-ui/react';
35+
import { I18nProvider, useObjectTranslation } from '@object-ui/i18n';
36+
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '../ui/dialog';
37+
import '../renderers/basic/elements';
38+
39+
let api: Mock<(action: ActionDef, ctx: ActionContext) => Promise<ActionResult>>;
40+
let handed: Array<ActionDef | undefined>;
41+
42+
beforeEach(() => {
43+
api = vi.fn(async () => ({ success: true }));
44+
handed = [];
45+
});
46+
47+
/** The producer's shape: a nameless inline `api` action that collects params. */
48+
const INVITE = {
49+
type: 'api',
50+
target: '/api/v1/cloud/invitations',
51+
label: { en: 'Invite member', 'zh-CN': '邀请成员' },
52+
params: [{ name: 'email', label: 'Email', type: 'email', required: true }],
53+
};
54+
55+
const DESCRIPTION = {
56+
en: 'We e-mail them an invitation link.',
57+
'zh-CN': '我们会向对方发送一封邀请邮件。',
58+
};
59+
60+
/**
61+
* The registered renderer, read once at module scope (a component created
62+
* during render remounts on every render). An unregistered `element:button`
63+
* fails the whole file here, before any row runs.
64+
*/
65+
function registeredElementButton() {
66+
const C = ComponentRegistry.get('element:button');
67+
if (!C) throw new Error('element:button not registered');
68+
return C;
69+
}
70+
const ElementButton = registeredElementButton();
71+
72+
/**
73+
* The param-collection host: opens a dialog from what the runner hands it and
74+
* leaves the promise pending, as a dialog awaiting its Confirm does.
75+
*/
76+
function ParamHost({ action }: { action: Record<string, unknown> }) {
77+
const { t } = useObjectTranslation();
78+
const [dialog, setDialog] = React.useState<{ title: string; description: string } | null>(null);
79+
const onParamCollection = React.useCallback<ParamCollectionHandler>((_params, dispatched) => {
80+
handed.push(dispatched);
81+
// A non-string reaching the host is drawn as nothing, so the assertions
82+
// below see the gap instead of React refusing an object child.
83+
const title = dispatched?.label;
84+
const description = dispatched?.description;
85+
setDialog({
86+
title: typeof title === 'string' ? title : '',
87+
description: typeof description === 'string' ? description : '',
88+
});
89+
return new Promise(() => {});
90+
}, []);
91+
return (
92+
<ActionProvider handlers={{ api }} onParamCollection={onParamCollection}>
93+
<ElementButton
94+
schema={{
95+
type: 'element:button',
96+
properties: { label: { en: 'Invite member', 'zh-CN': '邀请成员' }, action },
97+
}}
98+
/>
99+
{dialog && (
100+
<Dialog open>
101+
<DialogContent>
102+
<DialogTitle>{dialog.title}</DialogTitle>
103+
<DialogDescription>{dialog.description || t('actionDialog.description')}</DialogDescription>
104+
</DialogContent>
105+
</Dialog>
106+
)}
107+
</ActionProvider>
108+
);
109+
}
110+
111+
function renderIn(language: string, action: Record<string, unknown>) {
112+
return render(
113+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
114+
<ParamHost action={action} />
115+
</I18nProvider>,
116+
);
117+
}
118+
119+
describe('element:button — the param dialog shows the inline description in the active language (objectstack#29249)', () => {
120+
it('a zh-CN/en map shows the zh-CN string under a zh-CN locale', async () => {
121+
renderIn('zh-CN', { ...INVITE, description: DESCRIPTION });
122+
123+
fireEvent.click(screen.getByRole('button', { name: '邀请成员' }));
124+
125+
const dialog = await screen.findByRole('dialog');
126+
expect(screen.getByRole('heading', { name: '邀请成员' })).toBeTruthy();
127+
expect(dialog.textContent).toContain('我们会向对方发送一封邀请邮件。');
128+
// The runner was handed one string, never the map.
129+
expect(handed).toHaveLength(1);
130+
expect(handed[0]?.description).toBe('我们会向对方发送一封邀请邮件。');
131+
// Nothing runs until the dialog's own Confirm.
132+
expect(api).not.toHaveBeenCalled();
133+
});
134+
135+
it('the same map shows the English string under an en locale', async () => {
136+
renderIn('en', { ...INVITE, description: DESCRIPTION });
137+
138+
fireEvent.click(screen.getByRole('button', { name: 'Invite member' }));
139+
140+
const dialog = await screen.findByRole('dialog');
141+
expect(screen.getByRole('heading', { name: 'Invite member' })).toBeTruthy();
142+
expect(dialog.textContent).toContain('We e-mail them an invitation link.');
143+
expect(handed[0]?.description).toBe('We e-mail them an invitation link.');
144+
});
145+
146+
it('no description: the dialog keeps its generic line', async () => {
147+
renderIn('zh-CN', INVITE);
148+
149+
fireEvent.click(screen.getByRole('button', { name: '邀请成员' }));
150+
151+
const dialog = await screen.findByRole('dialog');
152+
expect(screen.getByRole('heading', { name: '邀请成员' })).toBeTruthy();
153+
expect(dialog.textContent).toContain('请填写以下信息以继续操作。');
154+
expect(handed[0]?.description).toBeUndefined();
155+
});
156+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#29249 — an action's `description`, the line the param dialog
11+
* draws under its title, is declared `I18nLabel` copy like `confirmText`: an
12+
* inline per-locale map collapses to the active language before the runner
13+
* and the dialog, which have no language, receive it.
14+
*
15+
* Declared-only and collapse-only, like `errorMessage`: an action that
16+
* declares no `description` gains no key (the dialog keeps its generic line),
17+
* and the bundle's `_actions.<name>.description` is not read here — the
18+
* param-collection host resolves that entry over the string handed on.
19+
*
20+
* Measured under a real `I18nProvider`, reading what the localizer returns.
21+
*/
22+
import { describe, it, expect, afterEach } from 'vitest';
23+
import { cleanup, renderHook } from '@testing-library/react';
24+
import React from 'react';
25+
import { I18nProvider, useObjectTranslation } from '@object-ui/i18n';
26+
import { useActionTextLocalizer } from '../useActionTextLocalizer';
27+
28+
afterEach(() => cleanup());
29+
30+
const DESCRIPTION = {
31+
en: 'We e-mail them an invitation link.',
32+
'zh-CN': '我们会向对方发送一封邀请邮件。',
33+
};
34+
35+
/** The producer's shape: a nameless inline `api` action that collects params. */
36+
const INVITE = {
37+
type: 'api',
38+
target: '/api/v1/cloud/invitations',
39+
label: { en: 'Invite member', 'zh-CN': '邀请成员' },
40+
params: [{ name: 'email', label: 'Email', type: 'email', required: true }],
41+
};
42+
43+
function harness(language: string, bundle?: Record<string, unknown>) {
44+
const wrapper = ({ children }: { children: React.ReactNode }) => (
45+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
46+
{children}
47+
</I18nProvider>
48+
);
49+
const { result } = renderHook(
50+
() => ({ localize: useActionTextLocalizer(), translation: useObjectTranslation() }),
51+
{ wrapper },
52+
);
53+
if (bundle) result.current.translation.i18n.addResourceBundle(language, 'translation', bundle, true, true);
54+
return result;
55+
}
56+
57+
describe('useActionTextLocalizer — description (objectstack#29249)', () => {
58+
it('a nameless inline action: the locale map collapses to the zh-CN string', () => {
59+
const result = harness('zh-CN');
60+
const localized = result.current.localize(undefined, { ...INVITE, description: DESCRIPTION });
61+
expect(localized.description).toBe('我们会向对方发送一封邀请邮件。');
62+
});
63+
64+
it('the same map reads the English entry under an en locale', () => {
65+
const result = harness('en');
66+
const localized = result.current.localize(undefined, { ...INVITE, description: DESCRIPTION });
67+
expect(localized.description).toBe('We e-mail them an invitation link.');
68+
});
69+
70+
it('a named action collapses the map too, and the bundle entry is not read here', () => {
71+
const result = harness('zh-CN', {
72+
sys: { globalActions: { invite_member: { label: '邀请同事', description: '来自翻译包的说明' } } },
73+
});
74+
const localized = result.current.localize(undefined, {
75+
...INVITE,
76+
name: 'invite_member',
77+
description: DESCRIPTION,
78+
});
79+
// Positive control: the same bundle node IS read for `label`, so the
80+
// harness reached a named action's bundle path.
81+
expect(localized.label).toBe('邀请同事');
82+
// Collapse-only: the host's `actionDescription` owns the bundle read.
83+
expect(localized.description).toBe('我们会向对方发送一封邀请邮件。');
84+
});
85+
86+
it('a `template` envelope comes back as itself, never collapsed as a locale map', () => {
87+
const result = harness('zh-CN');
88+
const envelope = { dialect: 'template', source: 'Invite {{ record.name }} to the team.' };
89+
const action = { ...INVITE, label: 'Invite member', description: envelope };
90+
const localized = result.current.localize(undefined, action);
91+
expect(localized.description).toBe(envelope);
92+
expect(localized.description).toEqual({ dialect: 'template', source: 'Invite {{ record.name }} to the team.' });
93+
});
94+
95+
it('a plain string comes back unchanged', () => {
96+
const result = harness('zh-CN');
97+
const localized = result.current.localize(undefined, {
98+
...INVITE,
99+
description: 'We e-mail them an invitation link.',
100+
});
101+
expect(localized.description).toBe('We e-mail them an invitation link.');
102+
});
103+
104+
it('an action with no description gains no key, nameless or named', () => {
105+
const result = harness('zh-CN', {
106+
sys: { globalActions: { invite_member: { description: '来自翻译包的说明' } } },
107+
});
108+
const nameless = result.current.localize(undefined, INVITE);
109+
const named = result.current.localize(undefined, { ...INVITE, name: 'invite_member' });
110+
expect('description' in nameless).toBe(false);
111+
expect('description' in named).toBe(false);
112+
});
113+
});

‎ui/packages/react/src/hooks/useActionTextLocalizer.ts‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,22 @@
6262
* declares resolves `_actions.<name>.outcomeMessages.<outcome>`; a bundle
6363
* entry for an outcome the action does not declare is never read, so a
6464
* bundle cannot add copy for an answer the author never wrote one for.
65-
* - **`label`, `confirmText`, `successMessage`, `errorMessage` and each
66-
* `outcomeMessages` entry are reduced through `pickLocalized` first**,
67-
* because each is an `I18nLabel` — a string or an inline per-locale map. The map is collapsed to the active language
65+
* - **`label`, `confirmText`, `successMessage`, `errorMessage`, `description`
66+
* and each `outcomeMessages` entry are reduced through `pickLocalized`
67+
* first**, because each is an `I18nLabel` — a string or an inline per-locale map. The map is collapsed to the active language
6868
* BEFORE it is offered to the bundle as the fallback, so a per-locale
6969
* literal and a bundle entry cannot disagree about what "the authored text"
7070
* is, and the action runner, which has no language, only ever receives a
7171
* string. `errorMessage` has no bundle key (the spec's action translation
7272
* declares none), so the collapse is all it gets (objectui#12757).
73+
* - **`description` is collapse-only too** (objectstack#29249): the line the
74+
* param dialog draws under its title is an `I18nLabel` on an inline action
75+
* (`InlineActionSchema` picks it), so a locale map collapses to the active
76+
* language here, before the runner and the dialog, which have no language,
77+
* receive it. Its bundle entry (`_actions.<name>.description`) is NOT read
78+
* here: the param-collection host resolves it (`actionDescription` in
79+
* `useConsoleActionRuntime`), over the string this hook hands on. An action
80+
* that declares none gains no key, so the dialog keeps its generic line.
7381
* - **`disabledReason` follows the same declared-only rule**
7482
* (objectstack#26619): the reason a greyed action gives resolves
7583
* `_actions.<name>.disabledReason` only when the action declares one, after
@@ -90,7 +98,8 @@
9098
* scope `onSuccess.navigate` reads — one interpolation, in one place.
9199
* - **The `template` envelope keeps its shape** (objectui#13367, the renderer
92100
* half of objectstack#24814). `confirmText`, `successMessage`,
93-
* `errorMessage` and each `outcomeMessages` entry (objectstack#27948) may
101+
* `errorMessage`, `description` and each `outcomeMessages` entry
102+
* (objectstack#27948) may
94103
* arrive as `{ dialect: 'template', source }` instead of a string. The
95104
* envelope is not an `I18nLabel` map — read as one it would
96105
* collapse to its first string value, the word `template` — so its `source`
@@ -113,10 +122,11 @@ export interface ActionTextLocalizerOptions {
113122

114123
/**
115124
* Returns a copy of `action` with `label` — plus `confirmText`,
116-
* `successMessage`, `errorMessage`, `outcomeMessages`, `disabledReason` and
117-
* `cancelText` when the action declares them —
125+
* `successMessage`, `errorMessage`, `description`, `outcomeMessages`,
126+
* `disabledReason` and `cancelText` when the action declares them —
118127
* resolved from the active locale's bundle, falling back to the authored
119-
* literals. `objectName` is the host object the action is drawn on; the
128+
* literals (`errorMessage` and `description` are collapsed to the active
129+
* language only; neither reads the bundle here). `objectName` is the host object the action is drawn on; the
120130
* action's own declared `objectName` wins over it when present.
121131
*/
122132
export type ActionTextLocalizer = <T extends Record<string, any>>(
@@ -126,7 +136,7 @@ export type ActionTextLocalizer = <T extends Record<string, any>>(
126136
) => T;
127137

128138
/** The declared `I18nLabel` copy, besides `label`, that reaches the runner. */
129-
const DECLARED_COPY_KEYS = ['confirmText', 'successMessage', 'errorMessage'] as const;
139+
const DECLARED_COPY_KEYS = ['confirmText', 'successMessage', 'errorMessage', 'description'] as const;
130140

131141
/**
132142
* Collapse one declared copy value to the active language: an `I18nLabel`

0 commit comments

Comments
 (0)