Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/console-chrome-i18n-4024.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@object-ui/i18n': minor
'@object-ui/console': minor
'@object-ui/plugin-list': patch
'@object-ui/plugin-grid': patch
'@object-ui/plugin-form': patch
'@object-ui/components': patch
---

Console chrome reaches the bundle — the list switcher, the aggregate footer, the dialog a11y fallbacks and the whole Settings namespace screen stop being English on non-English consoles

Six strings on the two screens a user looks at most were hardcoded English literals rather than bundle lookups, so they stayed English on every non-English console with nothing an app could author to change them. They are not object, field, view or action labels — no key in `TranslationData` reaches them — while the console's own bundle already ships zh-CN, ja-JP, es-ES, de, fr, pt, ru, ko and ar and translates hundreds of neighbouring strings. Omissions from an otherwise complete bundle, not a missing capability.

**Two of the six needed no new keys at all, which is the more interesting half.** The list-view mode switcher named its nine visualizations from a private `VIEW_LABELS` table while `console.objectView.viewType*` — the same nine words — had been resolved through the bundle by the create-view picker for months; the switcher now reads those keys, so the picker's 「画廊」 and the switcher's 「画廊」 cannot drift apart in nine languages. The create/edit dialog's close button is the remainder of a fix that already landed: objectstack#5505 routed the `sr-only` close label through `common.close` for the two Shadcn-synced primitives, but `MobileDialogContent` is a hand-written wrapper outside that regeneration zone with its own close button, and it is exactly what `ModalForm` renders — so the dialog the report measured was the one place still announcing "Close" in English.

The aggregate footer is the one the original report singled out: the **number** was already locale-formatted and the **prefix** was a hardcoded `Avg: ` / `Sum: `. All eleven aggregation kinds now take their prefix from `grid.summary.*`, and the label/value join is its own key rather than a `': '` baked into the renderer — the separator is translatable content, so zh sets a fullwidth colon and fr the French space-before-colon. The numbers are untouched. The form dialog's `sr-only` description fallback joins the packs too; it is clipped, not visible, so the only way an app could displace it was to author a `description` and thereby put a visible subtitle on every dialog.

**The Settings namespace screen converts as one unit.** `SettingsView` routed zero framing copy through i18n — save/failure toasts, the env-lock and crypto refusals, the load-error card, the empty-route state, the navigation buttons, the unsaved-changes save bar — while its immediate sibling `SettingsHub`, in the same directory, resolved everything through `t('console.settingsHub.*')`. A zh-CN admin read correctly translated field labels sitting inside an English save bar, because `useSettingsLabel` translates a namespace's authored content but reaches none of the chrome around it. All of it now resolves through a `console.settingsView.*` namespace placed beside the hub's, including the crypto-refusal strings that objectui#4579 deliberately left in English rather than leave one translated string among a dozen literals.

The save-bar counter was an English plural rule executing in every locale (`change` plus an `s` when the count exceeds one). It is now a real i18next plural family — base key plus `_one` and `_other` in all ten packs — not the `(s)` spelling translated nine ways. The base key is the load-bearing part: i18next asks `Intl.PluralRules` for the one suffix a language needs and, finding no such slot, falls back to English, so without it Russian would read English at counts 2-20 and Arabic at 2-99. Russian and Arabic take the "noun: {count}" form their packs already use for this exact reason, and the counter is verified rendering in-language at 1, 2 and 5.

The Beta badge reuses the hub's existing key rather than minting a twin, and the refusal messages interpolate their subject through the bundle instead of concatenating a translated word onto an English prefix.
69 changes: 48 additions & 21 deletions apps/console/src/pages/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { toast } from 'sonner';
import { Loader2, ArrowLeft, RotateCcw, ShieldAlert } from 'lucide-react';
import { Button, Card, CardContent, Skeleton, Badge } from '@object-ui/components';
import { extractFieldErrors } from '@object-ui/react';
import { useObjectTranslation } from '@object-ui/i18n';
import { getIcon } from '../../utils/getIcon';
import { SettingsField } from './SettingsField';
import {
Expand Down Expand Up @@ -101,6 +102,12 @@ function cryptoRefusalOf(apiError: unknown): CryptoRefusal {
export function SettingsView() {
const params = useParams<{ namespace?: string }>();
const navigate = useNavigate();
// objectui#4024 — the same convention the sibling `SettingsHub.tsx` already
// uses (`useObjectTranslation` + `console.settings*`), rather than
// `createSafeTranslation`: this is an app screen, not a published primitive
// with provider-less consumers to keep green, and its own suites mount a
// provider or pin the key-literal behaviour explicitly.
const { t } = useObjectTranslation();
const namespace = params.namespace ?? '';

const [payload, setPayload] = useState<SettingsNamespacePayload | null>(null);
Expand Down Expand Up @@ -140,11 +147,11 @@ export function SettingsView() {
setFieldErrors({});
setCryptoRefusal(null);
} catch (err: any) {
setError(err?.message ?? 'Failed to load settings');
setError(err?.message ?? t('console.settingsView.loadError'));
} finally {
setLoading(false);
}
}, [namespace]);
}, [namespace, t]);

useEffect(() => {
if (namespace) void load();
Expand All @@ -164,7 +171,9 @@ export function SettingsView() {
const labels = useSettingsLabel(namespace);

if (!namespace) {
return <div className="p-6 text-muted-foreground">No namespace selected.</div>;
return (
<div className="p-6 text-muted-foreground">{t('console.settingsView.noNamespace')}</div>
);
}

if (loading) {
Expand All @@ -182,7 +191,7 @@ export function SettingsView() {
return (
<div className="p-6 max-w-3xl">
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4 mr-1" /> Back
<ArrowLeft className="h-4 w-4 mr-1" /> {t('console.settingsView.back')}
</Button>
<Card className="mt-3">
<CardContent className="py-8 text-center text-sm text-destructive">{error}</CardContent>
Expand All @@ -209,13 +218,19 @@ export function SettingsView() {
setPayload({ ...payload, values: { ...values, ...res.values } });
setDraft({});
setFieldErrors({});
toast.success('Settings saved');
toast.success(t('console.settingsView.saved'));
} catch (err: any) {
const apiError = err?.payload?.error;
if (apiError?.code === 'SETTINGS_LOCKED') {
// `lockedKeyOf` reads both wire positions — see its note (objectstack#4224).
const key = lockedKeyOf(apiError);
toast.error(key ? `Locked by environment: ${key}` : 'Locked by environment');
// Parameterized, not concatenated: the key is spliced INTO the
// sentence, so a pack can place it where its own grammar wants.
toast.error(
key
? t('console.settingsView.lockedByEnv', { key })
: t('console.settingsView.lockedByEnvNoKey'),
);
} else if (apiError?.code === 'SETTINGS_CRYPTO_UNAVAILABLE') {
// The deployment cannot encrypt a declared-secret key, so the write was
// refused (objectstack#8396). This is neither a per-field rejection nor
Expand All @@ -231,7 +246,9 @@ export function SettingsView() {
const refusal = cryptoRefusalOf(apiError);
setCryptoRefusal(refusal);
toast.error(
refusal.subject ? `Cannot encrypt secrets: ${refusal.subject}` : 'Cannot encrypt secrets',
refusal.subject
? t('console.settingsView.cryptoRefusalToast', { subject: refusal.subject })
: t('console.settingsView.cryptoRefusalToastNoSubject'),
);
} else {
// Per-field rejections render against the inputs that caused them
Expand All @@ -247,7 +264,7 @@ export function SettingsView() {
if (perField?.length) {
setFieldErrors(Object.fromEntries(perField.map((f) => [f.field, f.message])));
}
toast.error(err?.message ?? 'Save failed');
toast.error(err?.message ?? t('console.settingsView.saveFailed'));
}
} finally {
setSaving(false);
Expand All @@ -258,10 +275,10 @@ export function SettingsView() {
setSaving(true);
try {
const result = await runSettingsAction(namespace, actionId, draft);
if (result.ok) toast.success(result.message ?? 'Action succeeded');
else toast.error(result.message ?? 'Action failed');
if (result.ok) toast.success(result.message ?? t('console.settingsView.actionSucceeded'));
else toast.error(result.message ?? t('console.settingsView.actionFailed'));
} catch (err: any) {
toast.error(err?.message ?? 'Action failed');
toast.error(err?.message ?? t('console.settingsView.actionFailed'));
} finally {
setSaving(false);
}
Expand All @@ -270,7 +287,7 @@ export function SettingsView() {
return (
<div className="p-6 max-w-3xl mx-auto pb-32">
<Button variant="ghost" size="sm" onClick={() => navigate('/system/settings')}>
<ArrowLeft className="h-4 w-4 mr-1" /> All settings
<ArrowLeft className="h-4 w-4 mr-1" /> {t('console.settingsView.backToHub')}
</Button>

<div className="mt-3 flex items-start gap-3">
Expand All @@ -281,7 +298,13 @@ export function SettingsView() {
<div className="flex-1">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{manifest.beta ? <Badge variant="secondary">Beta</Badge> : null}
{manifest.beta ? (
// `settingsHub.beta`, not a settingsView twin: it is the same
// release-stage badge on the same feature, and zh deliberately
// keeps it Latin (allowlisted in untranslated-identity-4376).
// A second key would be one more thing to keep in step.
<Badge variant="secondary">{t('console.settingsHub.beta')}</Badge>
) : null}
</div>
{description ? (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
Expand All @@ -300,16 +323,16 @@ export function SettingsView() {
<ShieldAlert className="h-5 w-5 mt-0.5 shrink-0 text-destructive" />
<div className="flex-1 text-sm">
<p className="font-medium text-destructive">
This deployment cannot encrypt secrets
{t('console.settingsView.cryptoRefusalTitle')}
</p>
<p className="mt-1">
{cryptoRefusal.subject ? (
<>
<code className="font-mono text-xs">{cryptoRefusal.subject}</code> is
declared encrypted, so nothing was written.
<code className="font-mono text-xs">{cryptoRefusal.subject}</code>{' '}
{t('console.settingsView.cryptoRefusalSubjectSuffix')}
</>
) : (
'The declared-encrypted value was refused, so nothing was written.'
t('console.settingsView.cryptoRefusalNoSubject')
)}
</p>
{cryptoRefusal.prescription ? (
Expand Down Expand Up @@ -358,8 +381,12 @@ export function SettingsView() {
{dirtyKeys.length > 0 ? (
<div className="fixed bottom-0 inset-x-0 bg-background/95 backdrop-blur border-t shadow-lg z-40">
<div className="max-w-3xl mx-auto px-6 py-3 flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{dirtyKeys.length} unsaved change{dirtyKeys.length > 1 ? 's' : ''}
{/* i18next's plural mechanism, NOT an English `change(s)`: the
count picks the pack's own plural slot, and the base key serves
every CLDR category a pack does not enumerate (objectui#3863) —
which is what keeps `ru` Russian at 2 and 5. */}
<div className="text-sm text-muted-foreground" data-testid="settings-unsaved-count">
{t('console.settingsView.unsavedCount', { count: dirtyKeys.length })}
</div>
<div className="flex gap-2">
<Button
Expand All @@ -374,11 +401,11 @@ export function SettingsView() {
}}
disabled={saving}
>
<RotateCcw className="h-4 w-4 mr-1" /> Discard
<RotateCcw className="h-4 w-4 mr-1" /> {t('console.settingsView.discard')}
</Button>
<Button onClick={onSave} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
Save changes
{t('console.settingsView.saveChanges')}
</Button>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { I18nProvider } from '@object-ui/i18n';

const getSettingsNamespace = vi.fn();
const saveSettingsNamespace = vi.fn();
Expand Down Expand Up @@ -170,13 +171,35 @@ function validationRejection() {
return err;
}

/**
* Mounts an EXPLICIT `en` I18nProvider — objectui#4024.
*
* This file used to mount none, which was fine while `SettingsView` carried its
* copy as English literals. It no longer does: the screen resolves
* `console.settingsView.*` through the bundle, and `t()` outside a provider
* returns the KEY, so every English assertion below would read
* `console.settingsView.cryptoRefusalTitle`.
*
* A provider, rather than re-pointing the assertions at key literals, because
* the two most valuable assertions here are about INTERPOLATION — that the
* refused key reaches `Cannot encrypt secrets: ai.api_key`, and that
* `SETTINGS_LOCKED` still names its own key. With no provider the key comes
* back bare and the `{{subject}}` / `{{key}}` holes are never filled, so a
* key-literal assertion could not tell a working interpolation from a broken
* one — it would keep the file green while deleting the thing it tests.
*
* The sibling `SettingsView.envelope.test.tsx` is deliberately handled the
* OTHER way; see its own note.
*/
function renderView() {
return render(
<MemoryRouter initialEntries={['/settings/ai']}>
<Routes>
<Route path="/settings/:namespace" element={<SettingsView />} />
</Routes>
</MemoryRouter>,
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
<MemoryRouter initialEntries={['/settings/ai']}>
<Routes>
<Route path="/settings/:namespace" element={<SettingsView />} />
</Routes>
</MemoryRouter>
</I18nProvider>,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@
* registered any settings while the server was answering 11 manifests.
*
* Mocking `./api` would assert nothing about either: the bug WAS `./api`.
*
* ## Deliberately still provider-less after objectui#4024
*
* #4024 routed `SettingsView`'s framing copy through the bundle, and the
* sibling `SettingsView.crypto-unavailable.test.tsx` had to gain an
* `I18nProvider` because its assertions are English sentences. This file did
* not, and that is a decision rather than an oversight: everything it asserts
* on screen is either manifest-authored CONTENT (`Timezone`, `Branding` — which
* comes off the payload, not the pack) or the hub's key literal
* `console.settingsHub.empty`, which it pins precisely BECAUSE `t()` with no
* provider returns the key. Adding a provider here would delete that pin, which
* is the one assertion in the file that is about i18n at all.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
Expand Down
Loading
Loading