Skip to content

Commit c6aaed8

Browse files
authored
fix(i18n): retire four hand-rolled zh/en branches (#2871, part 1) (#2887)
Four surfaces picked their language with a hand-written `startsWith('zh')` instead of the locale packs, so ja/ko/de/fr/es/pt/ru/ar silently rendered English and the strings could never be translated without a code change. - RecordTitleChip's private zh-CN/zh-TW dictionary is deleted. Its comment claimed "components is i18n-free"; the package declares @object-ui/i18n and the file it cites already uses it. All four keys already existed in all ten packs — ten locales fixed, zero new translations, on a component that renders on every record detail page. - EnvironmentListToolbar's three CTA labels move to a new `environment.*` namespace (added to all ten packs). This surface regressed once before for the same reason (#844). - StudioAiCopilot's dock title moves to the Studio catalog. - StudioHomePage.relativeTime uses Intl.RelativeTimeFormat instead of five ternaries: every locale, correct plurals, "yesterday"/「昨天」 rather than "1d ago", and Arabic's dual form which a ternary cannot express. EnvironmentListToolbar'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.
1 parent d147a13 commit c6aaed8

17 files changed

Lines changed: 146 additions & 70 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@object-ui/i18n": patch
3+
"@object-ui/components": patch
4+
"@object-ui/app-shell": patch
5+
---
6+
7+
fix(i18n): retire four hand-rolled zh/en branches (objectui#2871, part 1)
8+
9+
Four surfaces decided their language with a hand-written `startsWith('zh')`
10+
check instead of the locale packs, so the other eight shipped languages
11+
silently rendered English and the strings could never be translated without a
12+
code change.
13+
14+
- **`RecordTitleChip`** carried a private zh-CN/zh-TW dictionary behind a
15+
comment claiming "components is i18n-free". That is not true —
16+
`@object-ui/components` declares `@object-ui/i18n` and its sibling
17+
`containers.tsx` already uses it. All four of its keys (`detail.copied`,
18+
`detail.copyRecordId`, `detail.addToFavorites`, `detail.removeFromFavorites`)
19+
already existed in **all ten packs**, so this deletes ~35 lines and fixes ten
20+
locales with zero new translations. It renders on every record detail page.
21+
- **`EnvironmentListToolbar`**'s three state-aware CTA labels move to a new
22+
`environment.*` namespace. This surface had already regressed once for the
23+
same reason (#844) and was fixed then with inline `{en,zh}` pairs.
24+
- **`StudioAiCopilot`**'s dock title moves to the Studio catalog as
25+
`engine.studio.aiCopilot`.
26+
- **`StudioHomePage.relativeTime`** now uses `Intl.RelativeTimeFormat` with
27+
`numeric: 'auto'` instead of five `zh ? … : …` ternaries. This is strictly
28+
better than adding ten catalog keys: it covers every locale, applies the
29+
correct plural rules, and yields "yesterday" / 「昨天」 rather than "1d ago".
30+
Arabic gets its dual form («أسبوعين») — something a ternary cannot express.
31+
32+
The new `environment.*` keys are added to all ten packs, so this does not widen
33+
the gap tracked by objectui#2872 part (a).
34+
35+
`EnvironmentListToolbar`'s tests now render inside a real `I18nProvider` pinned
36+
to `en`. Without one, `t()` returns the raw key, so the previous assertions on
37+
literal English would have been asserting nothing.

packages/app-shell/src/environment/EnvironmentListToolbar.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { useEffect, useRef, useState } from 'react';
2525
import { SchemaRenderer } from '@object-ui/react';
2626
import { Button } from '@object-ui/components';
2727
import { Plus } from 'lucide-react';
28+
import { useObjectTranslation } from '@object-ui/i18n';
2829
import {
2930
decideEnvironmentCta,
3031
upgradeDialogSpec,
@@ -34,13 +35,6 @@ import {
3435

3536
const CREATE_ACTION = 'create_environment';
3637

37-
/** Resolve an inline {en,zh} label against the document locale. */
38-
function pick(label: { en: string; zh: string }): string {
39-
const lang =
40-
(typeof document !== 'undefined' && document.documentElement.getAttribute('lang')) || 'en';
41-
return lang.toLowerCase().startsWith('zh') ? label.zh : label.en;
42-
}
43-
4438
/**
4539
* Deep-link support (#844): `?runAction=create_environment` on the
4640
* environments route auto-opens the create dialog once entitlements have
@@ -89,6 +83,7 @@ interface Props {
8983
}
9084

9185
export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Props) {
86+
const { t } = useObjectTranslation();
9287
const toolbarActions = (actions || []).filter((a: any) => a?.locations?.includes('list_toolbar'));
9388
const ctaKind = entitlements?.ready ? decideEnvironmentCta(entitlements) : null;
9489
const autoRunCreate = useAutoRunCreate(toolbarActions.length > 0 ? ctaKind : null);
@@ -124,7 +119,7 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro
124119
data-testid="environment-add-upgrade"
125120
>
126121
<Plus className="h-4 w-4" />
127-
<span>{pick({ en: 'Add environment', zh: '新建环境' })}</span>
122+
<span>{t('environment.addEnvironment')}</span>
128123
</Button>
129124
</>
130125
);
@@ -134,21 +129,23 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro
134129
// the create action's label (and promoting production setup to a primary CTA).
135130
// Labels are locale-aware — the metadata label is already localized by the
136131
// caller, but these state-aware overrides used to be hard-coded English,
137-
// which flashed an English button in a zh console (#844).
132+
// which flashed an English button in a zh console (#844). They now resolve
133+
// from the locale packs, so all ten languages work rather than just zh
134+
// (objectui#2871).
138135
const renderedActions = toolbarActions.map((a: any) => {
139136
if (a?.name !== CREATE_ACTION || ctaKind == null) return a;
140137
if (ctaKind === 'setup_production') {
141138
return {
142139
...a,
143-
label: pick({ en: 'Set up your production environment', zh: '创建你的生产环境' }),
140+
label: t('environment.setUpProduction'),
144141
variant: 'primary',
145142
...(autoRunCreate ? { autoTrigger: true } : {}),
146143
};
147144
}
148145
// add_development
149146
return {
150147
...a,
151-
label: pick({ en: 'Add development environment', zh: '新建开发环境' }),
148+
label: t('environment.addDevelopment'),
152149
...(autoRunCreate ? { autoTrigger: true } : {}),
153150
};
154151
});

packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,23 @@ vi.mock('@object-ui/react', async (importActual) => ({
2020
),
2121
}));
2222

23+
import { I18nProvider } from '@object-ui/i18n';
2324
import { EnvironmentListToolbar } from '../EnvironmentListToolbar';
2425
import type { EnvironmentEntitlementsState } from '../entitlements';
2526

27+
/**
28+
* The state-aware CTA labels resolve from the locale packs (objectui#2871),
29+
* so these renders need a real i18n context — asserting the English strings
30+
* without one would only be asserting the raw key. Pinned to `en` and
31+
* `detectBrowserLanguage: false` so the expectations stay deterministic.
32+
*/
33+
const renderToolbar = (ui: React.ReactElement) =>
34+
render(
35+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
36+
{ui}
37+
</I18nProvider>,
38+
);
39+
2640
const CREATE = {
2741
name: 'create_environment', label: 'Create Environment',
2842
type: 'api', variant: 'primary', locations: ['list_toolbar'],
@@ -32,22 +46,22 @@ const st = (o: Partial<EnvironmentEntitlementsState>): EnvironmentEntitlementsSt
3246

3347
describe('EnvironmentListToolbar', () => {
3448
it('no production env → "Set up your production environment" (primary)', () => {
35-
render(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ hasProductionEnv: false })} onUpgrade={vi.fn()} />);
49+
renderToolbar(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ hasProductionEnv: false })} onUpgrade={vi.fn()} />);
3650
const btn = screen.getByText('Set up your production environment');
3751
expect(btn).toBeTruthy();
3852
expect(btn.getAttribute('data-variant')).toBe('primary');
3953
expect(screen.queryByTestId('environment-add-upgrade')).toBeNull();
4054
});
4155

4256
it('has prod + dev allowed (paid) → "Add development environment"', () => {
43-
render(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ canCreateDevelopmentEnv: true })} onUpgrade={vi.fn()} />);
57+
renderToolbar(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ canCreateDevelopmentEnv: true })} onUpgrade={vi.fn()} />);
4458
expect(screen.getByText('Add development environment')).toBeTruthy();
4559
expect(screen.queryByTestId('environment-add-upgrade')).toBeNull();
4660
});
4761

4862
it('has prod + dev locked (free) → upgrade button, NO create POST affordance', () => {
4963
const onUpgrade = vi.fn();
50-
render(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ canCreateDevelopmentEnv: false, plan: 'free' })} onUpgrade={onUpgrade} />);
64+
renderToolbar(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ canCreateDevelopmentEnv: false, plan: 'free' })} onUpgrade={onUpgrade} />);
5165
// The create action is NOT rendered as a bar button (no POST-then-403).
5266
expect(screen.queryByText('Create Environment')).toBeNull();
5367
expect(screen.queryByText('Add development environment')).toBeNull();
@@ -57,7 +71,7 @@ describe('EnvironmentListToolbar', () => {
5771
});
5872

5973
it('still resolving (null) → neutral default label, no upgrade button', () => {
60-
render(<EnvironmentListToolbar actions={[CREATE]} entitlements={null} onUpgrade={vi.fn()} />);
74+
renderToolbar(<EnvironmentListToolbar actions={[CREATE]} entitlements={null} onUpgrade={vi.fn()} />);
6175
expect(screen.getByText('Create Environment')).toBeTruthy();
6276
expect(screen.queryByTestId('environment-add-upgrade')).toBeNull();
6377
});
@@ -72,7 +86,7 @@ describe('EnvironmentListToolbar — ?runAction=create_environment deep link (#8
7286

7387
it('marks the create action autoTrigger once entitlements resolve, then strips the param', async () => {
7488
withRunActionParam();
75-
render(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ hasProductionEnv: false })} onUpgrade={vi.fn()} />);
89+
renderToolbar(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ hasProductionEnv: false })} onUpgrade={vi.fn()} />);
7690
const btn = screen.getByText('Set up your production environment');
7791
// The SchemaRenderer stub surfaces autoTrigger as data-autotrigger.
7892
expect(btn.getAttribute('data-autotrigger')).toBe('true');
@@ -85,7 +99,7 @@ describe('EnvironmentListToolbar — ?runAction=create_environment deep link (#8
8599
it('upgrade state: deep link opens the upgrade prompt instead of a create POST', async () => {
86100
withRunActionParam();
87101
const onUpgrade = vi.fn();
88-
render(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ canCreateDevelopmentEnv: false, plan: 'free' })} onUpgrade={onUpgrade} />);
102+
renderToolbar(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ canCreateDevelopmentEnv: false, plan: 'free' })} onUpgrade={onUpgrade} />);
89103
await vi.waitFor(() => expect(onUpgrade).toHaveBeenCalledTimes(1));
90104
expect(onUpgrade.mock.calls[0][0]).toMatchObject({ code: 'DEV_ENV_PLAN_LOCKED' });
91105
});

packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,20 +108,32 @@ function greetingKey(): string {
108108
return 'engine.home.greetingEvening';
109109
}
110110

111+
/**
112+
* Relative timestamp for the "recently visited" list.
113+
*
114+
* Uses `Intl.RelativeTimeFormat` rather than a hand-written en/zh branch
115+
* (objectui#2871): the platform already knows how to say "3 days ago" in every
116+
* locale the console ships, with the right plural rules and the right word
117+
* order — none of which a `zh ? … : …` ternary can express. This also drops
118+
* the last hard-coded strings in this file.
119+
*
120+
* `numeric: 'auto'` is what turns "1 day ago" into "yesterday" / 「昨天」.
121+
*/
111122
function relativeTime(iso: string, locale: string): string {
112123
const then = new Date(iso).getTime();
113124
if (Number.isNaN(then)) return '';
114-
const diff = Date.now() - then;
115-
const zh = locale.startsWith('zh');
116-
const mins = Math.floor(diff / 60000);
117-
if (mins < 1) return zh ? '刚刚' : 'just now';
118-
if (mins < 60) return zh ? `${mins} 分钟前` : `${mins}m ago`;
125+
const mins = Math.floor((Date.now() - then) / 60000);
126+
127+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
128+
// Negative values read as past ("3 minutes ago"); pick the coarsest unit
129+
// that still has a whole number, matching the previous behaviour.
130+
if (mins < 1) return rtf.format(0, 'minute');
131+
if (mins < 60) return rtf.format(-mins, 'minute');
119132
const hrs = Math.floor(mins / 60);
120-
if (hrs < 24) return zh ? `${hrs} 小时前` : `${hrs}h ago`;
133+
if (hrs < 24) return rtf.format(-hrs, 'hour');
121134
const days = Math.floor(hrs / 24);
122-
if (days < 7) return zh ? `${days} 天前` : `${days}d ago`;
123-
const weeks = Math.floor(days / 7);
124-
return zh ? `${weeks} 周前` : `${weeks}w ago`;
135+
if (days < 7) return rtf.format(-days, 'day');
136+
return rtf.format(-Math.floor(days / 7), 'week');
125137
}
126138

127139
export function StudioHomePage() {

packages/app-shell/src/views/metadata-admin/i18n.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
12071207
'engine.studio.toggleRail': 'Toggle sidebar',
12081208
'engine.studio.home': 'Back to home',
12091209
// Pillar tab labels
1210+
'engine.studio.aiCopilot': 'AI copilot',
12101211
'engine.studio.pillar.data': 'Data',
12111212
'engine.studio.pillar.automations': 'Automations',
12121213
'engine.studio.pillar.interfaces': 'Interfaces',
@@ -2741,6 +2742,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
27412742
'engine.studio.toggleRail': '切换侧栏',
27422743
'engine.studio.home': '返回主页',
27432744
// Pillar tab labels
2745+
'engine.studio.aiCopilot': 'AI 副驾',
27442746
'engine.studio.pillar.data': '数据',
27452747
'engine.studio.pillar.automations': '自动化',
27462748
'engine.studio.pillar.interfaces': '界面',

packages/app-shell/src/views/studio-design/StudioAiCopilot.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useAgents } from '@object-ui/plugin-chatbot';
88
import { useChatConversation } from '../../hooks/useChatConversation';
99
import { chatConversationScope, chatProductOfAgent } from '../../hooks/chatScope';
1010
import { resolveSurfaceAgent } from '../../hooks/surfaceAgent';
11+
import { t } from '../metadata-admin/i18n';
1112
import { ChatPane, resolveApiBase, type PendingFirstMessage } from '../../console/ai/AiChatPage';
1213
import {
1314
ChatDockPanel,
@@ -127,7 +128,6 @@ export interface StudioChatDockProps {
127128
* (ADR-0037 — the preview needs more width than the rail has).
128129
*/
129130
export function StudioChatDock({ packageId, locale }: StudioChatDockProps): React.ReactElement | null {
130-
const zh = (locale ?? '').toLowerCase().startsWith('zh');
131131
const navigate = useNavigate();
132132
const location = useLocation();
133133
const apiBase = React.useMemo(() => resolveApiBase(), []);
@@ -176,7 +176,7 @@ export function StudioChatDock({ packageId, locale }: StudioChatDockProps): Reac
176176
<ChatDockMobileSheet
177177
open={mobileOpen}
178178
onOpenChange={setMobileOpen}
179-
title={zh ? 'AI 副驾' : 'AI copilot'}
179+
title={t('engine.studio.aiCopilot', locale)}
180180
// Bridge to the package's full-page build thread; deferred so the
181181
// sheet closes cleanly before the route changes.
182182
onMaximize={openFullPage}
@@ -194,7 +194,7 @@ export function StudioChatDock({ packageId, locale }: StudioChatDockProps): Reac
194194
return (
195195
<ChatDockPanel
196196
dock={dock}
197-
title={zh ? 'AI 副驾' : 'AI copilot'}
197+
title={t('engine.studio.aiCopilot', locale)}
198198
onMaximize={openFullPage}
199199
>
200200
<StudioCopilotConversation

packages/components/src/custom/RecordTitleChip.tsx

Lines changed: 5 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -29,44 +29,9 @@ import {
2929
TooltipProvider,
3030
TooltipTrigger,
3131
} from '../ui/tooltip';
32+
import { useObjectTranslation } from '@object-ui/i18n';
3233
import { cn } from '../lib/utils';
3334

34-
/** Inline translator — components is i18n-free, so we mirror the
35-
* zh-CN / zh-TW dictionary used by `containers.tsx` for tab labels. */
36-
const T: Record<string, Record<string, string>> = {
37-
'zh-CN': {
38-
addToFavorites: '加入收藏',
39-
removeFromFavorites: '从收藏移除',
40-
copyRecordId: '复制记录 ID',
41-
copied: '已复制',
42-
},
43-
'zh-TW': {
44-
addToFavorites: '加入收藏',
45-
removeFromFavorites: '從收藏移除',
46-
copyRecordId: '複製記錄 ID',
47-
copied: '已複製',
48-
},
49-
};
50-
51-
const detectLocale = (): string => {
52-
if (typeof document !== 'undefined') {
53-
const docLang = document.documentElement?.lang;
54-
if (docLang) return docLang;
55-
}
56-
if (typeof navigator !== 'undefined' && navigator.language) {
57-
return navigator.language;
58-
}
59-
return 'en';
60-
};
61-
62-
const tt = (key: keyof typeof T['zh-CN'], fallback: string): string => {
63-
const locale = detectLocale();
64-
const exact = T[locale];
65-
const base = locale.split('-')[0];
66-
const dict = exact || (base === 'zh' ? T['zh-CN'] : undefined);
67-
return (dict && dict[key]) || fallback;
68-
};
69-
7035
export interface RecordTitleChipProps {
7136
/** Resolved title text (already interpolated against the record). */
7237
title: string;
@@ -102,6 +67,7 @@ export const RecordTitleChip: React.FC<RecordTitleChipProps> = ({
10267
className,
10368
inlineExtras,
10469
}) => {
70+
const { t } = useObjectTranslation();
10571
const [internalFav, setInternalFav] = React.useState(false);
10672
const [idCopied, setIdCopied] = React.useState(false);
10773
const isFavorite = isFavoriteProp ?? internalFav;
@@ -126,11 +92,9 @@ export const RecordTitleChip: React.FC<RecordTitleChipProps> = ({
12692
}, [resourceId]);
12793

12894
const favLabel = isFavorite
129-
? tt('removeFromFavorites', 'Remove from favourites')
130-
: tt('addToFavorites', 'Add to favourites');
131-
const copyLabel = idCopied
132-
? tt('copied', 'Copied')
133-
: tt('copyRecordId', 'Copy record ID');
95+
? t('detail.removeFromFavorites')
96+
: t('detail.addToFavorites');
97+
const copyLabel = idCopied ? t('detail.copied') : t('detail.copyRecordId');
13498

13599
return (
136100
<TooltipProvider>

packages/i18n/src/locales/ar.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1876,6 +1876,11 @@ const ar = {
18761876
done: 'تم — إخفاء',
18771877
},
18781878
},
1879+
environment: {
1880+
addEnvironment: "إضافة بيئة",
1881+
setUpProduction: "إعداد بيئة الإنتاج",
1882+
addDevelopment: "إضافة بيئة تطوير",
1883+
},
18791884
cloudConnection: {
18801885
checking: "جارٍ التحقق من الاتصال…",
18811886
retry: "إعادة المحاولة",

packages/i18n/src/locales/de.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1878,6 +1878,11 @@ const de = {
18781878
done: 'Fertig — ausblenden',
18791879
},
18801880
},
1881+
environment: {
1882+
addEnvironment: "Umgebung hinzufügen",
1883+
setUpProduction: "Produktionsumgebung einrichten",
1884+
addDevelopment: "Entwicklungsumgebung hinzufügen",
1885+
},
18811886
cloudConnection: {
18821887
checking: "Verbindung wird geprüft…",
18831888
retry: "Erneut versuchen",

packages/i18n/src/locales/en.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2534,6 +2534,11 @@ const en = {
25342534
done: 'Done — hide it',
25352535
},
25362536
},
2537+
environment: {
2538+
addEnvironment: 'Add environment',
2539+
setUpProduction: 'Set up your production environment',
2540+
addDevelopment: 'Add development environment',
2541+
},
25372542
cloudConnection: {
25382543
checking: 'Checking connection…',
25392544
retry: 'Try again',

0 commit comments

Comments
 (0)