Skip to content

Commit 263f885

Browse files
authored
fix(i18n): delete the four pick({en,zh}) clones (#2871, part 2) (#2893)
Four files each carried an identical private resolver branching on `startsWith('zh')`. Only Chinese was handled, so the other eight shipped languages silently rendered English, and the inline {en,zh} copy was unreachable by translators. All four copies and their `I18n` alias are gone. Migrated to the locale packs in all ten languages: `excelImport.*` (8), `cloudOnboarding.*` (5), `aiModelStatus.*` (11), `chatbotQuota.*` (4). Two spots were restructured rather than ported: the import toast becomes a `{{count}}`/`{{object}}` interpolation instead of a template literal baked into both variants, and the AI-model error becomes two whole sentences instead of splicing a conditional `(HTTP nnn)` fragment mid-clause. The chatbot banner still chooses between the server-owned `quota.message` and `quota.messageEn`, but by the console's active language rather than `navigator.language`, which ignored the in-app locale switcher. CloudOnboardingNext's tests now render inside a real I18nProvider; without one `t()` returns the raw key, so the previous assertions on literal English were asserting nothing. Remaining `startsWith('zh')` sites are the classified KEEPs: LoadingScreen (bootstrap), conversationLanguage (chat-language detection for the agent), containers.tsx (author-data normalisation + CJK typography), and the Studio/field-types data catalogs.
1 parent 8aae006 commit 263f885

18 files changed

Lines changed: 485 additions & 79 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@object-ui/i18n": patch
3+
"@object-ui/app-shell": patch
4+
"@object-ui/plugin-chatbot": patch
5+
---
6+
7+
fix(i18n): delete the four `pick({en,zh})` clones (objectui#2871, part 2)
8+
9+
Four files each carried an identical private resolver:
10+
11+
```ts
12+
function pick(label: I18n): string {
13+
const lang = document.documentElement.getAttribute('lang') || 'en';
14+
return lang.toLowerCase().startsWith('zh') ? label.zh : label.en;
15+
}
16+
```
17+
18+
Only Chinese was ever handled, so ja/ko/de/fr/es/pt/ru/ar silently rendered
19+
English — and because the copy was baked into the components as inline
20+
`{en, zh}` pairs, no translator could reach it. All four copies are deleted
21+
along with their `I18n` type alias.
22+
23+
Migrated to the locale packs, **all ten languages**:
24+
25+
- `excelImport.*` (8 keys) — `ExcelImportBar`. The completion toast becomes a
26+
proper `{{count}}` / `{{object}}` interpolation instead of a template literal
27+
baked into both language variants.
28+
- `cloudOnboarding.*` (5 keys) — `CloudOnboardingNext`, the Cloud welcome page.
29+
- `aiModelStatus.*` (11 keys) — `CloudAiModelStatus`, including the
30+
`sourceLabel()` enum→prose helper (now `t`-driven with a `{{source}}`
31+
placeholder) and the three `ModelRow` labels. The conditional
32+
`(HTTP nnn)` fragment becomes two whole sentences rather than a string
33+
spliced mid-clause, which is not translatable into every word order.
34+
- `chatbotQuota.*` (4 keys) — the AI quota banner in `ChatbotEnhanced`.
35+
36+
The chatbot banner keeps choosing between the server's `quota.message` (zh) and
37+
`quota.messageEn` — that pair is server-owned — but now decides using the
38+
console's active language instead of `navigator.language`, which had ignored
39+
the in-app locale switcher entirely.
40+
41+
`CloudOnboardingNext`'s tests now render inside a real `I18nProvider`; without
42+
one `t()` returns the raw key, so the previous assertions on literal English
43+
were asserting nothing.
44+
45+
This completes the `pick()` cluster from #2871. The remaining
46+
`startsWith('zh')` sites are the ones that classification marked KEEP —
47+
`LoadingScreen` (bootstrap, selects real locale packs before i18next is up),
48+
`conversationLanguage` (detects the chat's language for the agent, not UI
49+
copy), `containers.tsx` (normalises author-supplied schema data; its `'与'`
50+
separator is a CJK typography rule), and the Studio catalog / `field-types.ts`
51+
data catalog.

packages/app-shell/src/console/ai/ExcelImportBar.tsx

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,9 @@ import { useEffect, useMemo, useState } from 'react';
1818
import { Button, Badge } from '@object-ui/components';
1919
import { createAuthenticatedFetch } from '@object-ui/auth';
2020
import { toast } from 'sonner';
21+
import { useObjectTranslation } from '@object-ui/i18n';
2122
import { ImportWizard } from '@object-ui/plugin-grid';
2223

23-
type I18n = { en: string; zh: string };
24-
2524
interface WizardField {
2625
name: string;
2726
label: string;
@@ -41,12 +40,6 @@ interface ExcelImportBarProps {
4140
onDone: () => void;
4241
}
4342

44-
function pick(label: I18n): string {
45-
const lang =
46-
(typeof document !== 'undefined' && document.documentElement.getAttribute('lang')) || 'en';
47-
return lang.toLowerCase().startsWith('zh') ? label.zh : label.en;
48-
}
49-
5043
const SKIP_OBJECT_PREFIXES = ['sys_', 'ai_', 'cloud_'];
5144
const NON_WRITABLE_TYPES = ['formula', 'summary', 'autonumber'];
5245

@@ -68,6 +61,7 @@ function normalizeFields(schema: any): WizardField[] {
6861
}
6962

7063
export function ExcelImportBar({ file, dataSource, defaultObjectName, onDone }: ExcelImportBarProps) {
64+
const { t } = useObjectTranslation();
7165
const authFetch = useMemo(() => createAuthenticatedFetch(), []);
7266
const [objects, setObjects] = useState<Array<{ name: string; label: string }> | null>(null);
7367
const [selected, setSelected] = useState<string>(defaultObjectName ?? '');
@@ -109,13 +103,13 @@ export function ExcelImportBar({ file, dataSource, defaultObjectName, onDone }:
109103
const schema = await dataSource.getObjectSchema(selected);
110104
const f = normalizeFields(schema);
111105
if (!f.length) {
112-
toast.error(pick({ en: 'That object has no importable fields.', zh: '该对象没有可导入的字段。' }));
106+
toast.error(t('excelImport.noImportableFields'));
113107
return;
114108
}
115109
setFields(f);
116110
setOpen(true);
117111
} catch {
118-
toast.error(pick({ en: 'Could not read the object schema.', zh: '无法读取对象结构。' }));
112+
toast.error(t('excelImport.schemaReadFailed'));
119113
} finally {
120114
setLoadingFields(false);
121115
}
@@ -135,10 +129,7 @@ export function ExcelImportBar({ file, dataSource, defaultObjectName, onDone }:
135129
initialFile={file}
136130
onComplete={(result) => {
137131
toast.success(
138-
pick({
139-
en: `Imported ${result.importedRows} row(s) into ${selectedLabel}.`,
140-
zh: `已把 ${result.importedRows} 行真实数据导入「${selectedLabel}」。`,
141-
}),
132+
t('excelImport.imported', { count: result.importedRows, object: selectedLabel }),
142133
);
143134
}}
144135
/>
@@ -149,10 +140,10 @@ export function ExcelImportBar({ file, dataSource, defaultObjectName, onDone }:
149140
<div className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm" data-excel-import-bar>
150141
<Badge variant="secondary">CSV / Excel</Badge>
151142
<span className="text-muted-foreground">
152-
{pick({ en: 'Import the real rows from', zh: '把真实数据导入自' })}
143+
{t('excelImport.importFrom')}
153144
</span>
154145
<code className="font-medium">{file.name}</code>
155-
<span className="text-muted-foreground">{pick({ en: 'into', zh: '到' })}</span>
146+
<span className="text-muted-foreground">{t('excelImport.into')}</span>
156147
<select
157148
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
158149
value={selected}
@@ -164,10 +155,10 @@ export function ExcelImportBar({ file, dataSource, defaultObjectName, onDone }:
164155
))}
165156
</select>
166157
<Button size="sm" onClick={openWizard} disabled={!selected || loadingFields}>
167-
{loadingFields ? pick({ en: 'Opening…', zh: '打开中…' }) : pick({ en: 'Import', zh: '导入' })}
158+
{loadingFields ? t('excelImport.opening') : t('excelImport.importAction')}
168159
</Button>
169160
<Button size="sm" variant="ghost" onClick={onDone}>
170-
{pick({ en: 'Dismiss', zh: '忽略' })}
161+
{t('excelImport.dismiss')}
171162
</Button>
172163
</div>
173164
);

packages/app-shell/src/console/diagnostics/CloudAiModelStatus.tsx

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ import { useEffect, useMemo, useState } from 'react';
2020
import { Badge, Skeleton } from '@object-ui/components';
2121
import { createAuthenticatedFetch } from '@object-ui/auth';
2222
import { ComponentRegistry } from '@object-ui/core';
23+
import { useObjectTranslation } from '@object-ui/i18n';
2324

24-
type I18n = { en: string; zh: string };
25+
type TFn = (key: string, vars?: Record<string, unknown>) => string;
2526

2627
interface EffectiveModelReport {
2728
conversational: { model?: string; source: string };
@@ -47,21 +48,12 @@ type Phase =
4748
| { phase: 'ready'; report: EffectiveModelReport }
4849
| { phase: 'error'; status?: number };
4950

50-
function pick(label: I18n): string {
51-
const lang =
52-
(typeof document !== 'undefined' && document.documentElement.getAttribute('lang')) || 'en';
53-
return lang.toLowerCase().startsWith('zh') ? label.zh : label.en;
54-
}
55-
5651
/** Env-override source → a friendly label; `code-default` reads as such. */
57-
function sourceLabel(source: string): I18n {
58-
if (source === 'code-default')
59-
return { en: 'code default (no env override)', zh: '代码默认(无 env 覆盖)' };
60-
if (source === 'inherits-conversational')
61-
return { en: 'same as build/ask', zh: '与 build/ask 相同' };
62-
if (source.startsWith('env:'))
63-
return { en: `pinned by ${source.slice(4)}`, zh: `被 ${source.slice(4)} 钉住` };
64-
return { en: source, zh: source };
52+
function sourceLabel(source: string, t: TFn): string {
53+
if (source === 'code-default') return t('aiModelStatus.sourceCodeDefault');
54+
if (source === 'inherits-conversational') return t('aiModelStatus.sourceInherits');
55+
if (source.startsWith('env:')) return t('aiModelStatus.sourcePinned', { source: source.slice(4) });
56+
return source;
6557
}
6658

6759
/** `code-default` is the calm state; any env pin is worth a highlight. */
@@ -104,19 +96,20 @@ function useEffectiveModel(url: string): Phase {
10496
}
10597

10698
/** One labelled row: dimension name, the resolved model, and a source badge. */
107-
function ModelRow({ label, model, source }: { label: I18n; model?: string; source: string }) {
99+
function ModelRow({ label, model, source, t }: { label: string; model?: string; source: string; t: TFn }) {
108100
return (
109101
<div className="flex flex-wrap items-center justify-between gap-2 py-2 border-b border-border last:border-b-0">
110-
<span className="text-sm text-muted-foreground">{pick(label)}</span>
102+
<span className="text-sm text-muted-foreground">{label}</span>
111103
<span className="flex items-center gap-2">
112104
<code className="text-sm font-medium">{model ?? '—'}</code>
113-
<Badge variant={sourceTone(source)}>{pick(sourceLabel(source))}</Badge>
105+
<Badge variant={sourceTone(source)}>{sourceLabel(source, t)}</Badge>
114106
</span>
115107
</div>
116108
);
117109
}
118110

119111
export function CloudAiModelStatus({ properties }: CloudAiModelStatusProps) {
112+
const { t } = useObjectTranslation();
120113
const url = properties?.effectiveModelUrl || DEFAULT_URL;
121114
const state = useEffectiveModel(url);
122115

@@ -133,10 +126,9 @@ export function CloudAiModelStatus({ properties }: CloudAiModelStatusProps) {
133126
if (state.phase === 'error') {
134127
return (
135128
<div className="text-sm text-muted-foreground" data-ai-model-status="error">
136-
{pick({
137-
en: `Couldn't read the effective AI model${state.status ? ` (HTTP ${state.status})` : ''}. This environment may not run an AI service, or you may lack the ai:read permission.`,
138-
zh: `无法读取有效 AI 模型${state.status ? `(HTTP ${state.status})` : ''}。该环境可能未运行 AI 服务,或你没有 ai:read 权限。`,
139-
})}
129+
{state.status
130+
? t('aiModelStatus.readFailedWithStatus', { status: state.status })
131+
: t('aiModelStatus.readFailed')}
140132
</div>
141133
);
142134
}
@@ -150,33 +142,36 @@ export function CloudAiModelStatus({ properties }: CloudAiModelStatusProps) {
150142

151143
<div className="rounded-md border border-border px-3">
152144
<ModelRow
153-
label={{ en: 'Build / Ask model', zh: 'Build / Ask 模型' }}
145+
label={t('aiModelStatus.rowConversational')}
146+
t={t}
154147
model={report.conversational.model}
155148
source={report.conversational.source}
156149
/>
157150
<ModelRow
158-
label={{ en: 'Structured (blueprint / seed)', zh: '结构化(蓝图 / 种子)' }}
151+
label={t('aiModelStatus.rowStructured')}
152+
t={t}
159153
model={report.structured.model}
160154
source={report.structured.source}
161155
/>
162156
<ModelRow
163-
label={{ en: 'Reasoning effort', zh: '推理强度' }}
157+
label={t('aiModelStatus.rowReasoning')}
158+
t={t}
164159
model={report.reasoningEffort.effective}
165160
source={report.reasoningEffort.source}
166161
/>
167162
</div>
168163

169164
<div className="text-xs text-muted-foreground">
170-
<span className="font-medium">{pick({ en: 'Overrides in effect: ', zh: '生效的 env 覆盖:' })}</span>
165+
<span className="font-medium">{t('aiModelStatus.overridesInEffect')}</span>
171166
{setOverrides.length === 0 ? (
172-
<span>{pick({ en: 'none — running the deployed code defaults.', zh: '无 —— 跑的是部署代码的默认值。' })}</span>
167+
<span>{t('aiModelStatus.noOverrides')}</span>
173168
) : (
174169
<code>{setOverrides.map(([k, v]) => `${k}=${v}`).join(' · ')}</code>
175170
)}
176171
</div>
177172

178173
<p className="text-xs text-muted-foreground">
179-
{pick({ en: 'Adapter: ', zh: '适配器:' })}
174+
{t('aiModelStatus.adapter')}
180175
<code>{report.adapter}{report.provider ? ` / ${report.provider}` : ''}</code>
181176
</p>
182177
</div>

packages/app-shell/src/console/home/CloudOnboardingNext.tsx

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,7 @@ import { Rocket, Plus, Settings2 } from 'lucide-react';
3232
import { useAuth } from '@object-ui/auth';
3333
import { createAuthenticatedFetch } from '@object-ui/auth';
3434
import { ComponentRegistry } from '@object-ui/core';
35-
36-
/** Inline {en,zh} copy resolved against the active locale. */
37-
type I18n = { en: string; zh: string };
35+
import { useObjectTranslation } from '@object-ui/i18n';
3836

3937
interface CloudOnboardingNextProps {
4038
properties?: {
@@ -63,12 +61,6 @@ const DEFAULT_OPEN_PRODUCTION_URL = '/api/v1/cloud/environments/production/sso-o
6361
const DEFAULT_ENVIRONMENTS_ROUTE = '/apps/cloud_control/sys_environment';
6462

6563
/** Resolve the active locale's string (cheap; the page uses {en,zh} pairs). */
66-
function pick(label: I18n): string {
67-
const lang =
68-
(typeof document !== 'undefined' && document.documentElement.getAttribute('lang')) || 'en';
69-
return lang.toLowerCase().startsWith('zh') ? label.zh : label.en;
70-
}
71-
7264
/**
7365
* Resolve `hasProductionEnv` from the org-scoped entitlements summary. Returns
7466
* `unknown` on any failure so the caller degrades gracefully rather than
@@ -127,6 +119,7 @@ function openProduction(url: string) {
127119
}
128120

129121
export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) {
122+
const { t } = useObjectTranslation();
130123
const navigate = useNavigate();
131124
const state = useProductionEnvState(properties?.warmUrl);
132125
const openUrl = properties?.openProductionUrl || DEFAULT_OPEN_PRODUCTION_URL;
@@ -143,16 +136,10 @@ export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) {
143136
);
144137
}
145138

146-
const hint: I18n =
139+
const hint =
147140
state.phase === 'ready' && !state.hasProductionEnv
148-
? {
149-
en: 'Spin up your first environment — a private workspace with its own URL, database, and plan. Building happens inside it.',
150-
zh: '创建你的第一个环境——一个独立的工作区,有自己的网址、数据库和套餐。应用的搭建在里面进行。',
151-
}
152-
: {
153-
en: 'Your production environment is ready. Open it to build and run your apps — that all happens inside the environment.',
154-
zh: '你的生产环境已就绪。打开它来搭建和运行应用——这些都在环境内部进行。',
155-
};
141+
? t('cloudOnboarding.hintCreate')
142+
: t('cloudOnboarding.hintReady');
156143

157144
// No production env yet → the real first step is "create", not "open".
158145
const showCreatePrimary = state.phase === 'ready' && !state.hasProductionEnv;
@@ -168,20 +155,20 @@ export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) {
168155
// second create button on the list page.
169156
<Button size="lg" onClick={() => navigate(`${envsRoute}?runAction=create_environment`)}>
170157
<Plus className="mr-2 h-4 w-4" />
171-
{pick({ en: 'Create your environment', zh: '创建你的环境' })}
158+
{t('cloudOnboarding.createEnvironment')}
172159
</Button>
173160
) : (
174161
<Button size="lg" onClick={() => openProduction(openUrl)}>
175162
<Rocket className="mr-2 h-4 w-4" />
176-
{pick({ en: 'Open Production', zh: '打开生产环境' })}
163+
{t('cloudOnboarding.openProduction')}
177164
</Button>
178165
)}
179166
<Button size="lg" variant="secondary" onClick={() => navigate(envsRoute)}>
180167
<Settings2 className="mr-2 h-4 w-4" />
181-
{pick({ en: 'Manage environments', zh: '管理环境' })}
168+
{t('cloudOnboarding.manageEnvironments')}
182169
</Button>
183170
</div>
184-
<p className="max-w-xl text-center text-sm text-muted-foreground">{pick(hint)}</p>
171+
<p className="max-w-xl text-center text-sm text-muted-foreground">{hint}</p>
185172
</div>
186173
);
187174
}

packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,22 @@ vi.mock('@object-ui/auth', () => ({
2222
createAuthenticatedFetch: () => (url: string, init?: any) => fetchImpl(url, init),
2323
}));
2424

25+
import { I18nProvider } from '@object-ui/i18n';
2526
import { CloudOnboardingNext } from '../CloudOnboardingNext';
2627

28+
/**
29+
* The CTA labels resolve from the locale packs (objectui#2871), so these
30+
* renders need a real i18n context — without one `t()` returns the raw key and
31+
* these assertions would pass against nothing. Pinned to `en` with browser
32+
* detection off so the expectations stay deterministic.
33+
*/
34+
const renderOnboarding = (ui: React.ReactElement) =>
35+
render(
36+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
37+
{ui}
38+
</I18nProvider>,
39+
);
40+
2741
function summary(hasProductionEnv: boolean) {
2842
return {
2943
ok: true,
@@ -46,7 +60,7 @@ describe('CloudOnboardingNext', () => {
4660

4761
it('shows "Create your environment" when the org has no production env', async () => {
4862
fetchImpl = async () => summary(false);
49-
render(<CloudOnboardingNext {...PROPS} />);
63+
renderOnboarding(<CloudOnboardingNext {...PROPS} />);
5064

5165
const create = await screen.findByText('Create your environment');
5266
expect(create).toBeTruthy();
@@ -62,15 +76,15 @@ describe('CloudOnboardingNext', () => {
6276

6377
it('shows "Open Production" once the org has a production env', async () => {
6478
fetchImpl = async () => summary(true);
65-
render(<CloudOnboardingNext {...PROPS} />);
79+
renderOnboarding(<CloudOnboardingNext {...PROPS} />);
6680

6781
expect(await screen.findByText('Open Production')).toBeTruthy();
6882
expect(screen.queryByText('Create your environment')).toBeNull();
6983
});
7084

7185
it('degrades to the open-production actions when the signal cannot be resolved', async () => {
7286
fetchImpl = async () => ({ ok: false, status: 500, json: async () => null });
73-
render(<CloudOnboardingNext {...PROPS} />);
87+
renderOnboarding(<CloudOnboardingNext {...PROPS} />);
7488

7589
// Unknown state is fail-safe: it must NEVER strand a real user behind a
7690
// wrong "create" CTA, so it shows Open Production + Manage environments.
@@ -82,7 +96,7 @@ describe('CloudOnboardingNext', () => {
8296
it('renders a non-CTA skeleton while the signal is still loading', async () => {
8397
let resolveFetch: (v: any) => void = () => {};
8498
fetchImpl = () => new Promise((r) => { resolveFetch = r; });
85-
const { container } = render(<CloudOnboardingNext {...PROPS} />);
99+
const { container } = renderOnboarding(<CloudOnboardingNext {...PROPS} />);
86100

87101
// Before the fetch resolves: no CTA text, just the skeleton placeholder.
88102
expect(screen.queryByText('Open Production')).toBeNull();

0 commit comments

Comments
 (0)