From c6b340224eebad8e9a5e03a7508a37da54bc6638 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 17:55:47 +0000 Subject: [PATCH] fix(console,i18n): localize the Applications page's own chrome, and keep the server's words verbatim (#4307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AppManagementPage` was raw English end to end — headings, search, the selection and bulk controls, six per-row actions with their tooltip/ARIA pairs, the status badges and every toast. It was the last un-i18n'd system page, and #4233 / PR #4300 had just given it four live mutations, so the gap became user-visible exactly when operators started using it. 45 keys land under `appManagement.*` in all ten packs, reached through `useObjectTranslation` with the call site's `defaultValue` inline — the convention `ProfilePage` and `ApprovalsInboxPage` already follow. The split that shapes this change is between the strings the PAGE authors and the strings the SERVER authors. A refusal like `forbidden: manage_metadata required` is the server's diagnosis of one request, and there is no fixed catalogue of those to key against, so each failure toast is a keyed template with a `{{reason}}` hole filled byte for byte. The one part that is the page's own — what it says when the server sent nothing — is keyed as `appManagement.toast.unknownError`. Two consequences of converting it properly rather than mechanically: the per-entry template and separator of a bulk toast are keys, because bracket style and list punctuation are locale properties (`validation. formInvalidJoiner`'s rule and its past defect); and the row controls name an app through the resolver the visible heading already used, with `t` passed — a keyed label previously rendered `Select [object Object]`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .changeset/app-management-page-i18n-4307.md | 35 +++ .../src/pages/system/AppManagementPage.tsx | 209 ++++++++++--- .../__tests__/AppManagementPage.i18n.test.tsx | 287 ++++++++++++++++++ .../AppManagementPage.mutations.test.tsx | 28 ++ packages/i18n/src/locales/ar.ts | 49 +++ packages/i18n/src/locales/de.ts | 49 +++ packages/i18n/src/locales/en.ts | 78 +++++ packages/i18n/src/locales/es.ts | 49 +++ packages/i18n/src/locales/fr.ts | 49 +++ packages/i18n/src/locales/ja.ts | 49 +++ packages/i18n/src/locales/ko.ts | 49 +++ packages/i18n/src/locales/pt.ts | 49 +++ packages/i18n/src/locales/ru.ts | 49 +++ packages/i18n/src/locales/zh.ts | 49 +++ 14 files changed, 1030 insertions(+), 48 deletions(-) create mode 100644 .changeset/app-management-page-i18n-4307.md create mode 100644 apps/console/src/pages/system/__tests__/AppManagementPage.i18n.test.tsx diff --git a/.changeset/app-management-page-i18n-4307.md b/.changeset/app-management-page-i18n-4307.md new file mode 100644 index 000000000..78f5bfadb --- /dev/null +++ b/.changeset/app-management-page-i18n-4307.md @@ -0,0 +1,35 @@ +--- +'@object-ui/i18n': patch +'@object-ui/console': patch +--- + +The console's Applications page is localized — its own chrome only, never the +server's words (objectui#4307). + +`AppManagementPage` was raw English end to end: headings, the search field, the +selection and bulk controls, the six per-row actions with their tooltip/ARIA +pairs, the status badges, and every toast. It was the last un-i18n'd system page, +and #4233 / PR #4300 had just given it four live mutations — so the gap became +user-visible on every non-English console at the moment operators started using +it. 45 keys land under `appManagement.*` in all ten packs, reached through +`useObjectTranslation` with the call site's `defaultValue` inline, which is the +convention the neighbouring system pages already follow. + +The split that shapes this change is between the strings the PAGE authors and +the strings the SERVER authors. `PUT`/`DELETE /api/v1/meta/app/:name` is gated on +`manage_metadata` (ADR-0066 D1), so a refusal like `forbidden: manage_metadata +required` is the server's diagnosis of one specific request; there is no fixed +catalogue of those sentences to key against. Each failure toast is therefore a +keyed template with a `{{reason}}` hole, and what fills the hole is passed +through byte for byte, untranslated. The one part that IS the page's own — what +it says when the server sent no message at all — is keyed as +`appManagement.toast.unknownError`. + +Two smaller things follow from doing the conversion properly rather than +mechanically. The per-failure entry of a bulk toast and the separator between +entries are keys, not literals, because bracket style and list punctuation are +locale properties (the same rule, and the same past defect, as +`validation.formInvalidJoiner`). And the row's controls now name an app through +the resolver the visible heading two lines away already used, with `t` passed: +an app carrying objectui's keyed label form previously rendered `Select [object +Object]` into its checkbox's ARIA label. diff --git a/apps/console/src/pages/system/AppManagementPage.tsx b/apps/console/src/pages/system/AppManagementPage.tsx index 2f8d97c3f..5ae57899b 100644 --- a/apps/console/src/pages/system/AppManagementPage.tsx +++ b/apps/console/src/pages/system/AppManagementPage.tsx @@ -28,6 +28,18 @@ * objectui#4233's other half: because these controls never wrote, an operator * could not set `isDefault` from the UI at all — the app had to be republished * through metadata — so the landing bug had no in-product workaround. + * + * ## The chrome is keyed, the server's words are not (objectui#4307) + * + * Every string this page AUTHORS goes through `t('appManagement.…')`. Every + * string the SERVER authored is interpolated raw, and the split is deliberate + * rather than incidental: a refusal like `forbidden: manage_metadata required` + * is the server's diagnosis of a specific request, so keying it would mean + * inventing a fixed catalogue of sentences the server does not promise to keep + * sending. So the failure toasts are keyed **templates with a `{{reason}}` + * hole** — the frame is localized, what fills it is passed through byte for + * byte. `AppManagementPage.i18n.test.tsx` pins both halves at once, against a + * `t` that answers in no natural language at all. */ import { useState, useCallback } from 'react'; @@ -54,6 +66,7 @@ import { } from 'lucide-react'; import { toast } from 'sonner'; import { useMetadata, useAdapter } from '@object-ui/app-shell'; +import { useObjectTranslation } from '@object-ui/i18n'; import { resolveKeyedI18nLabel } from '../../utils'; /** @@ -70,13 +83,20 @@ interface AdapterWithClient { getClient?: () => { meta?: MetaWriteClient } | null | undefined; } -/** What the server said, when it said anything — never a swallowed failure. */ -function reason(e: unknown): string { +/** + * What the server said, when it said anything — never a swallowed failure. + * + * The message is returned UNTRANSLATED by construction (objectui#4307): it is + * the server's own sentence about this request, and the only localized part is + * `unknown`, which is what this page says when the server said nothing at all. + */ +function reason(e: unknown, unknown: string): string { const message = e instanceof Error ? e.message : String(e ?? ''); - return message || 'unknown error'; + return message || unknown; } export function AppManagementPage() { + const { t } = useObjectTranslation(); const navigate = useNavigate(); const { appName } = useParams(); const basePath = appName ? `/apps/${appName}` : ''; @@ -95,6 +115,24 @@ export function AppManagementPage() { return client?.meta ?? null; }, [adapter]); + /** The page's own word for "the server told us nothing" — see `reason`. */ + const unknownError = t('appManagement.toast.unknownError', { defaultValue: 'unknown error' }); + + /** + * One display name for an app, used by every label and toast below. + * + * `t` is passed on deliberately: an app's `label` may be objectui's KEYED + * form (`{ key, defaultValue }`), and the resolver only reaches the pack when + * it is handed a translator — the same call shape `AppSidebar` and + * `DashboardView` already use. Without it a keyed label renders its authoring + * `defaultValue` on every locale; interpolating `app.label` directly (what + * these labels did before this page was keyed) renders `[object Object]`. + */ + const appTitle = useCallback( + (app: any): string => resolveKeyedI18nLabel(app?.label, t) || app?.name || '', + [t], + ); + const [searchQuery, setSearchQuery] = useState(''); const [selectedIds, setSelectedIds] = useState>(new Set()); const [confirmDelete, setConfirmDelete] = useState(null); @@ -131,7 +169,7 @@ export function AppManagementPage() { const handleToggleActive = useCallback(async (app: any) => { const meta = metaClient(); if (!meta) { - toast.error('Cannot reach the metadata service'); + toast.error(t('appManagement.toast.noClient', { defaultValue: 'Cannot reach the metadata service' })); return; } setProcessing(true); @@ -142,19 +180,27 @@ export function AppManagementPage() { // for the `navigation` key. Rebuilding a minimal payload instead would // drop every field of the app the console does not model. await meta.saveItem('app', app.name, { ...app, active: newActive }); - toast.success(`${app.label || app.name} ${newActive ? 'enabled' : 'disabled'}`); + toast.success( + newActive + ? t('appManagement.toast.appEnabled', { defaultValue: '{{name}} enabled', name: appTitle(app) }) + : t('appManagement.toast.appDisabled', { defaultValue: '{{name}} disabled', name: appTitle(app) }), + ); await refresh(); } catch (e: unknown) { - toast.error(`Failed to toggle app status: ${reason(e)}`); + // The frame is localized; `{{reason}}` is the server's own sentence, raw. + toast.error(t('appManagement.toast.toggleFailed', { + defaultValue: 'Failed to toggle app status: {{reason}}', + reason: reason(e, unknownError), + })); } finally { setProcessing(false); } - }, [refresh, metaClient]); + }, [refresh, metaClient, t, appTitle, unknownError]); const handleSetDefault = useCallback(async (app: any) => { const meta = metaClient(); if (!meta) { - toast.error('Cannot reach the metadata service'); + toast.error(t('appManagement.toast.noClient', { defaultValue: 'Cannot reach the metadata service' })); return; } setProcessing(true); @@ -173,14 +219,20 @@ export function AppManagementPage() { await meta.saveItem('app', prev.name, { ...prev, isDefault: false }); } await meta.saveItem('app', app.name, { ...app, isDefault: true }); - toast.success(`${app.label || app.name} set as default`); + toast.success(t('appManagement.toast.setDefaultDone', { + defaultValue: '{{name}} set as default', + name: appTitle(app), + })); await refresh(); } catch (e: unknown) { - toast.error(`Failed to set default app: ${reason(e)}`); + toast.error(t('appManagement.toast.setDefaultFailed', { + defaultValue: 'Failed to set default app: {{reason}}', + reason: reason(e, unknownError), + })); } finally { setProcessing(false); } - }, [apps, refresh, metaClient]); + }, [apps, refresh, metaClient, t, appTitle, unknownError]); const handleDelete = useCallback(async (appToDelete: any) => { if (confirmDelete !== appToDelete.name) { @@ -189,26 +241,32 @@ export function AppManagementPage() { } const meta = metaClient(); if (!meta) { - toast.error('Cannot reach the metadata service'); + toast.error(t('appManagement.toast.noClient', { defaultValue: 'Cannot reach the metadata service' })); return; } setProcessing(true); try { await meta.deleteItem('app', appToDelete.name); - toast.success(`${appToDelete.label || appToDelete.name} deleted`); + toast.success(t('appManagement.toast.appDeleted', { + defaultValue: '{{name}} deleted', + name: appTitle(appToDelete), + })); setConfirmDelete(null); await refresh(); } catch (e: unknown) { - toast.error(`Failed to delete app: ${reason(e)}`); + toast.error(t('appManagement.toast.deleteFailed', { + defaultValue: 'Failed to delete app: {{reason}}', + reason: reason(e, unknownError), + })); } finally { setProcessing(false); } - }, [confirmDelete, refresh, metaClient]); + }, [confirmDelete, refresh, metaClient, t, appTitle, unknownError]); const handleBulkToggle = useCallback(async (active: boolean) => { const meta = metaClient(); if (!meta) { - toast.error('Cannot reach the metadata service'); + toast.error(t('appManagement.toast.noClient', { defaultValue: 'Cannot reach the metadata service' })); return; } setProcessing(true); @@ -227,47 +285,72 @@ export function AppManagementPage() { await meta.saveItem('app', name, { ...app, active }); saved += 1; } catch (e: unknown) { - failures.push(`${app.label || name} (${reason(e)})`); + // Keyed for its PUNCTUATION as much as its words — the CJK packs set + // the bracket pair and the space before it, exactly as + // `detail.userStatusTitle` does. `{{reason}}` stays the server's. + failures.push(t('appManagement.toast.bulkFailureEntry', { + defaultValue: '{{name}} ({{reason}})', + name: appTitle(app), + reason: reason(e, unknownError), + })); } } if (saved > 0) { - toast.success(`${saved} apps ${active ? 'enabled' : 'disabled'}`); + toast.success( + active + ? t('appManagement.toast.bulkEnabled', { defaultValue: '{{n}} apps enabled', n: saved }) + : t('appManagement.toast.bulkDisabled', { defaultValue: '{{n}} apps disabled', n: saved }), + ); setSelectedIds(new Set()); } if (failures.length > 0) { - toast.error(`Failed for ${failures.length}: ${failures.join('; ')}`); + // The joiner is a locale property, not a code constant — same rule (and + // same past defect) as `validation.formInvalidJoiner`. + const joiner = t('appManagement.toast.bulkFailureJoiner', { defaultValue: '; ' }); + toast.error(t('appManagement.toast.bulkFailed', { + defaultValue: 'Failed for {{n}}: {{details}}', + n: failures.length, + details: failures.join(joiner), + })); } await refresh(); } catch (e: unknown) { - toast.error(`Bulk operation failed: ${reason(e)}`); + toast.error(t('appManagement.toast.bulkOperationFailed', { + defaultValue: 'Bulk operation failed: {{reason}}', + reason: reason(e, unknownError), + })); } finally { setProcessing(false); } - }, [apps, selectedIds, refresh, metaClient]); + }, [apps, selectedIds, refresh, metaClient, t, appTitle, unknownError]); return (
-

Applications

+

+ {t('appManagement.title', { defaultValue: 'Applications' })} +

- Manage all configured applications + {t('appManagement.subtitle', { defaultValue: 'Manage all configured applications' })}

{/* Search & Bulk Actions */}
- +
{selectedIds.size > 0 && (
- {selectedIds.size} selected + + {t('appManagement.selectedCount', { defaultValue: '{{n}} selected', n: selectedIds.size })} +
)} @@ -295,7 +380,9 @@ export function AppManagementPage() { checked={selectedIds.size === filteredApps.length && filteredApps.length > 0} onCheckedChange={() => toggleSelectAll()} /> - +
)} @@ -303,7 +390,7 @@ export function AppManagementPage() { {filteredApps.length === 0 ? (
-

No apps found.

+

{t('appManagement.empty', { defaultValue: 'No apps found.' })}

) : (
@@ -311,6 +398,9 @@ export function AppManagementPage() { const isActive = app.active !== false; const isDefault = app.isDefault === true; const isDeleting = confirmDelete === app.name; + // One resolved display name for the row: the visible heading and + // all six control labels below name the app the SAME way. + const title = appTitle(app); return ( @@ -319,7 +409,7 @@ export function AppManagementPage() { id={`select-app-${app.name}`} checked={selectedIds.has(app.name)} onCheckedChange={() => toggleSelect(app.name)} - aria-label={`Select ${app.label || app.name}`} + aria-label={t('appManagement.selectApp', { defaultValue: 'Select {{name}}', name: title })} className="shrink-0" />
@@ -327,22 +417,28 @@ export function AppManagementPage() {
- {resolveKeyedI18nLabel(app.label) || app.name} - {isDefault && Default} + {title} + {isDefault && ( + + {t('appManagement.defaultBadge', { defaultValue: 'Default' })} + + )} - {isActive ? 'Active' : 'Inactive'} + {isActive + ? t('appManagement.active', { defaultValue: 'Active' }) + : t('appManagement.inactive', { defaultValue: 'Inactive' })}
{app.description && ( -

{resolveKeyedI18nLabel(app.description)}

+

{resolveKeyedI18nLabel(app.description, t)}

)}