Skip to content

Commit 20df08c

Browse files
authored
fix(cloud-connection): localize the Cloud Connection panel (objectstack#3589 follow-up) (#2865)
`CloudConnectionPanel` — the `cloud-connection:panel` SDUI widget that is the entire body of the Cloud Connection Setup page — had no i18n at all: no `@object-ui/i18n` import, and no `cloudConnection` namespace in any of the ten built-in locale packs. Its siblings (`marketplace:installed-list`, `mcp:connect-agent`) were already fully localized, so once the framework-side `page:header` resolution landed (objectstack#3648) this page rendered a translated header above an English body. - New `cloudConnection` namespace in all ten packs, covering every phase of the device-code flow: checking, error + retry, waiting, bound, unbound. - The three hard-coded failure messages are translated where they are raised rather than where they are rendered, since they live in component state. - The "code is pre-filled…" line was one sentence stitched across JSX with a conditional tail and a bare '.'; it is now two self-contained strings. - `bound_at` formats with the active UI language instead of the browser default. Adds a locale-parity test over the `cloudConnection` key set — partial coverage degrades quietly, since i18next falls back to `en` and the page merely looks half-translated.
1 parent e16ed2d commit 20df08c

13 files changed

Lines changed: 498 additions & 32 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@object-ui/i18n": patch
3+
"@object-ui/app-shell": patch
4+
---
5+
6+
fix(cloud-connection): localize the Cloud Connection panel (objectstack#3589 follow-up)
7+
8+
`CloudConnectionPanel` — the `cloud-connection:panel` SDUI widget that is the
9+
entire body of the Cloud Connection Setup page — had no i18n at all: no
10+
`@object-ui/i18n` import, and no `cloudConnection` namespace in any of the ten
11+
built-in locale packs. Its siblings on neighbouring pages
12+
(`marketplace:installed-list`, `mcp:connect-agent`) were already fully
13+
localized, so this one page rendered a translated header above an English body
14+
once the framework-side `page:header` resolution landed.
15+
16+
- New `cloudConnection` namespace in all ten packs (en, zh, ja, ko, de, fr, es,
17+
pt, ru, ar), matching the coverage its sibling namespaces already had. Covers
18+
every phase of the device-code flow: checking, error + retry, waiting
19+
(approval prompt, user code, copy), bound (connection detail labels), and
20+
unbound (call to action).
21+
- The three hard-coded failure messages (expired request, bind failure, device
22+
code request failure) are translated where they are raised, not where they
23+
are rendered, since they are stored in component state.
24+
- The "code is pre-filled…" line was one sentence stitched together across JSX
25+
with a conditional tail and a bare `'.'`. It is now two self-contained
26+
strings, so a translator never receives a dangling clause whose word order
27+
they cannot change.
28+
- The `bound_at` timestamp now formats with the active UI language rather than
29+
the browser default, matching the surrounding copy.
30+
31+
Also adds a locale-parity test asserting the `cloudConnection` key set is
32+
identical across all ten packs — partial coverage degrades quietly, because
33+
i18next falls back to `en` and the result merely looks half-translated.

packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
CheckCircle2,
3232
Unplug,
3333
} from 'lucide-react';
34+
import { useObjectTranslation } from '@object-ui/i18n';
3435
import { ComponentRegistry } from '@object-ui/core';
3536

3637
const BASE = '/api/v1/cloud-connection';
@@ -79,6 +80,7 @@ async function getJson(url: string, init?: RequestInit): Promise<any> {
7980
}
8081

8182
export function CloudConnectionPanel() {
83+
const { t, language } = useObjectTranslation();
8284
const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
8385
const [busy, setBusy] = useState(false);
8486
const [copied, setCopied] = useState(false);
@@ -107,7 +109,7 @@ export function CloudConnectionPanel() {
107109
const intervalMs = Math.max(code.interval, 2) * 1000;
108110
const tick = async () => {
109111
if (Date.now() - startedAt > code.expires_in * 1000) {
110-
setPhase({ kind: 'error', message: 'The request expired before it was approved. Start again.' });
112+
setPhase({ kind: 'error', message: t('cloudConnection.errors.expired') });
111113
return;
112114
}
113115
try {
@@ -123,20 +125,20 @@ export function CloudConnectionPanel() {
123125
await refreshStatus();
124126
return;
125127
}
126-
setPhase({ kind: 'error', message: body?.error?.code ?? 'Binding failed.' });
128+
setPhase({ kind: 'error', message: body?.error?.code ?? t('cloudConnection.errors.bindFailed') });
127129
} catch (err: any) {
128130
setPhase({ kind: 'error', message: err?.message ?? String(err) });
129131
}
130132
};
131133
pollTimer.current = setTimeout(tick, intervalMs);
132-
}, [refreshStatus]);
134+
}, [refreshStatus, t]);
133135

134136
const connect = useCallback(async () => {
135137
setBusy(true);
136138
try {
137139
const body = await getJson(`${BASE}/bind/start`, { method: 'POST', body: '{}' });
138140
const code: DeviceCode = body?.data;
139-
if (!code?.device_code || !code?.user_code) throw new Error('Device code request failed.');
141+
if (!code?.device_code || !code?.user_code) throw new Error(t('cloudConnection.errors.deviceCodeFailed'));
140142
// Auto-open the approval page — the GitHub-login moment. Still within
141143
// the click's transient activation, so popup blockers generally allow
142144
// it; the code display below is the blocked-popup fallback.
@@ -154,7 +156,7 @@ export function CloudConnectionPanel() {
154156
} finally {
155157
setBusy(false);
156158
}
157-
}, [poll]);
159+
}, [poll, t]);
158160

159161
const disconnect = useCallback(async () => {
160162
setBusy(true);
@@ -179,7 +181,7 @@ export function CloudConnectionPanel() {
179181
if (phase.kind === 'loading') {
180182
return (
181183
<div className="flex items-center gap-2 rounded-lg border p-6 text-sm text-muted-foreground">
182-
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> Checking connection…
184+
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> {t('cloudConnection.checking')}
183185
</div>
184186
);
185187
}
@@ -195,7 +197,7 @@ export function CloudConnectionPanel() {
195197
className="self-start rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
196198
onClick={() => { stopPolling(); void refreshStatus(); }}
197199
>
198-
Try again
200+
{t('cloudConnection.retry')}
199201
</button>
200202
</div>
201203
);
@@ -208,8 +210,8 @@ export function CloudConnectionPanel() {
208210
<div className="flex items-center gap-2 text-sm text-muted-foreground">
209211
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
210212
{phase.popupOpened
211-
? 'Approve the connection in the window that just opened — this page updates by itself.'
212-
: 'Waiting for approval in the cloud console…'}
213+
? t('cloudConnection.waiting.popupOpened')
214+
: t('cloudConnection.waiting.polling')}
213215
</div>
214216
{!phase.popupOpened && link ? (
215217
<a
@@ -218,7 +220,7 @@ export function CloudConnectionPanel() {
218220
target="_blank"
219221
rel="noreferrer"
220222
>
221-
Open the approval page <ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
223+
{t('cloudConnection.waiting.openApproval')} <ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
222224
</a>
223225
) : null}
224226
<div className="flex items-center gap-3">
@@ -230,27 +232,29 @@ export function CloudConnectionPanel() {
230232
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
231233
onClick={() => void copyCode(phase.code.user_code)}
232234
>
233-
<Copy className="h-3.5 w-3.5" aria-hidden="true" /> {copied ? 'Copied' : 'Copy'}
235+
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
236+
{copied ? t('cloudConnection.waiting.copied') : t('cloudConnection.waiting.copy')}
234237
</button>
235238
</div>
239+
{/* Two self-contained strings rather than one sentence stitched across
240+
JSX — a translator never receives a dangling clause or a bare '.'. */}
236241
<p className="text-sm text-muted-foreground">
237-
The code is pre-filled on the approval page
242+
{t('cloudConnection.waiting.codePrefilled')}
238243
{phase.popupOpened && link ? (
239244
<>
240-
{' '}— if the window did not appear,{' '}
245+
{' '}
241246
<a className="inline-flex items-center gap-1 text-primary underline-offset-2 hover:underline" href={link} target="_blank" rel="noreferrer">
242-
open it here <ExternalLink className="h-3 w-3" aria-hidden="true" />
247+
{t('cloudConnection.waiting.openItHere')} <ExternalLink className="h-3 w-3" aria-hidden="true" />
243248
</a>
244-
.
245249
</>
246-
) : '.'}
250+
) : null}
247251
</p>
248252
<button
249253
type="button"
250254
className="self-start rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
251255
onClick={() => { stopPolling(); void refreshStatus(); }}
252256
>
253-
Cancel
257+
{t('cloudConnection.waiting.cancel')}
254258
</button>
255259
</div>
256260
);
@@ -263,26 +267,26 @@ export function CloudConnectionPanel() {
263267
<div className="flex flex-col gap-4 rounded-lg border p-6">
264268
<div className="flex items-center gap-2">
265269
<CheckCircle2 className="h-5 w-5 text-emerald-600" aria-hidden="true" />
266-
<span className="font-medium">Connected to ObjectStack Cloud</span>
270+
<span className="font-medium">{t('cloudConnection.bound.title')}</span>
267271
</div>
268272
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 text-sm">
269-
{conn.name ? (<><dt className="text-muted-foreground">Runtime</dt><dd>{conn.name}</dd></>) : null}
270-
{conn.organization_id ? (<><dt className="text-muted-foreground">Organization</dt><dd className="font-mono">{conn.organization_id}</dd></>) : null}
271-
{conn.account_email ? (<><dt className="text-muted-foreground">Approved by</dt><dd>{conn.account_email}</dd></>) : null}
272-
{runtimeId ? (<><dt className="text-muted-foreground">Runtime ID</dt><dd className="font-mono text-xs">{runtimeId}</dd></>) : null}
273-
{phase.status.environmentId ? (<><dt className="text-muted-foreground">Environment</dt><dd className="font-mono">{phase.status.environmentId}</dd></>) : null}
274-
{conn.bound_at ? (<><dt className="text-muted-foreground">Since</dt><dd>{new Date(conn.bound_at).toLocaleString()}</dd></>) : null}
273+
{conn.name ? (<><dt className="text-muted-foreground">{t('cloudConnection.bound.runtime')}</dt><dd>{conn.name}</dd></>) : null}
274+
{conn.organization_id ? (<><dt className="text-muted-foreground">{t('cloudConnection.bound.organization')}</dt><dd className="font-mono">{conn.organization_id}</dd></>) : null}
275+
{conn.account_email ? (<><dt className="text-muted-foreground">{t('cloudConnection.bound.approvedBy')}</dt><dd>{conn.account_email}</dd></>) : null}
276+
{runtimeId ? (<><dt className="text-muted-foreground">{t('cloudConnection.bound.runtimeId')}</dt><dd className="font-mono text-xs">{runtimeId}</dd></>) : null}
277+
{phase.status.environmentId ? (<><dt className="text-muted-foreground">{t('cloudConnection.bound.environment')}</dt><dd className="font-mono">{phase.status.environmentId}</dd></>) : null}
278+
{conn.bound_at ? (<><dt className="text-muted-foreground">{t('cloudConnection.bound.since')}</dt><dd>{new Date(conn.bound_at).toLocaleString(language)}</dd></>) : null}
275279
</dl>
276280
<p className="text-sm text-muted-foreground">
277-
Your organization's private packages now appear in the Marketplace under “Your organization”.
281+
{t('cloudConnection.bound.privatePackages')}
278282
</p>
279283
<button
280284
type="button"
281285
disabled={busy}
282286
className="inline-flex items-center gap-1.5 self-start rounded-md border border-destructive/40 px-3 py-1.5 text-sm text-destructive hover:bg-destructive/5 disabled:opacity-50"
283287
onClick={() => void disconnect()}
284288
>
285-
<Unplug className="h-3.5 w-3.5" aria-hidden="true" /> Disconnect
289+
<Unplug className="h-3.5 w-3.5" aria-hidden="true" /> {t('cloudConnection.bound.disconnect')}
286290
</button>
287291
</div>
288292
);
@@ -293,13 +297,10 @@ export function CloudConnectionPanel() {
293297
<div className="flex flex-col gap-4 rounded-lg border p-6">
294298
<div className="flex items-center gap-2">
295299
<CloudOff className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
296-
<span className="font-medium">Not connected</span>
300+
<span className="font-medium">{t('cloudConnection.unbound.title')}</span>
297301
</div>
298302
<p className="text-sm text-muted-foreground">
299-
Connect this runtime to an ObjectStack control plane to browse your
300-
organization's private packages and install them here. Approval is a
301-
single click in your cloud account — no ids or credentials are typed
302-
into this page.
303+
{t('cloudConnection.unbound.body')}
303304
</p>
304305
<button
305306
type="button"
@@ -308,7 +309,7 @@ export function CloudConnectionPanel() {
308309
onClick={() => void connect()}
309310
>
310311
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : <Cloud className="h-4 w-4" aria-hidden="true" />}
311-
Connect
312+
{t('cloudConnection.unbound.connect')}
312313
</button>
313314
</div>
314315
);
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* cloudConnection locale parity (objectstack#3589 follow-up).
3+
*
4+
* The Cloud Connection panel shipped with every string hard-coded in English
5+
* while its sibling widgets (marketplace, connectAgent) were fully localized —
6+
* so the page rendered a translated header above an English body. The related
7+
* framework-side gap had the same shape: `nav_cloud_connection` existed only
8+
* in zh-CN, leaving ja-JP/es-ES silently on the English fallback.
9+
*
10+
* Partial coverage is the failure mode worth pinning, because it degrades
11+
* quietly: i18next falls back to `en` and the UI just looks half-translated.
12+
* This asserts the `cloudConnection` key set is identical across all ten
13+
* built-in packs, so adding a key to `en` alone fails here rather than in a
14+
* user's browser.
15+
*/
16+
import { describe, it, expect } from 'vitest';
17+
import { builtInLocales } from '../locales';
18+
19+
/** Flatten a nested translation node into sorted dot-paths. */
20+
function keyPaths(node: unknown, prefix = ''): string[] {
21+
if (node === null || typeof node !== 'object') return [prefix];
22+
return Object.entries(node as Record<string, unknown>)
23+
.flatMap(([k, v]) => keyPaths(v, prefix ? `${prefix}.${k}` : k))
24+
.sort();
25+
}
26+
27+
/** Read a dot-path out of a nested translation node. */
28+
function readPath(node: unknown, path: string): unknown {
29+
return path.split('.').reduce<unknown>(
30+
(acc, seg) => (acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[seg] : undefined),
31+
node,
32+
);
33+
}
34+
35+
type LocaleCode = keyof typeof builtInLocales;
36+
37+
const LOCALES = Object.keys(builtInLocales) as LocaleCode[];
38+
const cloudConnectionOf = (code: LocaleCode): unknown =>
39+
(builtInLocales[code] as Record<string, unknown>).cloudConnection;
40+
41+
describe('cloudConnection translations', () => {
42+
it('is present in every built-in locale pack', () => {
43+
const missing = LOCALES.filter((code) => !cloudConnectionOf(code));
44+
expect(missing).toEqual([]);
45+
});
46+
47+
it('exposes an identical key set in every locale', () => {
48+
const expected = keyPaths(cloudConnectionOf('en'));
49+
// Guard against the flattener silently returning nothing.
50+
expect(expected.length).toBeGreaterThan(15);
51+
52+
for (const code of LOCALES) {
53+
expect({ code, keys: keyPaths(cloudConnectionOf(code)) }).toEqual({ code, keys: expected });
54+
}
55+
});
56+
57+
it('has a non-empty string at every leaf', () => {
58+
for (const code of LOCALES) {
59+
const node = cloudConnectionOf(code);
60+
for (const path of keyPaths(node)) {
61+
const value = readPath(node, path);
62+
expect({ code, path, ok: typeof value === 'string' && value.trim().length > 0 })
63+
.toEqual({ code, path, ok: true });
64+
}
65+
}
66+
});
67+
68+
it('actually translates the user-facing prose, not just the key structure', () => {
69+
// Short labels legitimately collide across languages ("Runtime" is
70+
// "Runtime" in de/fr/es), so only the prose keys are checked — those
71+
// matching English verbatim means the pack was never translated.
72+
const prose = ['unbound.body', 'bound.privatePackages', 'waiting.popupOpened'] as const;
73+
const read = (code: LocaleCode, path: string) => readPath(cloudConnectionOf(code), path);
74+
75+
for (const code of LOCALES.filter((c) => c !== 'en')) {
76+
for (const path of prose) {
77+
expect({ code, path, sameAsEnglish: read(code, path) === read('en', path) })
78+
.toEqual({ code, path, sameAsEnglish: false });
79+
}
80+
}
81+
});
82+
});

packages/i18n/src/locales/ar.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1842,6 +1842,41 @@ const ar = {
18421842
done: 'تم — إخفاء',
18431843
},
18441844
},
1845+
cloudConnection: {
1846+
checking: "جارٍ التحقق من الاتصال…",
1847+
retry: "إعادة المحاولة",
1848+
errors: {
1849+
expired: "انتهت صلاحية الطلب قبل الموافقة عليه. ابدأ من جديد.",
1850+
bindFailed: "فشل الربط.",
1851+
deviceCodeFailed: "فشل طلب رمز الجهاز.",
1852+
},
1853+
waiting: {
1854+
popupOpened: "وافق على الاتصال في النافذة التي فُتحت للتو — سيتم تحديث هذه الصفحة تلقائيًا.",
1855+
polling: "في انتظار الموافقة في وحدة التحكم السحابية…",
1856+
openApproval: "فتح صفحة الموافقة",
1857+
copy: "نسخ",
1858+
copied: "تم النسخ",
1859+
codePrefilled: "الرمز مُعبَّأ مسبقًا في صفحة الموافقة.",
1860+
openItHere: "لم تظهر النافذة؟ افتحها من هنا",
1861+
cancel: "إلغاء",
1862+
},
1863+
bound: {
1864+
title: "متصل بـ ObjectStack Cloud",
1865+
runtime: "بيئة التشغيل",
1866+
organization: "المؤسسة",
1867+
approvedBy: "تمت الموافقة بواسطة",
1868+
runtimeId: "معرّف بيئة التشغيل",
1869+
environment: "البيئة",
1870+
since: "متصل منذ",
1871+
privatePackages: "تظهر الآن الحزم الخاصة بمؤسستك في السوق ضمن «مؤسستك».",
1872+
disconnect: "قطع الاتصال",
1873+
},
1874+
unbound: {
1875+
title: "غير متصل",
1876+
body: "اربط بيئة التشغيل هذه بمستوى تحكم ObjectStack لتصفّح الحزم الخاصة بمؤسستك وتثبيتها هنا. الموافقة بنقرة واحدة في حسابك السحابي — دون إدخال أي معرّفات أو بيانات اعتماد في هذه الصفحة.",
1877+
connect: "اتصال",
1878+
},
1879+
},
18451880
marketplace: {
18461881
title: "سوق التطبيقات",
18471882
subtitle: "استعرض التطبيقات المعتمدة المنشورة في كتالوج ObjectStack. انقر على تطبيق لرؤية التفاصيل وتثبيته في أحد بيئاتك.",

packages/i18n/src/locales/de.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1844,6 +1844,41 @@ const de = {
18441844
done: 'Fertig — ausblenden',
18451845
},
18461846
},
1847+
cloudConnection: {
1848+
checking: "Verbindung wird geprüft…",
1849+
retry: "Erneut versuchen",
1850+
errors: {
1851+
expired: "Die Anfrage ist abgelaufen, bevor sie genehmigt wurde. Starten Sie erneut.",
1852+
bindFailed: "Verbindung fehlgeschlagen.",
1853+
deviceCodeFailed: "Anforderung des Gerätecodes fehlgeschlagen.",
1854+
},
1855+
waiting: {
1856+
popupOpened: "Genehmigen Sie die Verbindung im soeben geöffneten Fenster — diese Seite aktualisiert sich von selbst.",
1857+
polling: "Warten auf die Genehmigung in der Cloud-Konsole…",
1858+
openApproval: "Genehmigungsseite öffnen",
1859+
copy: "Kopieren",
1860+
copied: "Kopiert",
1861+
codePrefilled: "Der Code ist auf der Genehmigungsseite bereits eingetragen.",
1862+
openItHere: "Fenster nicht erschienen? Hier öffnen",
1863+
cancel: "Abbrechen",
1864+
},
1865+
bound: {
1866+
title: "Mit ObjectStack Cloud verbunden",
1867+
runtime: "Runtime",
1868+
organization: "Organisation",
1869+
approvedBy: "Genehmigt von",
1870+
runtimeId: "Runtime-ID",
1871+
environment: "Umgebung",
1872+
since: "Verbunden seit",
1873+
privatePackages: "Die privaten Pakete Ihrer Organisation erscheinen jetzt im Marktplatz unter „Ihre Organisation“.",
1874+
disconnect: "Verbindung trennen",
1875+
},
1876+
unbound: {
1877+
title: "Nicht verbunden",
1878+
body: "Verbinden Sie diese Runtime mit einer ObjectStack Control Plane, um die privaten Pakete Ihrer Organisation zu durchsuchen und hier zu installieren. Die Genehmigung erfolgt mit einem einzigen Klick in Ihrem Cloud-Konto — es werden keine IDs oder Zugangsdaten auf dieser Seite eingegeben.",
1879+
connect: "Verbinden",
1880+
},
1881+
},
18471882
marketplace: {
18481883
title: "App-Marktplatz",
18491884
subtitle: "Durchsuchen Sie genehmigte Apps, die im ObjectStack-Katalog veröffentlicht wurden. Klicken Sie auf eine App, um Details zu sehen und sie in eine Ihrer Umgebungen zu installieren.",

0 commit comments

Comments
 (0)