Skip to content

Commit c6fd752

Browse files
authored
fix(app-shell): localize the two DeclaredActionsBar strings that bypassed i18n (#2762 P0-3) (#2894)
The declared action labels resolve through `useObjectLabel`, so a zh-CN workspace got 通过 / 拒绝 buttons — sitting inside a toolbar whose accessible name was the English literal 'Actions', above decision-output fields whose help text read "Handed to the flow as a decision output." Both strings are authored by the bar itself rather than by metadata, and both skipped the locale bundle. `aria-label` now uses the existing `common.actions` key; a host-supplied `label` still wins. The help text moves to new `actions.decisionOutput.help` / `.helpMultiValue` keys, added across all ten shipped locales. The help text needed fixing even though the runtime localizes action params: `useConsoleActionRuntime` runs every param through `actionParamText`, but these params are synthesized from the record's `decision_output_defs`, so their key path (`outputs.<key>`) is dynamic and no `_actions.<action>.params.*` entry can ever match. The fallback is not a rare path — it is the only path. Left alone deliberately: a decision output arriving without a `label` still renders a title-cased version of its machine key, mirroring the framework's `humanizeFieldPath` convention. The fix there is the backend declaring the label; a client bundle cannot key off a dynamic output name. Tests assert the strings go through `t()` rather than merely being English — they fail against the previous literals. Framework half: objectstack-ai/objectstack#3753.
1 parent 263f885 commit c6fd752

13 files changed

Lines changed: 171 additions & 6 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
"@object-ui/i18n": patch
4+
---
5+
6+
fix(app-shell): localize the two `DeclaredActionsBar` strings that bypassed i18n (objectui#2762 P0-3)
7+
8+
The declared action *labels* resolve through `useObjectLabel`, so a zh-CN
9+
workspace got 通过 / 拒绝 buttons — sitting inside a toolbar whose accessible
10+
name was the English literal `'Actions'`, above decision-output fields whose
11+
help text read `Handed to the flow as a decision output.` Both strings are
12+
authored by the bar itself rather than by metadata, and both skipped the locale
13+
bundle entirely.
14+
15+
- `aria-label` now uses the existing `common.actions` key (a host-supplied
16+
`label` still wins).
17+
- The decision-output help text moves to new `actions.decisionOutput.help` /
18+
`.helpMultiValue` keys, added across all ten shipped locales.
19+
20+
Worth being precise about why the help text needed fixing at all, since the
21+
runtime *does* localize action params: `useConsoleActionRuntime` runs every
22+
param through `actionParamText`, but these params are synthesized here from the
23+
record's `decision_output_defs`, so their key path (`outputs.<key>`) is dynamic
24+
and no `_actions.<action>.params.*` bundle entry can ever match it. The
25+
fallback is not a rare path — it is the only path, which is why the English
26+
survived.
27+
28+
Not fixed, and deliberately: a decision output that arrives without a `label`
29+
still renders a title-cased version of its machine key. That derived text
30+
mirrors the framework's `humanizeFieldPath` convention, and the real fix is the
31+
backend declaring the label — a client-side bundle cannot key off a dynamic
32+
output name.

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import {
3939
toPredicateInput,
4040
} from '@object-ui/react';
4141
import type { ActionDef } from '@object-ui/core';
42-
import { useObjectLabel } from '@object-ui/i18n';
42+
import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
4343
import { Loader2 } from 'lucide-react';
4444
import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime';
4545
import { useAdapter } from '../providers/AdapterProvider';
@@ -103,6 +103,13 @@ const DeclaredActionButton: React.FC<{
103103
// ObjectView/RecordDetailView do for their toolbars. The param dialog's
104104
// labels localize downstream in useConsoleActionRuntime.
105105
const { actionLabel, actionConfirm, actionSuccess } = useObjectLabel();
106+
// Chrome strings the bar itself authors — as opposed to the declared metadata
107+
// above — go through the normal locale bundle. The decision-output params are
108+
// synthesized here from `decision_output_defs`, so their key path is dynamic
109+
// and no `_actions.<action>.params.*` entry can ever exist for them; the
110+
// literal IS what renders, which is how English help text survived in a zh-CN
111+
// workspace (objectui#2762 P0-3).
112+
const { t } = useObjectTranslation();
106113

107114
const recordData = record != null && typeof record === 'object' ? (record as Record<string, any>) : {};
108115
// `visible` fails CLOSED on a throwing predicate — mirrors action:button and
@@ -160,19 +167,19 @@ const DeclaredActionButton: React.FC<{
160167
if (d.type === 'user') {
161168
return {
162169
...base, type: 'user', multiple: d.multiple === true,
163-
helpText: 'Handed to the flow as a decision output.',
170+
helpText: t('actions.decisionOutput.help'),
164171
};
165172
}
166173
const lookupObject = d.type ? OUTPUT_LOOKUP_OBJECTS[d.type] : undefined;
167174
if (lookupObject) {
168175
return {
169176
...base, type: 'lookup', referenceTo: lookupObject, multiple: d.multiple === true,
170-
helpText: 'Handed to the flow as a decision output.',
177+
helpText: t('actions.decisionOutput.help'),
171178
};
172179
}
173180
return {
174181
...base, type: 'text',
175-
helpText: 'Handed to the flow as a decision output. Comma-separate multiple values.',
182+
helpText: t('actions.decisionOutput.helpMultiValue'),
176183
};
177184
});
178185
const dispatch: any = {
@@ -201,7 +208,7 @@ const DeclaredActionButton: React.FC<{
201208
} finally {
202209
setLoading(false);
203210
}
204-
}, [action, execute, loading, objectName, record, actionLabel, actionConfirm, actionSuccess]);
211+
}, [action, execute, loading, objectName, record, actionLabel, actionConfirm, actionSuccess, t]);
205212

206213
if ((action as any).visible && !isVisible) return null;
207214

@@ -252,6 +259,7 @@ export function DeclaredActionsBar({
252259
label,
253260
}: DeclaredActionsBarProps) {
254261
const dataSource = useAdapter();
262+
const { t } = useObjectTranslation();
255263
// Fetch the object def (and its declared actions) unless the host passed
256264
// them in. `useMetadataItem` no-ops when `name` is undefined.
257265
const { item: objectDef } = useMetadataItem('object', actionsProp ? undefined : objectName);
@@ -299,7 +307,7 @@ export function DeclaredActionsBar({
299307
<div className="text-xs font-medium text-muted-foreground">{label}</div>
300308
</>
301309
)}
302-
<div role="toolbar" aria-label={label || 'Actions'} className="flex flex-row flex-wrap items-center gap-2">
310+
<div role="toolbar" aria-label={label || t('common.actions')} className="flex flex-row flex-wrap items-center gap-2">
303311
{located.map((action) => (
304312
<DeclaredActionButton
305313
key={action.name}

packages/app-shell/src/views/__tests__/DeclaredActionsBar.test.tsx

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ vi.mock('../../providers/MetadataProvider', () => ({
4141

4242
vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => null }));
4343

44+
// Declared metadata resolves through `useObjectLabel` (falling back to the
45+
// authored literal, which is what the render assertions below expect); the
46+
// bar's OWN chrome resolves through `t`. Marking `t` output makes it visible
47+
// whether a string went through the locale bundle or was baked in English.
48+
vi.mock('@object-ui/i18n', () => ({
49+
useObjectLabel: () => ({
50+
actionLabel: (_o: unknown, _n: unknown, fallback: string) => fallback,
51+
actionConfirm: (_o: unknown, _n: unknown, fallback?: string) => fallback,
52+
actionSuccess: (_o: unknown, _n: unknown, fallback?: string) => fallback,
53+
}),
54+
useObjectTranslation: () => ({ t: (key: string) => `t:${key}` }),
55+
}));
56+
4457
vi.mock('@object-ui/components', () => ({
4558
Button: ({ children, onClick, ...props }: any) => (
4659
<button onClick={onClick} {...props}>{children}</button>
@@ -219,3 +232,75 @@ describe('DeclaredActionsBar', () => {
219232
expect(empty.firstChild).toBeNull();
220233
});
221234
});
235+
236+
// objectui#2762 P0-3. The declared action LABELS localize via `useObjectLabel`,
237+
// but two strings the bar authors itself were baked in English and bypassed
238+
// i18n entirely — so a zh-CN workspace got 通过 / 拒绝 buttons sitting inside an
239+
// "Actions" toolbar, with English help text under the decision-output fields.
240+
//
241+
// The help text is the subtler one: those params are synthesized here from the
242+
// record's `decision_output_defs`, so their key path (`outputs.<key>`) is
243+
// dynamic and no `_actions.<action>.params.*` bundle entry can ever match it.
244+
// The runtime's `actionParamText` pass therefore always falls through to the
245+
// literal — the literal IS what renders, every time.
246+
describe('DeclaredActionsBar chrome localization (objectui#2762)', () => {
247+
it('localizes the toolbar aria-label instead of hardcoding "Actions"', () => {
248+
render(
249+
<DeclaredActionsBar
250+
objectName="sys_approval_request"
251+
record={REQUEST}
252+
location="record_section"
253+
actions={ACTIONS as any}
254+
/>,
255+
);
256+
expect(screen.getByRole('toolbar')).toHaveAttribute('aria-label', 't:common.actions');
257+
});
258+
259+
it('still prefers a host-supplied label over the translated default', () => {
260+
render(
261+
<DeclaredActionsBar
262+
objectName="sys_approval_request"
263+
record={REQUEST}
264+
location="record_section"
265+
label="决策"
266+
actions={ACTIONS as any}
267+
/>,
268+
);
269+
expect(screen.getByRole('toolbar')).toHaveAttribute('aria-label', '决策');
270+
});
271+
272+
it('localizes the decision-output help text', async () => {
273+
const decideAction = {
274+
name: 'approval_approve',
275+
type: 'api',
276+
label: 'Approve',
277+
target: '/api/v1/approvals/requests/{id}/approve',
278+
locations: ['record_section'],
279+
};
280+
render(
281+
<DeclaredActionsBar
282+
objectName="sys_approval_request"
283+
record={{
284+
...REQUEST,
285+
decision_output_defs: [
286+
{ key: 'reviewer', type: 'user' },
287+
{ key: 'owning_team', type: 'team' },
288+
{ key: 'notes' },
289+
],
290+
}}
291+
location="record_section"
292+
actions={[decideAction] as any}
293+
/>,
294+
);
295+
296+
fireEvent.click(screen.getByText('Approve'));
297+
await waitFor(() => expect(executeSpy).toHaveBeenCalled());
298+
299+
const params = executeSpy.mock.calls[0][0].actionParams as Array<Record<string, unknown>>;
300+
const byName = Object.fromEntries(params.map((p) => [p.name, p]));
301+
expect(byName['outputs.reviewer'].helpText).toBe('t:actions.decisionOutput.help');
302+
expect(byName['outputs.owning_team'].helpText).toBe('t:actions.decisionOutput.help');
303+
// The free-text variant carries the extra comma-separation sentence.
304+
expect(byName['outputs.notes'].helpText).toBe('t:actions.decisionOutput.helpMultiValue');
305+
});
306+
});

packages/i18n/src/locales/ar.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ const ar = {
7474
package: "الحزمة",
7575
},
7676
actions: {
77+
decisionOutput: {
78+
help: 'يُمرَّر إلى التدفق كمخرج للقرار.',
79+
helpMultiValue: 'يُمرَّر إلى التدفق كمخرج للقرار. افصل بين القيم المتعددة بفواصل.',
80+
},
7781
resultDialog: {
7882
defaultTitle: 'احفظ هذه القيمة الآن',
7983
acknowledge: 'لقد حفظتها',

packages/i18n/src/locales/de.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ const de = {
7474
package: "Paket",
7575
},
7676
actions: {
77+
decisionOutput: {
78+
help: 'Wird dem Flow als Entscheidungsausgabe übergeben.',
79+
helpMultiValue: 'Wird dem Flow als Entscheidungsausgabe übergeben. Mehrere Werte durch Komma trennen.',
80+
},
7781
resultDialog: {
7882
defaultTitle: 'Diesen Wert jetzt speichern',
7983
acknowledge: 'Ich habe ihn gespeichert',

packages/i18n/src/locales/en.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ const en = {
8989
package: 'Package',
9090
},
9191
actions: {
92+
decisionOutput: {
93+
help: 'Handed to the flow as a decision output.',
94+
helpMultiValue: 'Handed to the flow as a decision output. Comma-separate multiple values.',
95+
},
9296
resultDialog: {
9397
defaultTitle: 'Save this value now',
9498
acknowledge: 'I have saved this',

packages/i18n/src/locales/es.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ const es = {
7474
package: "Paquete",
7575
},
7676
actions: {
77+
decisionOutput: {
78+
help: 'Se transfiere al flujo como salida de la decisión.',
79+
helpMultiValue: 'Se transfiere al flujo como salida de la decisión. Separe varios valores con comas.',
80+
},
7781
resultDialog: {
7882
defaultTitle: 'Guarda este valor ahora',
7983
acknowledge: 'Lo he guardado',

packages/i18n/src/locales/fr.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ const fr = {
7474
package: "Package",
7575
},
7676
actions: {
77+
decisionOutput: {
78+
help: 'Transmis au flux comme sortie de décision.',
79+
helpMultiValue: 'Transmis au flux comme sortie de décision. Séparez les valeurs multiples par des virgules.',
80+
},
7781
resultDialog: {
7882
defaultTitle: 'Enregistrez cette valeur maintenant',
7983
acknowledge: 'Je l\'ai enregistrée',

packages/i18n/src/locales/ja.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ const ja = {
7474
package: "パッケージ",
7575
},
7676
actions: {
77+
decisionOutput: {
78+
help: '決定の出力としてフローに渡されます。',
79+
helpMultiValue: '決定の出力としてフローに渡されます。複数の値はカンマで区切ってください。',
80+
},
7781
resultDialog: {
7882
defaultTitle: 'この値を今すぐ保存してください',
7983
acknowledge: '保存しました',

packages/i18n/src/locales/ko.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ const ko = {
7474
package: "패키지",
7575
},
7676
actions: {
77+
decisionOutput: {
78+
help: '결정 출력으로 플로우에 전달됩니다.',
79+
helpMultiValue: '결정 출력으로 플로우에 전달됩니다. 여러 값은 쉼표로 구분하세요.',
80+
},
7781
resultDialog: {
7882
defaultTitle: '이 값을 지금 저장하세요',
7983
acknowledge: '저장했습니다',

0 commit comments

Comments
 (0)