Skip to content

Commit af1b0db

Browse files
baozhoutaoclaude
andauthored
feat(i18n): localize action result dialogs via _actions.<action>.resultDialog (#2736)
The one-shot secret-reveal dialog (create-user temporary password, 2FA backup codes, OAuth client secrets) always rendered hardcoded English: the handlers passed the metadata spec straight through, and the locale packs' own fallback strings were English in every language. - @object-ui/i18n: useObjectLabel() gains actionResultDialog() — overlays translated title/description/acknowledge + per-field labels from `{ns}.objects.<obj>._actions.<action>.resultDialog.*` (globalActions fallback). The fields node is keyed by the LITERAL result-field path ("user.email"), fetched whole via returnObjects and indexed directly, never through a dotted i18next key. Locale packs translate the defaultTitle/acknowledge fallbacks and add actions.resultDialog.copyAll. - @object-ui/app-shell: result-dialog handlers in useConsoleActionRuntime and RecordDetailView take the action context (already passed by ActionRunner) and localize the spec before opening; ActionResultDialog's hardcoded "Copy all" now resolves through actions.resultDialog.copyAll. Server-side counterpart (translation schema slot, extractor keys, platform bundle content) lands in objectstack-ai/framework. Verified end-to-end on a live showcase instance: create-user and set-password dialogs render fully localized in zh-CN. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 803558e commit af1b0db

16 files changed

Lines changed: 235 additions & 27 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@object-ui/i18n": minor
3+
"@object-ui/app-shell": patch
4+
---
5+
6+
feat(i18n): localize action result dialogs via the `_actions.<action>.resultDialog` convention
7+
8+
The post-success secret-reveal dialog (create-user temporary password, 2FA
9+
backup codes, OAuth client secrets) always rendered the hardcoded English
10+
metadata literals — the spec bundles now carry `resultDialog` translations
11+
(objectstack `_actions.<action>.resultDialog.*`), but nothing resolved them
12+
client-side.
13+
14+
- **@object-ui/i18n.** `useObjectLabel()` gains `actionResultDialog(objectName,
15+
actionName, spec)`: overlays translated `title` / `description` /
16+
`acknowledge` and per-field labels onto the metadata spec, falling back to
17+
the literals. The `fields` node is keyed by the LITERAL result-field path
18+
(may contain dots, e.g. `"user.email"`), so it is fetched whole with
19+
`returnObjects` and indexed directly — never resolved through a dotted
20+
i18next key. Built-in locale packs also translate the dialog's fallback
21+
`defaultTitle` / `acknowledge` (previously English in all ten locales) and
22+
add the new `actions.resultDialog.copyAll` key.
23+
- **@object-ui/app-shell.** The result-dialog handlers in
24+
`useConsoleActionRuntime` and `RecordDetailView` accept the action context
25+
(already passed by `ActionRunner`) and localize the spec before opening the
26+
dialog; `ActionResultDialog`'s hardcoded "Copy all" button now goes through
27+
`actions.resultDialog.copyAll`.

packages/app-shell/src/hooks/useConsoleActionRuntime.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
128128
// [ADR-0066 D4] System capabilities for the action capability gate (fail-open
129129
// when no PermissionProvider is mounted — usePermissions returns []).
130130
const { systemPermissions } = usePermissions();
131-
const { fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel, actionDescription } = useObjectLabel();
131+
const { fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel, actionDescription, actionResultDialog } = useObjectLabel();
132132

133133
const objectDef = useMemo(
134134
() => (objectName ? objects?.find((o: any) => o.name === objectName) : undefined),
@@ -161,10 +161,18 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
161161
const serverActionInFlight = useRef<Set<string>>(new Set());
162162

163163
const resultDialogHandler = useCallback<ResultDialogHandler>(
164-
(spec: any, data: unknown) => new Promise<void>((resolve) => {
165-
setResultDialogState({ open: true, spec, data, resolve });
164+
(spec: any, data: unknown, action?: any) => new Promise<void>((resolve) => {
165+
// Localize title/description/acknowledge + field labels via the
166+
// `_actions.<action>.resultDialog` convention (metadata literals as
167+
// fallback). The action's own object wins over the page object,
168+
// mirroring the param-dialog localization below.
169+
const objForI18n = (typeof action?.objectName === 'string' && action.objectName)
170+
? action.objectName
171+
: objectName || (objectDef as any)?.name;
172+
const localized = actionResultDialog(objForI18n, action?.name, spec) ?? spec;
173+
setResultDialogState({ open: true, spec: localized, data, resolve });
166174
}),
167-
[],
175+
[objectName, objectDef, actionResultDialog],
168176
);
169177

170178
const confirmHandler = useCallback<ConfirmationHandler>((message, options) => {

packages/app-shell/src/views/ActionResultDialog.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ function QrcodeBlock({ value }: { value: string }) {
219219
}
220220

221221
function CodeListBlock({ value }: { value: string[] }) {
222+
const { t } = useObjectTranslation();
222223
const joined = value.join('\n');
223224
return (
224225
<div className="space-y-2">
@@ -235,7 +236,7 @@ function CodeListBlock({ value }: { value: string[] }) {
235236
</ul>
236237
</div>
237238
<div className="flex justify-end">
238-
<CopyButton value={joined} label="Copy all" />
239+
<CopyButton value={joined} label={t('actions.resultDialog.copyAll') || 'Copy all'} />
239240
</div>
240241
</div>
241242
);

packages/app-shell/src/views/RecordDetailView.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
158158
};
159159
}, [originFromState, location.search, appName]);
160160
const { t, language } = useObjectTranslation();
161-
const { objectLabel, viewLabel: _vLabel, sectionLabel, actionLabel, actionConfirm, actionSuccess, actionParamText, actionParamOptionLabel, actionDescription, fieldLabel, fieldOptionLabel } = useObjectLabel();
161+
const { objectLabel, viewLabel: _vLabel, sectionLabel, actionLabel, actionConfirm, actionSuccess, actionParamText, actionParamOptionLabel, actionDescription, actionResultDialog, fieldLabel, fieldOptionLabel } = useObjectLabel();
162162
const { isFavorite, toggleFavorite, refreshLabel: refreshFavoriteLabel } = useFavorites();
163163
const { addRecentItem } = useRecentItems();
164164
const [isLoading, setIsLoading] = useState(true);
@@ -335,10 +335,17 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
335335
// (2FA secrets, OAuth client_secret, recovery codes).
336336
const [resultDialogState, setResultDialogState] = useState<ResultDialogState>({ open: false });
337337
const resultDialogHandler = useCallback(
338-
(spec: any, data: unknown) => new Promise<void>((resolve) => {
339-
setResultDialogState({ open: true, spec, data, resolve });
338+
(spec: any, data: unknown, action?: any) => new Promise<void>((resolve) => {
339+
// Localize title/description/acknowledge + field labels via the
340+
// `_actions.<action>.resultDialog` convention (metadata literals as
341+
// fallback); the action's own object wins over the page object.
342+
const objForI18n = (typeof action?.objectName === 'string' && action.objectName)
343+
? action.objectName
344+
: objectName || objectDef?.name;
345+
const localized = actionResultDialog(objForI18n, action?.name, spec) ?? spec;
346+
setResultDialogState({ open: true, spec: localized, data, resolve });
340347
}),
341-
[],
348+
[objectName, objectDef, actionResultDialog],
342349
);
343350

344351
const confirmHandler = useCallback((message: string, options?: { title?: string; confirmText?: string; cancelText?: string }) => {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { renderHook } from '@testing-library/react';
3+
import React from 'react';
4+
import { I18nProvider } from '../provider';
5+
import { useObjectTranslation } from '../provider';
6+
import { useObjectLabel } from '../useObjectLabel';
7+
8+
const wrapper = ({ children }: { children: React.ReactNode }) =>
9+
React.createElement(
10+
I18nProvider,
11+
{ config: { defaultLanguage: 'en', detectBrowserLanguage: false } },
12+
children,
13+
);
14+
15+
/** The metadata literal spec, as authored on sys_user.create_user. */
16+
const spec = {
17+
title: 'User Created',
18+
description: 'Copy the temporary password now — it is shown only once and never stored.',
19+
acknowledge: 'I have saved this password',
20+
fields: [
21+
{ path: 'user.email', label: 'Email', format: 'text' },
22+
{ path: 'user.phoneNumber', label: 'Phone Number', format: 'text' },
23+
{ path: 'temporaryPassword', label: 'Temporary Password', format: 'secret' },
24+
],
25+
};
26+
27+
describe('useObjectLabel().actionResultDialog', () => {
28+
it('falls back to the literal spec when no translation is registered', () => {
29+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
30+
const out = result.current.actionResultDialog('sys_user', 'create_user', spec);
31+
expect(out?.title).toBe('User Created');
32+
expect(out?.fields?.[0].label).toBe('Email');
33+
});
34+
35+
it('overlays translated copy and per-path field labels (dotted paths stay literal keys)', () => {
36+
const { result } = renderHook(
37+
() => ({ labels: useObjectLabel(), i18n: useObjectTranslation().i18n }),
38+
{ wrapper },
39+
);
40+
result.current.i18n.addResourceBundle(
41+
'en',
42+
'translation',
43+
{
44+
setup: {
45+
objects: {
46+
sys_user: {
47+
_actions: {
48+
create_user: {
49+
resultDialog: {
50+
title: '用户已创建',
51+
description: '请立即复制临时密码——它只显示一次,不会被保存。',
52+
acknowledge: '我已保存该密码',
53+
fields: {
54+
'user.email': '邮箱',
55+
temporaryPassword: '临时密码',
56+
},
57+
},
58+
},
59+
},
60+
},
61+
},
62+
},
63+
},
64+
true,
65+
true,
66+
);
67+
68+
const out = result.current.labels.actionResultDialog('sys_user', 'create_user', spec);
69+
expect(out?.title).toBe('用户已创建');
70+
expect(out?.description).toBe('请立即复制临时密码——它只显示一次,不会被保存。');
71+
expect(out?.acknowledge).toBe('我已保存该密码');
72+
// The dotted path resolves as ONE literal key of the fields record.
73+
expect(out?.fields?.[0]).toEqual({ path: 'user.email', label: '邮箱', format: 'text' });
74+
// Untranslated fields keep the metadata literal; format survives.
75+
expect(out?.fields?.[1]).toEqual({ path: 'user.phoneNumber', label: 'Phone Number', format: 'text' });
76+
expect(out?.fields?.[2]).toEqual({ path: 'temporaryPassword', label: '临时密码', format: 'secret' });
77+
// Source spec is not mutated.
78+
expect(spec.title).toBe('User Created');
79+
expect(spec.fields[0].label).toBe('Email');
80+
});
81+
82+
it('returns the spec untouched when the action name is missing', () => {
83+
const { result } = renderHook(() => useObjectLabel(), { wrapper });
84+
expect(result.current.actionResultDialog('sys_user', undefined, spec)).toBe(spec);
85+
expect(result.current.actionResultDialog('sys_user', 'create_user', undefined)).toBeUndefined();
86+
});
87+
});

packages/i18n/src/locales/ar.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,9 @@ const ar = {
7575
},
7676
actions: {
7777
resultDialog: {
78-
defaultTitle: 'Save this value now',
79-
acknowledge: 'I have saved this',
78+
defaultTitle: 'احفظ هذه القيمة الآن',
79+
acknowledge: 'لقد حفظتها',
80+
copyAll: 'نسخ الكل',
8081
},
8182
},
8283
validation: {

packages/i18n/src/locales/de.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,9 @@ const de = {
7575
},
7676
actions: {
7777
resultDialog: {
78-
defaultTitle: 'Save this value now',
79-
acknowledge: 'I have saved this',
78+
defaultTitle: 'Diesen Wert jetzt speichern',
79+
acknowledge: 'Ich habe ihn gespeichert',
80+
copyAll: 'Alle kopieren',
8081
},
8182
},
8283
validation: {

packages/i18n/src/locales/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ const en = {
9292
resultDialog: {
9393
defaultTitle: 'Save this value now',
9494
acknowledge: 'I have saved this',
95+
copyAll: 'Copy all',
9596
},
9697
},
9798
validation: {

packages/i18n/src/locales/es.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,9 @@ const es = {
7575
},
7676
actions: {
7777
resultDialog: {
78-
defaultTitle: 'Save this value now',
79-
acknowledge: 'I have saved this',
78+
defaultTitle: 'Guarda este valor ahora',
79+
acknowledge: 'Lo he guardado',
80+
copyAll: 'Copiar todo',
8081
},
8182
},
8283
validation: {

packages/i18n/src/locales/fr.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,9 @@ const fr = {
7575
},
7676
actions: {
7777
resultDialog: {
78-
defaultTitle: 'Save this value now',
79-
acknowledge: 'I have saved this',
78+
defaultTitle: 'Enregistrez cette valeur maintenant',
79+
acknowledge: 'Je l\'ai enregistrée',
80+
copyAll: 'Tout copier',
8081
},
8182
},
8283
validation: {

0 commit comments

Comments
 (0)