From 23f918ec14b3c395732d3585c44cee8dc885db80 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 17:25:15 +0000 Subject: [PATCH] =?UTF-8?q?fix(i18n):=20console=20chrome=20reaches=20the?= =?UTF-8?q?=20bundle=20=E2=80=94=20list=20switcher,=20aggregate=20prefixes?= =?UTF-8?q?,=20dialog=20a11y=20fallbacks,=20SettingsView=20(#4024)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six strings on the two screens a user looks at most were hardcoded English literals rather than bundle lookups. None corresponds to authorable metadata, so no app could work around them. Two needed no new keys at all. 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. And the create/edit dialog's close button is the remainder of objectstack#5505: that fix reached the two Shadcn-synced primitives via the declared patch, but MobileDialogContent is a hand-written custom/ wrapper with its own close button, and it is exactly what ModalForm renders. The aggregate footer had a locale-formatted number behind a hardcoded prefix. All eleven aggregation kinds now read grid.summary.*, and the label/value join is its own key so a pack owns its separator. Numbers untouched (#4589's surface). The form dialog's sr-only description fallback joins the packs too. SettingsView converts as one unit: its sibling SettingsHub already resolved everything through t('console.settingsHub.*') while the view routed zero framing copy through i18n. All of it now resolves through console.settingsView.*, including the crypto-refusal strings #4579 deliberately deferred here. The save-bar counter was an English plural rule executing in every locale. It is now a real i18next plural family — base + _one + _other in all ten packs — with the base key carrying the CLDR categories ru and ar do not enumerate. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .changeset/console-chrome-i18n-4024.md | 22 ++ .../src/pages/settings/SettingsView.tsx | 69 +++-- .../SettingsView.crypto-unavailable.test.tsx | 33 +- .../__tests__/SettingsView.envelope.test.tsx | 12 + .../__tests__/SettingsView.i18n-4024.test.tsx | 290 ++++++++++++++++++ .../mobile-dialog-close-i18n-4024.test.tsx | 110 +++++++ ...ile-dialog-close-no-provider-4024.test.tsx | 50 +++ .../src/custom/mobile-dialog-content.tsx | 11 +- .../untranslated-identity-4376.test.ts | 6 + packages/i18n/src/locales/ar.ts | 44 +++ packages/i18n/src/locales/de.ts | 44 +++ packages/i18n/src/locales/en.ts | 84 +++++ packages/i18n/src/locales/es.ts | 44 +++ packages/i18n/src/locales/fr.ts | 44 +++ packages/i18n/src/locales/ja.ts | 44 +++ packages/i18n/src/locales/ko.ts | 44 +++ packages/i18n/src/locales/pt.ts | 44 +++ packages/i18n/src/locales/ru.ts | 44 +++ packages/i18n/src/locales/zh.ts | 44 +++ packages/plugin-form/src/DrawerForm.tsx | 8 +- packages/plugin-form/src/ModalForm.tsx | 8 +- ...ionFallback.i18n-4024.no-provider.test.tsx | 78 +++++ ...alogDescriptionFallback.i18n-4024.test.tsx | 156 ++++++++++ ...lumnSummary.i18n-4024.no-provider.test.tsx | 61 ++++ .../src/useColumnSummary.i18n-4024.test.tsx | 148 +++++++++ packages/plugin-grid/src/useColumnSummary.ts | 123 ++++++-- packages/plugin-list/src/ViewSwitcher.tsx | 85 ++++- ...iewSwitcher.i18n-4024.no-provider.test.tsx | 58 ++++ .../__tests__/ViewSwitcher.i18n-4024.test.tsx | 123 ++++++++ 29 files changed, 1867 insertions(+), 64 deletions(-) create mode 100644 .changeset/console-chrome-i18n-4024.md create mode 100644 apps/console/src/pages/settings/__tests__/SettingsView.i18n-4024.test.tsx create mode 100644 packages/components/src/__tests__/mobile-dialog-close-i18n-4024.test.tsx create mode 100644 packages/components/src/__tests__/mobile-dialog-close-no-provider-4024.test.tsx create mode 100644 packages/plugin-form/src/dialogDescriptionFallback.i18n-4024.no-provider.test.tsx create mode 100644 packages/plugin-form/src/dialogDescriptionFallback.i18n-4024.test.tsx create mode 100644 packages/plugin-grid/src/useColumnSummary.i18n-4024.no-provider.test.tsx create mode 100644 packages/plugin-grid/src/useColumnSummary.i18n-4024.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ViewSwitcher.i18n-4024.no-provider.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ViewSwitcher.i18n-4024.test.tsx diff --git a/.changeset/console-chrome-i18n-4024.md b/.changeset/console-chrome-i18n-4024.md new file mode 100644 index 0000000000..9116dc93ab --- /dev/null +++ b/.changeset/console-chrome-i18n-4024.md @@ -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. diff --git a/apps/console/src/pages/settings/SettingsView.tsx b/apps/console/src/pages/settings/SettingsView.tsx index e4c90d18c0..c4664d9a77 100644 --- a/apps/console/src/pages/settings/SettingsView.tsx +++ b/apps/console/src/pages/settings/SettingsView.tsx @@ -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 { @@ -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(null); @@ -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(); @@ -164,7 +171,9 @@ export function SettingsView() { const labels = useSettingsLabel(namespace); if (!namespace) { - return
No namespace selected.
; + return ( +
{t('console.settingsView.noNamespace')}
+ ); } if (loading) { @@ -182,7 +191,7 @@ export function SettingsView() { return (
{error} @@ -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 @@ -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 @@ -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); @@ -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); } @@ -270,7 +287,7 @@ export function SettingsView() { return (
@@ -281,7 +298,13 @@ export function SettingsView() {

{title}

- {manifest.beta ? Beta : 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. + {t('console.settingsHub.beta')} + ) : null}
{description ? (

{description}

@@ -300,16 +323,16 @@ export function SettingsView() {

- This deployment cannot encrypt secrets + {t('console.settingsView.cryptoRefusalTitle')}

{cryptoRefusal.subject ? ( <> - {cryptoRefusal.subject} is - declared encrypted, so nothing was written. + {cryptoRefusal.subject}{' '} + {t('console.settingsView.cryptoRefusalSubjectSuffix')} ) : ( - 'The declared-encrypted value was refused, so nothing was written.' + t('console.settingsView.cryptoRefusalNoSubject') )}

{cryptoRefusal.prescription ? ( @@ -358,8 +381,12 @@ export function SettingsView() { {dirtyKeys.length > 0 ? (
-
- {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. */} +
+ {t('console.settingsView.unsavedCount', { count: dirtyKeys.length })}
diff --git a/apps/console/src/pages/settings/__tests__/SettingsView.crypto-unavailable.test.tsx b/apps/console/src/pages/settings/__tests__/SettingsView.crypto-unavailable.test.tsx index 9e8646a70a..1b06dbf734 100644 --- a/apps/console/src/pages/settings/__tests__/SettingsView.crypto-unavailable.test.tsx +++ b/apps/console/src/pages/settings/__tests__/SettingsView.crypto-unavailable.test.tsx @@ -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(); @@ -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( - - - } /> - - , + + + + } /> + + + , ); } diff --git a/apps/console/src/pages/settings/__tests__/SettingsView.envelope.test.tsx b/apps/console/src/pages/settings/__tests__/SettingsView.envelope.test.tsx index 909197bf21..44e3287cbd 100644 --- a/apps/console/src/pages/settings/__tests__/SettingsView.envelope.test.tsx +++ b/apps/console/src/pages/settings/__tests__/SettingsView.envelope.test.tsx @@ -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'; diff --git a/apps/console/src/pages/settings/__tests__/SettingsView.i18n-4024.test.tsx b/apps/console/src/pages/settings/__tests__/SettingsView.i18n-4024.test.tsx new file mode 100644 index 0000000000..2894537c89 --- /dev/null +++ b/apps/console/src/pages/settings/__tests__/SettingsView.i18n-4024.test.tsx @@ -0,0 +1,290 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `SettingsView`'s framing copy speaks the session locale — objectui#4024, + * the SettingsView unit recorded in the card's comment. + * + * ## Why this screen is one item rather than nine + * + * Its immediate sibling already does the right thing. `SettingsHub.tsx`, in the + * same directory, resolves through `t('console.settingsHub.*')` against the same + * bundle that ships all ten packs. So this was never a missing capability or an + * unwired screen — it was one file written against a different convention than + * the file next to it, and converting it half-way would have left a single + * translated string among a dozen hardcoded ones. `useSettingsLabel` already + * translates the manifest-authored CONTENT (title, description, field labels), + * so a zh-CN admin saw translated field labels sitting inside an English save + * bar, with no key a plugin author could reach. + * + * The convention matched is therefore the sibling's, exactly: `useObjectTranslation` + * from `@object-ui/i18n` + a `console.settingsView.*` namespace placed beside + * `console.settingsHub.*` in every pack. + * + * ## The save-bar counter goes through i18next's real PLURAL mechanism + * + * `{dirtyKeys.length} unsaved change{dirtyKeys.length > 1 ? 's' : ''}` is an + * ENGLISH plural rule executing in every locale. Translating it as + * "N unsaved change(s)" would only translate the defect, so the counter is a + * plural family: base + `_one` + `_other`, in all ten packs. + * + * The base key is load-bearing and is not decoration. i18next asks + * `Intl.PluralRules` for the ONE suffix a language needs for that number and, + * finding no such slot, walks `fallbackLng` to `en`. `ru` has four categories + * (one/few/many/other) and `ar` six; no pack in this repo enumerates + * `_few`/`_many`/`_two`/`_zero`, so without a base key `ru` renders ENGLISH at + * counts 2-20. That is not hypothetical — it is objectui#3863, measured on + * `detail.showEmptyRelated`, and `all-locales-key-parity.test.ts` owns the rule + * that came out of it. The `ru` cases below at counts 1/2/5 are what prove the + * mechanism actually reaches a plural-rich pack rather than merely type-checking. + * + * The alternative convention in this repo — two sibling keys `xxxCount` / + * `xxxCountOne` (`common.itemCount`, `collaboration.commentCount`) — was + * deliberately NOT used: its stated rationale is a parity fear that objectui#3863's + * base-key fix has since answered, and it hard-codes a two-form (English-shaped) + * split that `ru` and `ar` do not have. + * + * ## The #4514 provider-less trap, and what was decided per test + * + * The existing settings suites mount no `I18nProvider` and assert raw English; + * `t()` outside a provider returns the KEY. This file mounts a provider in every + * case, because interpolation is the thing under test: with no provider, + * `t('console.settingsView.lockedByEnv', { key })` returns the bare key and the + * `{{key}}` hole is never filled, so a provider-less assertion could not tell a + * working interpolation from a broken one. The sibling suites' own disposition + * is recorded in their headers. + * + * ## Red-first prediction (written BEFORE the first run) + * + * Pre-fix, on `origin/main`: 7 failed, 2 passed. + * - every zh/ru case FAILS — the component renders the English literal, so + * `findByText('全部设置')` and friends match nothing; + * - the `en` framing cases (`All settings`, `Save changes`) PASS pre-fix — + * the literal equals the value the pack is about to gain, which is exactly + * why nothing caught this. + * + * ONE PREDICTION IN THIS FILE WAS WRONG, and it is recorded rather than + * quietly corrected. The header first claimed the counter cases would go red in + * BOTH languages including `en`, on the reasoning that the bundle has no + * `console.settingsView.unsavedCount` yet. The `en` counter case PASSED pre-fix + * — because the concatenation it replaces (`change{n > 1 ? 's' : ''}`) IS the + * English plural rule, so English is precisely the one language whose output + * cannot change. That is the defect stated exactly: the screen was correct in + * English and could not be correct anywhere else. The `en` counter case is + * therefore a must-not-change pin, and `ru`/`zh` carry the evidence. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } 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'; + +// `vi.mock` is hoisted above every `const`, so the spies it closes over must be +// created inside `vi.hoisted` — a plain top-level `const` is in the temporal +// dead zone when the factory runs. +const { toastError, toastSuccess } = vi.hoisted(() => ({ + toastError: vi.fn(), + toastSuccess: vi.fn(), +})); +vi.mock('sonner', () => ({ toast: { error: toastError, success: toastSuccess } })); + +import { SettingsView } from '../SettingsView'; + +const PAYLOAD = { + manifest: { + namespace: 'localization', + version: 1, + label: 'Localization', + specifiers: [ + { type: 'text', key: 'timezone', label: 'Timezone' }, + { type: 'text', key: 'language', label: 'Language' }, + { type: 'text', key: 'region', label: 'Region' }, + ], + }, + values: { + timezone: { value: 'Asia/Shanghai', source: 'default', locked: false }, + language: { value: 'zh-CN', source: 'default', locked: false }, + region: { value: 'CN', source: 'default', locked: false }, + }, +}; + +const fetchMock = vi.fn(); + +beforeEach(() => { + fetchMock.mockReset(); + toastError.mockReset(); + toastSuccess.mockReset(); + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ success: true, data: PAYLOAD }), + } as unknown as Response); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function renderInLocale(language: string, path = '/settings/localization') { + return render( + + + + } /> + } /> + + + , + ); +} + +/** Type into the Nth text input so the save bar appears with N dirty keys. */ +async function dirty(n: number) { + const inputs = await screen.findAllByRole('textbox'); + for (let i = 0; i < n; i++) { + fireEvent.change(inputs[i], { target: { value: `edited-${i}` } }); + } +} + +describe('SettingsView framing copy resolves from the bundle (objectui#4024)', () => { + it('zh-CN: the navigation button reads 全部设置', async () => { + renderInLocale('zh'); + expect(await screen.findByText('全部设置')).toBeTruthy(); + expect(screen.queryByText('All settings')).toBeNull(); + }); + + it('zh-CN: the save bar reads 放弃 / 保存更改', async () => { + renderInLocale('zh'); + await dirty(1); + expect(await screen.findByText('放弃')).toBeTruthy(); + expect(screen.getByText('保存更改')).toBeTruthy(); + expect(screen.queryByText('Discard')).toBeNull(); + expect(screen.queryByText('Save changes')).toBeNull(); + }); + + it('en: the save bar is unchanged (must-not-change)', async () => { + renderInLocale('en'); + await dirty(1); + expect(await screen.findByText('Discard')).toBeTruthy(); + expect(screen.getByText('Save changes')).toBeTruthy(); + expect(screen.getByText('All settings')).toBeTruthy(); + }); + + it('zh-CN: the empty-route state reads 未选择命名空间。', async () => { + renderInLocale('zh', '/settings'); + expect(await screen.findByText('未选择命名空间。')).toBeTruthy(); + }); +}); + +describe('SettingsView save-bar counter pluralizes through i18next (objectui#4024)', () => { + it('en: 1 change is singular, 2 and 5 are plural', async () => { + renderInLocale('en'); + await dirty(1); + expect(await screen.findByText('1 unsaved change')).toBeTruthy(); + + await dirty(2); + expect(await screen.findByText('2 unsaved changes')).toBeTruthy(); + + await dirty(3); + expect(await screen.findByText('3 unsaved changes')).toBeTruthy(); + // The English-only `(s)` rule this replaced must be gone entirely. + expect(screen.queryByText(/change\(s\)/)).toBeNull(); + }); + + it('ru: the counter is Russian at 1, 2 AND 5 — the categories only a base key can serve', async () => { + // ru's CLDR categories are one/few/many/other. `_one` covers 1, `_other` + // covers the `other` category, and 2 (`few`) / 5 (`many`) resolve NOTHING + // locally — they land on the BASE key. Without it they fall through + // `fallbackLng` to English, which is objectui#3863 exactly. + renderInLocale('ru'); + await dirty(1); + const one = await screen.findByTestId('settings-unsaved-count'); + expect(one.textContent).toMatch(/[А-Яа-я]/); + expect(one.textContent).not.toMatch(/unsaved/); + + await dirty(2); + await waitFor(() => { + const few = screen.getByTestId('settings-unsaved-count'); + expect(few.textContent).toMatch(/[А-Яа-я]/); + expect(few.textContent).not.toMatch(/unsaved/); + }); + + await dirty(3); + await waitFor(() => { + const many = screen.getByTestId('settings-unsaved-count'); + expect(many.textContent).toMatch(/[А-Яа-я]/); + expect(many.textContent).not.toMatch(/unsaved/); + }); + }); + + it('zh-CN: the counter is Chinese and carries the number', async () => { + renderInLocale('zh'); + await dirty(2); + const node = await screen.findByTestId('settings-unsaved-count'); + expect(node.textContent).toContain('2'); + expect(node.textContent).toMatch(/[一-鿿]/); + expect(node.textContent).not.toMatch(/unsaved/); + }); +}); + +describe('SettingsView refusal toasts interpolate through the bundle (objectui#4024)', () => { + it('zh-CN: SETTINGS_LOCKED names the key inside a translated sentence', async () => { + renderInLocale('zh'); + await dirty(1); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 409, + statusText: 'Conflict', + json: async () => ({ + success: false, + error: { code: 'SETTINGS_LOCKED', message: 'locked', details: { key: 'timezone' } }, + }), + } as unknown as Response); + + fireEvent.click(await screen.findByText('保存更改')); + + await waitFor(() => expect(toastError).toHaveBeenCalled()); + const calls = toastError.mock.calls; + const msg = String(calls[calls.length - 1]?.[0] ?? ''); + // Interpolation is the thing under test: the key must be spliced INTO a + // translated sentence, not concatenated onto an English prefix. + expect(msg).toContain('timezone'); + expect(msg).toMatch(/[一-鿿]/); + expect(msg).not.toContain('Locked by environment'); + expect(msg).not.toContain('console.settingsView'); + }); + + it('zh-CN: the crypto refusal panel heading is translated (#4579 deferred it here)', async () => { + renderInLocale('zh'); + await dirty(1); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + json: async () => ({ + success: false, + error: { + code: 'SETTINGS_CRYPTO_UNAVAILABLE', + message: 'Wire a cryptoProvider.', + details: { namespace: 'localization', key: 'api_key' }, + }, + }), + } as unknown as Response); + + fireEvent.click(await screen.findByText('保存更改')); + + const panel = await screen.findByRole('alert'); + expect(panel.textContent).toMatch(/[一-鿿]/); + expect(panel.textContent).not.toContain('This deployment cannot encrypt secrets'); + // The server's own prescription is rendered VERBATIM — the server owns that + // copy, and a second wording is how the two drift apart. + expect(panel.textContent).toContain('Wire a cryptoProvider.'); + }); +}); diff --git a/packages/components/src/__tests__/mobile-dialog-close-i18n-4024.test.tsx b/packages/components/src/__tests__/mobile-dialog-close-i18n-4024.test.tsx new file mode 100644 index 0000000000..70153ad01d --- /dev/null +++ b/packages/components/src/__tests__/mobile-dialog-close-i18n-4024.test.tsx @@ -0,0 +1,110 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `MobileDialogContent`'s close button speaks the session locale — + * objectui#4024, closing the gap objectstack#5505 left open. + * + * ## This is a REMAINDER, not a fresh bug — and the difference matters + * + * objectstack#5505 already fixed this class: `CloseSrLabel` (`../lib/close-label`) + * resolves `common.close`, and `sheet-dialog-close-i18n.test.tsx` pins it for + * `SheetContent` and `DialogContent`. But #5505 patched the two SHADCN-SYNCED + * primitives under `src/ui/**`, reached by the declared patch in + * `scripts/shadcn-local-patches.mjs`. `custom/mobile-dialog-content.tsx` is not + * in that regeneration zone and carries its own hand-written close button, so + * it kept a hardcoded `< span className="sr-only" >Close< /span >` and no test + * looked at it. + * + * That is exactly the string the card reports. `MobileDialogContent` is what + * `plugin-form`'s `ModalForm` renders (`ModalForm.tsx:834`), so on a zh-CN + * console the create/edit dialog — one of the two screens the card names — is + * precisely where an English "Close" was still being announced. The triage seat + * on objectstack#5084 warned that this family was partly landed and told the + * implementer to reconcile with the landed fixes and "只补余量"; this file is + * the余量, measured rather than assumed. + * + * ## Assertions are accessible-name queries + * + * The button is icon-only (a lucide `X`), so the `sr-only` span IS its + * accessible name — the thing assistive tech announces and the thing + * `getByRole('button', { name })` matches. A `getByText` would pass on + * decoration. + * + * ## Red-first prediction (written BEFORE the first run) + * + * Pre-fix, on `origin/main`: + * - every non-English case FAILS — the literal renders, so the button is + * named "Close" in every locale and `{ name: '关闭' }` matches nothing. + * The `queryByRole({ name: 'Close' })` negative assertions fail in the + * other direction on the very same render, which is the bug stated twice; + * - the en case PASSES pre-fix — the literal equals the `en` pack value. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import * as React from 'react'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { Dialog, DialogTitle, DialogDescription } from '../ui/dialog'; +import { MobileDialogContent } from '../custom/mobile-dialog-content'; + +afterEach(() => cleanup()); + +function body() { + return ( + + + Acme Corp + Record modal + + + ); +} + +function inLocale(language: string) { + return render( + + {body()} + , + ); +} + +describe('MobileDialogContent close button — accessible name (objectui#4024)', () => { + it('reads English under an en session (must-not-change)', () => { + inLocale('en'); + expect(screen.getByRole('button', { name: 'Close' })).toBeTruthy(); + }); + + it('reads the zh bundle value under a zh session', () => { + inLocale('zh'); + expect(screen.getByRole('button', { name: '关闭' })).toBeTruthy(); + // The literal this replaced. Before the fix this query MATCHED under zh — + // that is the whole bug. + expect(screen.queryByRole('button', { name: 'Close' })).toBeNull(); + }); + + it('reads the ja bundle value under a ja session', () => { + inLocale('ja'); + expect(screen.getByRole('button', { name: '閉じる' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Close' })).toBeNull(); + }); + + it('reads the ar bundle value under an ar session', () => { + inLocale('ar'); + expect(screen.queryByRole('button', { name: 'Close' })).toBeNull(); + }); +}); + +/** + * The English no-provider fallback is pinned in a FILE OF ITS OWN — + * `mobile-dialog-close-no-provider-4024.test.tsx`, for the reason + * `sheet-dialog-close-i18n.test.tsx` records: `createI18n` registers a + * react-i18next module-global default that survives `cleanup()`, so a + * no-provider render placed here would resolve against whichever locale ran + * last (it failed naming a button "Cerrar" when they tried it). + */ diff --git a/packages/components/src/__tests__/mobile-dialog-close-no-provider-4024.test.tsx b/packages/components/src/__tests__/mobile-dialog-close-no-provider-4024.test.tsx new file mode 100644 index 0000000000..f7e1b76f63 --- /dev/null +++ b/packages/components/src/__tests__/mobile-dialog-close-no-provider-4024.test.tsx @@ -0,0 +1,50 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `MobileDialogContent`'s close button stays ENGLISH with no provider — + * objectui#4024. + * + * `CloseSrLabel` is built on `createSafeTranslation` precisely so the large + * body of existing coverage that addresses these controls by their English + * name with no `I18nProvider` mounted keeps working — + * `packages/plugin-form/src/discardGuard.test.tsx` drives `ModalForm`, which + * renders this very component, and clicks its close control. + * + * Separate FILE for the reason recorded on `sheet-dialog-close-i18n.test.tsx`: + * `createI18n` registers a react-i18next module-global default that survives + * `cleanup()`, so a no-provider render sharing a file with locale-mounted ones + * resolves against whichever locale ran last. + * + * ## Red-first prediction (written BEFORE the first run) + * + * PASSES before and after — a must-not-change pin. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { Dialog, DialogTitle, DialogDescription } from '../ui/dialog'; +import { MobileDialogContent } from '../custom/mobile-dialog-content'; + +afterEach(() => cleanup()); + +describe('MobileDialogContent close button with no I18nProvider (objectui#4024)', () => { + it('is named "Close", never a raw bundle key', () => { + render( + + + Acme Corp + Record modal + + , + ); + + expect(screen.getByRole('button', { name: 'Close' })).toBeTruthy(); + expect(screen.queryByText('common.close')).toBeNull(); + }); +}); diff --git a/packages/components/src/custom/mobile-dialog-content.tsx b/packages/components/src/custom/mobile-dialog-content.tsx index acdfdf15a5..ed15503b90 100644 --- a/packages/components/src/custom/mobile-dialog-content.tsx +++ b/packages/components/src/custom/mobile-dialog-content.tsx @@ -21,6 +21,7 @@ import * as React from 'react'; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { X } from 'lucide-react'; import { cn } from '../lib/utils'; +import { CloseSrLabel } from '../lib/close-label'; import { DialogOverlay, DialogPortal } from '../ui/dialog'; /** @@ -144,7 +145,15 @@ export const MobileDialogContent = React.forwardRef< )} > - Close + {/* objectui#4024 — the remainder objectstack#5505 could not reach. + That change routed the close label through `CloseSrLabel` for the + two SHADCN-SYNCED primitives under `src/ui/**`, via the declared + patch in `scripts/shadcn-local-patches.mjs`. This file is a + hand-written `custom/` wrapper with its own close button, outside + that regeneration zone, so it kept the English literal — and it is + what `plugin-form`'s `ModalForm` renders, i.e. exactly the + create/edit dialog the card measured. */} + diff --git a/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts b/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts index d765e6407c..9c941313b6 100644 --- a/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts +++ b/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts @@ -95,6 +95,12 @@ const LEGITIMATE_IDENTITIES: Record = { 'marketplace.pricing.freemium': 'Freemium — the pricing-tier term of art; ru keeps the loanword.', 'console.settingsHub.beta': 'Beta — the release-stage badge, kept Latin in zh.', 'console.settingsHub.categories.Beta': 'Beta — the same badge, reached by category name.', + // Pure format string: two holes and a separator, no prose to translate + // (objectui#4024). It exists as a key precisely SO a pack can change the + // separator, and the packs that need to have — zh sets a fullwidth colon, + // fr the French space-before-colon. ja/ko/ru/ar keep `: ` because that IS + // their punctuation here, which is a translation decision, not an omission. + 'grid.summary.pattern': 'Aggregate label/value join — a separator and two holes, no prose.', // Loanwords these packs have adopted as their OWN vocabulary (checked against // their neighbours: zh writes `Logo 链接` / `Logo 已上传…`, ru writes `Email подтверждён`). diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 9282c84a7a..d90360f54e 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -158,6 +158,9 @@ const ar = { submitFailed: "تعذّر الحفظ. يرجى المحاولة مرة أخرى.", discardTitle: "تجاهل التغييرات؟", discardMessage: "لديك تغييرات غير محفوظة. إذا أغلقت هذا النموذج الآن، ستفقد تعديلاتك.", + // objectui#4024 — the create/edit dialog's `sr-only` accessible + // description, used when the form declares no `description` of its own. + dialogDescriptionFallback: "املأ حقول النموذج ثم أرسل أو ألغِ.", keepEditing: "متابعة التحرير", discard: "تجاهل", conflictTitle: "تعارض في الحفظ", @@ -347,6 +350,22 @@ const ar = { yes: "نعم", no: "لا", systemFields: "النظام", + // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's + // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this + // pack controls its own separator and word order. + summary: { + pattern: "{{label}}: {{value}}", + count: "العدد", + countEmpty: "فارغة", + countFilled: "ممتلئة", + countUnique: "فريدة", + percentEmpty: "فارغة", + percentFilled: "ممتلئة", + sum: "المجموع", + avg: "المتوسط", + min: "الأدنى", + max: "الأعلى", + }, toolbar: { densityMode: "الكثافة", densityCompact: "مضغوط", @@ -1574,6 +1593,31 @@ const ar = { reports: "التقارير", system: "النظام", }, + // objectui#4024 — the single-namespace settings screen. Its sibling + // `settingsHub` above already resolved through this pack; the view was the + // one file still carrying English literals. + settingsView: { + backToHub: "جميع الإعدادات", + back: "رجوع", + noNamespace: "لم يتم تحديد أي مساحة اسم.", + loadError: "تعذّر تحميل الإعدادات", + saved: "تم حفظ الإعدادات", + saveFailed: "فشل في الحفظ", + lockedByEnv: "مقفل بواسطة البيئة: {{key}}", + lockedByEnvNoKey: "مقفل بواسطة البيئة", + actionSucceeded: "نجح الإجراء", + actionFailed: "فشل الإجراء", + discard: "تجاهل", + saveChanges: "حفظ التغييرات", + unsavedCount: "{{count}} تغيير(تغييرات) غير محفوظ", + unsavedCount_one: "{{count}} تغيير غير محفوظ", + unsavedCount_other: "{{count}} تغييرات غير محفوظة", + cryptoRefusalTitle: "لا يمكن لهذا النشر تشفير الأسرار", + cryptoRefusalSubjectSuffix: "معلن كمشفَّر، لذلك لم تتم كتابة أي شيء.", + cryptoRefusalNoSubject: "تم رفض القيمة المعلنة كمشفَّرة، لذلك لم تتم كتابة أي شيء.", + cryptoRefusalToast: "تعذّر تشفير الأسرار: {{subject}}", + cryptoRefusalToastNoSubject: "تعذّر تشفير الأسرار", + }, loadingSteps: { connecting: "جاري الاتصال بمصدر البيانات", loadingConfig: "جاري تحميل الإعدادات", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 70fdf871d7..212c19f79a 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -154,6 +154,9 @@ const de = { submitFailed: "Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.", discardTitle: "Änderungen verwerfen?", discardMessage: "Sie haben ungespeicherte Änderungen. Wenn Sie dieses Formular jetzt schließen, gehen Ihre Bearbeitungen verloren.", + // objectui#4024 — the create/edit dialog's `sr-only` accessible + // description, used when the form declares no `description` of its own. + dialogDescriptionFallback: "Füllen Sie die Formularfelder aus und senden Sie ab oder brechen Sie ab.", keepEditing: "Weiter bearbeiten", discard: "Verwerfen", conflictTitle: "Speicherkonflikt", @@ -343,6 +346,22 @@ const de = { yes: "Ja", no: "Nein", systemFields: "System", + // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's + // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this + // pack controls its own separator and word order. + summary: { + pattern: "{{label}}: {{value}}", + count: "Anzahl", + countEmpty: "Leer", + countFilled: "Ausgefüllt", + countUnique: "Eindeutig", + percentEmpty: "Leer", + percentFilled: "Ausgefüllt", + sum: "Summe", + avg: "Mittelwert", + min: "Min.", + max: "Max.", + }, toolbar: { densityMode: "Dichte", densityCompact: "Kompakt", @@ -1567,6 +1586,31 @@ const de = { reports: "Berichte", system: "System", }, + // objectui#4024 — the single-namespace settings screen. Its sibling + // `settingsHub` above already resolved through this pack; the view was the + // one file still carrying English literals. + settingsView: { + backToHub: "Alle Einstellungen", + back: "Zurück", + noNamespace: "Kein Namensraum ausgewählt.", + loadError: "Einstellungen konnten nicht geladen werden", + saved: "Einstellungen gespeichert", + saveFailed: "Speichern fehlgeschlagen", + lockedByEnv: "Durch Umgebung gesperrt: {{key}}", + lockedByEnvNoKey: "Durch Umgebung gesperrt", + actionSucceeded: "Aktion erfolgreich", + actionFailed: "Aktion fehlgeschlagen", + discard: "Verwerfen", + saveChanges: "Änderungen speichern", + unsavedCount: "{{count}} nicht gespeicherte Änderungen", + unsavedCount_one: "{{count}} nicht gespeicherte Änderung", + unsavedCount_other: "{{count}} nicht gespeicherte Änderungen", + cryptoRefusalTitle: "Diese Installation kann keine Geheimnisse verschlüsseln", + cryptoRefusalSubjectSuffix: "ist als verschlüsselt deklariert, daher wurde nichts geschrieben.", + cryptoRefusalNoSubject: "Der als verschlüsselt deklarierte Wert wurde abgelehnt, daher wurde nichts geschrieben.", + cryptoRefusalToast: "Geheimnisse können nicht verschlüsselt werden: {{subject}}", + cryptoRefusalToastNoSubject: "Geheimnisse können nicht verschlüsselt werden", + }, loadingSteps: { connecting: "Verbindung zur Datenquelle herstellen", loadingConfig: "Konfiguration laden", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index f45902a932..30f1627cc7 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -182,6 +182,12 @@ const en = { unsavedChanges: 'You have unsaved changes. Are you sure you want to leave?', discardTitle: 'Discard changes?', discardMessage: 'You have unsaved changes. If you close this form now, your edits will be lost.', + // The create/edit dialog's `sr-only` accessible description, used when the + // form declares no `description` of its own (objectui#4024). Not visible + // copy: it is what assistive tech announces for the dialog. An app could + // only displace it by authoring a `description`, which makes a VISIBLE + // subtitle appear on every form — so the fallback has to come from here. + dialogDescriptionFallback: 'Complete the form fields, then submit or cancel.', keepEditing: 'Keep editing', discard: 'Discard', conflictTitle: 'Save conflict', @@ -362,6 +368,32 @@ const en = { yes: 'Yes', no: 'No', systemFields: 'System', + // Column-footer aggregate prefixes, keyed by the spec's `ColumnSummary` + // vocabulary (objectui#4024). The footer already formatted its NUMBER + // through the locale-aware formatter and then put a hardcoded English + // `Avg: ` / `Sum: ` in front of it. + // + // `pattern` is a key rather than a `': '` baked into the renderer: the + // separator is translatable content — ja/zh set a fullwidth colon, ar runs + // right-to-left — so a pack owns the whole shape. Same reasoning as + // `collaboration.resolvedSuffix` below. + // + // `countEmpty`/`percentEmpty` (and the filled pair) deliberately share a + // word: the trailing `%` is what tells the two families apart on screen, + // exactly as the renderer's own comment says. + summary: { + pattern: '{{label}}: {{value}}', + count: 'Count', + countEmpty: 'Empty', + countFilled: 'Filled', + countUnique: 'Unique', + percentEmpty: 'Empty', + percentFilled: 'Filled', + sum: 'Sum', + avg: 'Avg', + min: 'Min', + max: 'Max', + }, toolbar: { densityMode: 'Density', densityCompact: 'Compact', @@ -1477,6 +1509,58 @@ const en = { Other: 'Other', }, }, + // The single-namespace settings screen (objectui#4024). Placed beside + // `settingsHub` deliberately: `SettingsView.tsx` and `SettingsHub.tsx` are + // the same feature in the same directory, and the view was the one file + // written against a different convention — every string below was a + // hardcoded English literal while its sibling resolved through the bundle. + // + // `useSettingsLabel` already translates the manifest-authored CONTENT + // (title, description, field labels), so a plugin author could translate + // what is INSIDE a settings namespace but had no key reaching the chrome + // around it — a zh-CN admin read translated field labels inside an English + // save bar. + settingsView: { + backToHub: 'All settings', + back: 'Back', + noNamespace: 'No namespace selected.', + loadError: 'Failed to load settings', + saved: 'Settings saved', + saveFailed: 'Save failed', + // Parameterized, never concatenated: `{{key}}` is spliced into the + // sentence so a pack can put the key where its own grammar wants it. + lockedByEnv: 'Locked by environment: {{key}}', + // The server named no key — the same refusal without a subject. + lockedByEnvNoKey: 'Locked by environment', + actionSucceeded: 'Action succeeded', + actionFailed: 'Action failed', + discard: 'Discard', + saveChanges: 'Save changes', + // Save-bar counter. A REAL i18next plural family, not an English-only + // `change(s)` and not the two-sibling-key `xxxCountOne` shape used + // elsewhere in this file. + // + // The BASE key is load-bearing (objectui#3863): i18next asks + // `Intl.PluralRules` for the one suffix a language needs and, finding no + // such slot, walks `fallbackLng` to `en`. `ru` has four categories and + // `ar` six; no pack here enumerates `_few`/`_many`/`_two`/`_zero`, so + // without this base key `ru` would render ENGLISH at counts 2-20. + // `all-locales-key-parity.test.ts` owns that rule. + unsavedCount: '{{count}} unsaved changes', + unsavedCount_one: '{{count}} unsaved change', + unsavedCount_other: '{{count}} unsaved changes', + // The fail-closed crypto refusal (objectstack#8396). objectui#4579 added + // these as English literals on purpose — routing one string through i18n + // would have left a single translated string among a dozen hardcoded + // ones — and deferred them to this card, which converts the screen whole. + cryptoRefusalTitle: 'This deployment cannot encrypt secrets', + // `{{subject}}` is rendered as a `< code >` element by the view, so the + // sentence is split around it rather than interpolated. + cryptoRefusalSubjectSuffix: 'is declared encrypted, so nothing was written.', + cryptoRefusalNoSubject: 'The declared-encrypted value was refused, so nothing was written.', + cryptoRefusalToast: 'Cannot encrypt secrets: {{subject}}', + cryptoRefusalToastNoSubject: 'Cannot encrypt secrets', + }, loadingSteps: { connecting: 'Connecting to data source', loadingConfig: 'Loading configuration', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index b6aa79ec17..825bbc0612 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -158,6 +158,9 @@ const es = { submitFailed: "No se pudo guardar. Inténtalo de nuevo.", discardTitle: "¿Descartar los cambios?", discardMessage: "Tiene cambios sin guardar. Si cierra este formulario ahora, sus ediciones se perderán.", + // objectui#4024 — the create/edit dialog's `sr-only` accessible + // description, used when the form declares no `description` of its own. + dialogDescriptionFallback: "Complete los campos del formulario y luego envíe o cancele.", keepEditing: "Seguir editando", discard: "Descartar", conflictTitle: "Conflicto al guardar", @@ -347,6 +350,22 @@ const es = { yes: "Sí", no: "No", systemFields: "Sistema", + // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's + // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this + // pack controls its own separator and word order. + summary: { + pattern: "{{label}}: {{value}}", + count: "Recuento", + countEmpty: "Vacíos", + countFilled: "Rellenos", + countUnique: "Únicos", + percentEmpty: "Vacíos", + percentFilled: "Rellenos", + sum: "Suma", + avg: "Promedio", + min: "Mín.", + max: "Máx.", + }, toolbar: { densityMode: "Densidad", densityCompact: "Compacto", @@ -1571,6 +1590,31 @@ const es = { reports: "Informes", system: "Sistema", }, + // objectui#4024 — the single-namespace settings screen. Its sibling + // `settingsHub` above already resolved through this pack; the view was the + // one file still carrying English literals. + settingsView: { + backToHub: "Toda la configuración", + back: "Atrás", + noNamespace: "No hay ningún espacio de nombres seleccionado.", + loadError: "No se pudo cargar la configuración", + saved: "Configuración guardada", + saveFailed: "Error al guardar", + lockedByEnv: "Bloqueado por el entorno: {{key}}", + lockedByEnvNoKey: "Bloqueado por el entorno", + actionSucceeded: "La acción se completó correctamente", + actionFailed: "La acción falló", + discard: "Descartar", + saveChanges: "Guardar cambios", + unsavedCount: "{{count}} cambios sin guardar", + unsavedCount_one: "{{count}} cambio sin guardar", + unsavedCount_other: "{{count}} cambios sin guardar", + cryptoRefusalTitle: "Esta instalación no puede cifrar secretos", + cryptoRefusalSubjectSuffix: "está declarado como cifrado, por lo que no se escribió nada.", + cryptoRefusalNoSubject: "El valor declarado como cifrado fue rechazado, por lo que no se escribió nada.", + cryptoRefusalToast: "No se pueden cifrar los secretos: {{subject}}", + cryptoRefusalToastNoSubject: "No se pueden cifrar los secretos", + }, loadingSteps: { connecting: "Conectando a la fuente de datos", loadingConfig: "Cargando configuración", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 99b5ac3e5a..645554a927 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -154,6 +154,9 @@ const fr = { submitFailed: "Échec de l'enregistrement. Veuillez réessayer.", discardTitle: "Abandonner les modifications ?", discardMessage: "Vous avez des modifications non enregistrées. Si vous fermez ce formulaire maintenant, vos modifications seront perdues.", + // objectui#4024 — the create/edit dialog's `sr-only` accessible + // description, used when the form declares no `description` of its own. + dialogDescriptionFallback: "Remplissez les champs du formulaire, puis envoyez ou annulez.", keepEditing: "Continuer l'édition", discard: "Abandonner", conflictTitle: "Conflit d'enregistrement", @@ -343,6 +346,22 @@ const fr = { yes: "Oui", no: "Non", systemFields: "Système", + // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's + // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this + // pack controls its own separator and word order. + summary: { + pattern: "{{label}} : {{value}}", + count: "Nombre", + countEmpty: "Vides", + countFilled: "Remplis", + countUnique: "Uniques", + percentEmpty: "Vides", + percentFilled: "Remplis", + sum: "Somme", + avg: "Moyenne", + min: "Min", + max: "Max", + }, toolbar: { densityMode: "Densité", densityCompact: "Compact", @@ -1569,6 +1588,31 @@ const fr = { reports: "Rapports", system: "Système", }, + // objectui#4024 — the single-namespace settings screen. Its sibling + // `settingsHub` above already resolved through this pack; the view was the + // one file still carrying English literals. + settingsView: { + backToHub: "Tous les paramètres", + back: "Retour", + noNamespace: "Aucun espace de noms sélectionné.", + loadError: "Échec du chargement des paramètres", + saved: "Paramètres enregistrés", + saveFailed: "Échec de l'enregistrement", + lockedByEnv: "Verrouillé par l'environnement : {{key}}", + lockedByEnvNoKey: "Verrouillé par l'environnement", + actionSucceeded: "Action réussie", + actionFailed: "Échec de l'action", + discard: "Abandonner", + saveChanges: "Enregistrer les modifications", + unsavedCount: "{{count}} modifications non enregistrées", + unsavedCount_one: "{{count}} modification non enregistrée", + unsavedCount_other: "{{count}} modifications non enregistrées", + cryptoRefusalTitle: "Ce déploiement ne peut pas chiffrer les secrets", + cryptoRefusalSubjectSuffix: "est déclaré chiffré, aucune écriture n'a donc eu lieu.", + cryptoRefusalNoSubject: "La valeur déclarée chiffrée a été refusée, aucune écriture n'a donc eu lieu.", + cryptoRefusalToast: "Impossible de chiffrer les secrets : {{subject}}", + cryptoRefusalToastNoSubject: "Impossible de chiffrer les secrets", + }, loadingSteps: { connecting: "Connexion à la source de données", loadingConfig: "Chargement de la configuration", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 50625d62de..717c92aec5 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -154,6 +154,9 @@ const ja = { submitFailed: "保存できませんでした。もう一度お試しください。", discardTitle: "変更を破棄しますか?", discardMessage: "保存されていない変更があります。このままフォームを閉じると編集内容は失われます。", + // objectui#4024 — the create/edit dialog's `sr-only` accessible + // description, used when the form declares no `description` of its own. + dialogDescriptionFallback: "フォームの項目を入力してから、送信またはキャンセルしてください。", keepEditing: "編集を続ける", discard: "破棄", conflictTitle: "保存の競合", @@ -343,6 +346,22 @@ const ja = { yes: "はい", no: "いいえ", systemFields: "システム", + // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's + // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this + // pack controls its own separator and word order. + summary: { + pattern: "{{label}}: {{value}}", + count: "件数", + countEmpty: "空欄", + countFilled: "入力済み", + countUnique: "一意", + percentEmpty: "空欄", + percentFilled: "入力済み", + sum: "合計", + avg: "平均", + min: "最小", + max: "最大", + }, toolbar: { densityMode: "密度", densityCompact: "コンパクト", @@ -1567,6 +1586,31 @@ const ja = { reports: "レポート", system: "システム", }, + // objectui#4024 — the single-namespace settings screen. Its sibling + // `settingsHub` above already resolved through this pack; the view was the + // one file still carrying English literals. + settingsView: { + backToHub: "すべての設定", + back: "戻る", + noNamespace: "名前空間が選択されていません。", + loadError: "設定の読み込みに失敗しました", + saved: "設定を保存しました", + saveFailed: "保存に失敗しました", + lockedByEnv: "環境によりロックされています: {{key}}", + lockedByEnvNoKey: "環境によりロックされています", + actionSucceeded: "操作に成功しました", + actionFailed: "操作に失敗しました", + discard: "破棄", + saveChanges: "変更を保存", + unsavedCount: "未保存の変更 {{count}} 件", + unsavedCount_one: "未保存の変更 {{count}} 件", + unsavedCount_other: "未保存の変更 {{count}} 件", + cryptoRefusalTitle: "このデプロイメントはシークレットを暗号化できません", + cryptoRefusalSubjectSuffix: "は暗号化対象として宣言されているため、何も書き込まれませんでした。", + cryptoRefusalNoSubject: "暗号化対象として宣言された値は拒否され、何も書き込まれませんでした。", + cryptoRefusalToast: "シークレットを暗号化できません: {{subject}}", + cryptoRefusalToastNoSubject: "シークレットを暗号化できません", + }, loadingSteps: { connecting: "データソースに接続中", loadingConfig: "設定を読み込み中", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index c6e041189f..e5f89dd894 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -154,6 +154,9 @@ const ko = { submitFailed: "저장하지 못했습니다. 다시 시도해 주세요.", discardTitle: "변경 내용을 버릴까요?", discardMessage: "저장하지 않은 변경 내용이 있습니다. 지금 이 양식을 닫으면 편집 내용이 사라집니다.", + // objectui#4024 — the create/edit dialog's `sr-only` accessible + // description, used when the form declares no `description` of its own. + dialogDescriptionFallback: "양식 필드를 작성한 다음 제출하거나 취소하세요.", keepEditing: "계속 편집", discard: "버리기", conflictTitle: "저장 충돌", @@ -343,6 +346,22 @@ const ko = { yes: "예", no: "아니요", systemFields: "시스템", + // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's + // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this + // pack controls its own separator and word order. + summary: { + pattern: "{{label}}: {{value}}", + count: "개수", + countEmpty: "빈 값", + countFilled: "채워짐", + countUnique: "고유", + percentEmpty: "빈 값", + percentFilled: "채워짐", + sum: "합계", + avg: "평균", + min: "최소", + max: "최대", + }, toolbar: { densityMode: "밀도", densityCompact: "컴팩트", @@ -1567,6 +1586,31 @@ const ko = { reports: "보고서", system: "시스템", }, + // objectui#4024 — the single-namespace settings screen. Its sibling + // `settingsHub` above already resolved through this pack; the view was the + // one file still carrying English literals. + settingsView: { + backToHub: "전체 설정", + back: "뒤로", + noNamespace: "선택된 네임스페이스가 없습니다.", + loadError: "설정을 불러오지 못했습니다", + saved: "설정이 저장되었습니다", + saveFailed: "저장에 실패했습니다", + lockedByEnv: "환경에 의해 잠김: {{key}}", + lockedByEnvNoKey: "환경에 의해 잠김", + actionSucceeded: "작업 성공", + actionFailed: "작업 실패", + discard: "버리기", + saveChanges: "변경사항 저장", + unsavedCount: "저장되지 않은 변경 {{count}}개", + unsavedCount_one: "저장되지 않은 변경 {{count}}개", + unsavedCount_other: "저장되지 않은 변경 {{count}}개", + cryptoRefusalTitle: "이 배포는 시크릿을 암호화할 수 없습니다", + cryptoRefusalSubjectSuffix: "은(는) 암호화 대상으로 선언되어 아무것도 기록되지 않았습니다.", + cryptoRefusalNoSubject: "암호화 대상으로 선언된 값이 거부되어 아무것도 기록되지 않았습니다.", + cryptoRefusalToast: "시크릿을 암호화할 수 없습니다: {{subject}}", + cryptoRefusalToastNoSubject: "시크릿을 암호화할 수 없습니다", + }, loadingSteps: { connecting: "데이터 소스에 연결 중", loadingConfig: "설정 로드 중", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 5b007d7c61..b57355fbd8 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -153,6 +153,9 @@ const pt = { submitFailed: "Não foi possível salvar. Tente novamente.", discardTitle: "Descartar as alterações?", discardMessage: "Você tem alterações não salvas. Se fechar este formulário agora, suas edições serão perdidas.", + // objectui#4024 — the create/edit dialog's `sr-only` accessible + // description, used when the form declares no `description` of its own. + dialogDescriptionFallback: "Preencha os campos do formulário e depois envie ou cancele.", keepEditing: "Continuar editando", discard: "Descartar", conflictTitle: "Conflito ao salvar", @@ -342,6 +345,22 @@ const pt = { yes: "Sim", no: "Não", systemFields: "Sistema", + // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's + // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this + // pack controls its own separator and word order. + summary: { + pattern: "{{label}}: {{value}}", + count: "Contagem", + countEmpty: "Vazios", + countFilled: "Preenchidos", + countUnique: "Únicos", + percentEmpty: "Vazios", + percentFilled: "Preenchidos", + sum: "Soma", + avg: "Média", + min: "Mín.", + max: "Máx.", + }, toolbar: { densityMode: "Densidade", densityCompact: "Compacto", @@ -1566,6 +1585,31 @@ const pt = { reports: "Relatórios", system: "Sistema", }, + // objectui#4024 — the single-namespace settings screen. Its sibling + // `settingsHub` above already resolved through this pack; the view was the + // one file still carrying English literals. + settingsView: { + backToHub: "Todas as configurações", + back: "Voltar", + noNamespace: "Nenhum namespace selecionado.", + loadError: "Falha ao carregar as configurações", + saved: "Configurações salvas", + saveFailed: "Falha ao salvar", + lockedByEnv: "Bloqueado pelo ambiente: {{key}}", + lockedByEnvNoKey: "Bloqueado pelo ambiente", + actionSucceeded: "A ação foi concluída", + actionFailed: "A ação falhou", + discard: "Descartar", + saveChanges: "Salvar alterações", + unsavedCount: "{{count}} alterações não salvas", + unsavedCount_one: "{{count}} alteração não salva", + unsavedCount_other: "{{count}} alterações não salvas", + cryptoRefusalTitle: "Esta implantação não consegue criptografar segredos", + cryptoRefusalSubjectSuffix: "está declarado como criptografado, portanto nada foi gravado.", + cryptoRefusalNoSubject: "O valor declarado como criptografado foi recusado, portanto nada foi gravado.", + cryptoRefusalToast: "Não é possível criptografar segredos: {{subject}}", + cryptoRefusalToastNoSubject: "Não é possível criptografar segredos", + }, loadingSteps: { connecting: "Conectando à fonte de dados", loadingConfig: "Carregando configuração", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 9a70a5e2b6..c306c6d3fe 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -160,6 +160,9 @@ const ru = { submitFailed: "Не удалось сохранить. Попробуйте ещё раз.", discardTitle: "Отменить изменения?", discardMessage: "Есть несохранённые изменения. Если закрыть форму сейчас, правки будут потеряны.", + // objectui#4024 — the create/edit dialog's `sr-only` accessible + // description, used when the form declares no `description` of its own. + dialogDescriptionFallback: "Заполните поля формы, затем отправьте или отмените.", keepEditing: "Продолжить редактирование", discard: "Отменить", conflictTitle: "Конфликт сохранения", @@ -349,6 +352,22 @@ const ru = { yes: "Да", no: "Нет", systemFields: "Система", + // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's + // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this + // pack controls its own separator and word order. + summary: { + pattern: "{{label}}: {{value}}", + count: "Количество", + countEmpty: "Пустые", + countFilled: "Заполненные", + countUnique: "Уникальные", + percentEmpty: "Пустые", + percentFilled: "Заполненные", + sum: "Сумма", + avg: "Среднее", + min: "Мин.", + max: "Макс.", + }, toolbar: { densityMode: "Плотность", densityCompact: "Компактный", @@ -1577,6 +1596,31 @@ const ru = { reports: "Отчёты", system: "Система", }, + // objectui#4024 — the single-namespace settings screen. Its sibling + // `settingsHub` above already resolved through this pack; the view was the + // one file still carrying English literals. + settingsView: { + backToHub: "Все настройки", + back: "Назад", + noNamespace: "Пространство имён не выбрано.", + loadError: "Не удалось загрузить настройки", + saved: "Настройки сохранены", + saveFailed: "Ошибка сохранения", + lockedByEnv: "Заблокировано окружением: {{key}}", + lockedByEnvNoKey: "Заблокировано окружением", + actionSucceeded: "Действие выполнено", + actionFailed: "Действие не выполнено", + discard: "Отменить", + saveChanges: "Сохранить изменения", + unsavedCount: "Несохранённых изменений: {{count}}", + unsavedCount_one: "{{count}} несохранённое изменение", + unsavedCount_other: "{{count}} несохранённых изменений", + cryptoRefusalTitle: "Эта установка не может шифровать секреты", + cryptoRefusalSubjectSuffix: "объявлен зашифрованным, поэтому ничего не было записано.", + cryptoRefusalNoSubject: "Значение, объявленное зашифрованным, было отклонено, поэтому ничего не было записано.", + cryptoRefusalToast: "Не удалось зашифровать секреты: {{subject}}", + cryptoRefusalToastNoSubject: "Не удалось зашифровать секреты", + }, loadingSteps: { connecting: "Подключение к источнику данных", loadingConfig: "Загрузка конфигурации", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 6bcbf18e22..deb96654a7 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -168,6 +168,9 @@ const zh = { unsavedChanges: '您有未保存的更改,确定要离开吗?', discardTitle: '放弃更改?', discardMessage: '您有未保存的更改。如果现在关闭此表单,您的编辑将会丢失。', + // objectui#4024 — the create/edit dialog's `sr-only` accessible + // description, used when the form declares no `description` of its own. + dialogDescriptionFallback: '填写表单字段,然后提交或取消。', keepEditing: '继续编辑', discard: '放弃', conflictTitle: '保存冲突', @@ -325,6 +328,22 @@ const zh = { yes: '是', no: '否', systemFields: '系统字段', + // objectui#4024 — column-footer aggregate prefixes, keyed by the spec's + // `ColumnSummary` vocabulary. `pattern` owns the label/value join so this + // pack controls its own separator and word order. + summary: { + pattern: '{{label}}:{{value}}', + count: '计数', + countEmpty: '空值', + countFilled: '非空', + countUnique: '去重', + percentEmpty: '空值', + percentFilled: '非空', + sum: '合计', + avg: '平均值', + min: '最小值', + max: '最大值', + }, toolbar: { densityMode: '密度', densityCompact: '紧凑', @@ -1411,6 +1430,31 @@ const zh = { Other: '其他', }, }, + // objectui#4024 — the single-namespace settings screen. Its sibling + // `settingsHub` above already resolved through this pack; the view was the + // one file still carrying English literals. + settingsView: { + backToHub: '全部设置', + back: '返回', + noNamespace: '未选择命名空间。', + loadError: '加载设置失败', + saved: '设置已保存', + saveFailed: '保存失败', + lockedByEnv: '已被环境变量锁定:{{key}}', + lockedByEnvNoKey: '已被环境变量锁定', + actionSucceeded: '操作成功', + actionFailed: '操作失败', + discard: '放弃', + saveChanges: '保存更改', + unsavedCount: '{{count}} 项未保存的更改', + unsavedCount_one: '{{count}} 项未保存的更改', + unsavedCount_other: '{{count}} 项未保存的更改', + cryptoRefusalTitle: '此部署无法加密机密信息', + cryptoRefusalSubjectSuffix: '被声明为加密字段,因此未写入任何内容。', + cryptoRefusalNoSubject: '声明为加密的值被拒绝,因此未写入任何内容。', + cryptoRefusalToast: '无法加密机密信息:{{subject}}', + cryptoRefusalToastNoSubject: '无法加密机密信息', + }, loadingSteps: { connecting: '正在连接数据源', loadingConfig: '正在加载配置', diff --git a/packages/plugin-form/src/DrawerForm.tsx b/packages/plugin-form/src/DrawerForm.tsx index 74129b638d..e1c113a3cf 100644 --- a/packages/plugin-form/src/DrawerForm.tsx +++ b/packages/plugin-form/src/DrawerForm.tsx @@ -67,6 +67,12 @@ const useDiscardTranslation = createSafeTranslation( { 'form.discardTitle': 'Discard changes?', 'form.discardMessage': 'You have unsaved changes. If you close this form now, your edits will be lost.', + // The dialog's `sr-only` accessible description, used when the form + // declares no `description` (objectui#4024). It rides this table rather + // than a bare `useObjectTranslation` so a provider-less host announces the + // English sentence instead of a raw key — the #4514 trap, and the reason + // this factory exists. + 'form.dialogDescriptionFallback': 'Complete the form fields, then submit or cancel.', 'form.keepEditing': 'Keep editing', 'form.discard': 'Discard', }, @@ -639,7 +645,7 @@ export const DrawerForm: React.FC = ({ {schema.description} ) : ( - Complete the form fields, then submit or cancel. + {t('form.dialogDescriptionFallback')} )} diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index 60c655a48c..854146e572 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -65,6 +65,12 @@ const useDiscardTranslation = createSafeTranslation( { 'form.discardTitle': 'Discard changes?', 'form.discardMessage': 'You have unsaved changes. If you close this form now, your edits will be lost.', + // The dialog's `sr-only` accessible description, used when the form + // declares no `description` (objectui#4024). It rides this table rather + // than a bare `useObjectTranslation` so a provider-less host announces the + // English sentence instead of a raw key — the #4514 trap, and the reason + // this factory exists. + 'form.dialogDescriptionFallback': 'Complete the form fields, then submit or cancel.', 'form.keepEditing': 'Keep editing', 'form.discard': 'Discard', }, @@ -839,7 +845,7 @@ export const ModalForm: React.FC = ({ {schema.description} ) : ( - Complete the form fields, then submit or cancel. + {t('form.dialogDescriptionFallback')} )} diff --git a/packages/plugin-form/src/dialogDescriptionFallback.i18n-4024.no-provider.test.tsx b/packages/plugin-form/src/dialogDescriptionFallback.i18n-4024.no-provider.test.tsx new file mode 100644 index 0000000000..87a4b7c64b --- /dev/null +++ b/packages/plugin-form/src/dialogDescriptionFallback.i18n-4024.no-provider.test.tsx @@ -0,0 +1,78 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The form dialog's description fallback stays ENGLISH with no provider — + * objectui#4024, and the #4514 trap. + * + * `ModalForm`/`DrawerForm` already own a `createSafeTranslation` table + * (`useDiscardTranslation`), and the fallback sentence joins it rather than + * arriving through a bare `useObjectTranslation` — so a provider-less host + * announces the English sentence instead of `form.dialogDescriptionFallback`. + * + * Separate FILE, not a separate `it`: `createI18n` registers a react-i18next + * module-global default that survives `cleanup()`, so a no-provider case + * sharing a file with locale-mounted renders resolves against whichever locale + * ran last (objectstack#5505/#5506). + * + * ## Red-first prediction (written BEFORE the first run) + * + * PASSES before and after — a must-not-change pin. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { registerAllFields } from '@object-ui/fields'; +import { ModalForm } from './ModalForm'; +import { DrawerForm } from './DrawerForm'; + +registerAllFields(); + +afterEach(() => cleanup()); + +const ds: any = { + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'task', + fields: { title: { type: 'text', label: 'Title' } }, + }), + create: vi.fn().mockResolvedValue({ id: '1' }), + update: vi.fn(), + findOne: vi.fn(), + find: vi.fn().mockResolvedValue([]), +}; + +const EN_FALLBACK = 'Complete the form fields, then submit or cancel.'; + +async function accessibleDescription(): Promise { + const dialog = await screen.findByRole('dialog'); + const ids = (dialog.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean); + return ids + .map((id) => dialog.ownerDocument.getElementById(id)?.textContent ?? '') + .join(' ') + .trim(); +} + +const schema = { + objectName: 'task', + mode: 'create', + open: true, + title: 'New task', + onOpenChange: vi.fn(), +} as any; + +describe('form dialog description fallback with no I18nProvider (objectui#4024 / #4514)', () => { + it('modal announces the English sentence, never a raw bundle key', async () => { + render(); + await waitFor(async () => expect(await accessibleDescription()).toBe(EN_FALLBACK)); + }); + + it('drawer announces the English sentence, never a raw bundle key', async () => { + render(); + await waitFor(async () => expect(await accessibleDescription()).toBe(EN_FALLBACK)); + }); +}); diff --git a/packages/plugin-form/src/dialogDescriptionFallback.i18n-4024.test.tsx b/packages/plugin-form/src/dialogDescriptionFallback.i18n-4024.test.tsx new file mode 100644 index 0000000000..0ac3f0abe3 --- /dev/null +++ b/packages/plugin-form/src/dialogDescriptionFallback.i18n-4024.test.tsx @@ -0,0 +1,156 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The create/edit dialog's `sr-only` description FALLBACK speaks the session + * locale — objectui#4024 (migrated from objectstack#5084). + * + * `ModalForm.tsx:842` and `DrawerForm.tsx:642` emitted + * "Complete the form fields, then submit or cancel." as a bare English literal + * whenever the form declares no `description`. The card's field report is + * careful about what this string is: it verified the text is CLIPPED + * (`sr-only`), not a visible subtitle — so it is what a screen reader + * announces as the dialog's accessible description and nothing else. + * + * That is exactly why an app cannot work around it. The only way to displace + * the fallback is to author a `description`, which makes a VISIBLE subtitle + * appear on every dialog. Adding visible UI to every form in order to remove an + * invisible untranslated string is not a trade an app should have to make. + * + * ## Assertions go through the accessible description, not `getByText` + * + * The string's whole job is to be announced, so the test asks the same question + * assistive tech asks: what is this dialog's accessible description? A + * `getByText` would pass just as well on a visible paragraph, which is the bug + * this fallback exists to avoid. + * + * ## Red-first prediction (written BEFORE the first run) + * + * Pre-fix, on `origin/main`: + * - both zh cases FAIL — the literal is rendered verbatim, so the dialog's + * accessible description is the English sentence and the expected Chinese + * one is absent; + * - the "authored description wins" case and the no-provider case PASS + * pre-fix and after — the fallback only ever applies when the form declares + * nothing, and the provider-less path must keep rendering English. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as React from 'react'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { registerAllFields } from '@object-ui/fields'; +import { ModalForm } from './ModalForm'; +import { DrawerForm } from './DrawerForm'; + +registerAllFields(); + +afterEach(() => cleanup()); + +const ds: any = { + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'task', + fields: { title: { type: 'text', label: 'Title' } }, + }), + create: vi.fn().mockResolvedValue({ id: '1' }), + update: vi.fn(), + findOne: vi.fn(), + find: vi.fn().mockResolvedValue([]), +}; + +const EN_FALLBACK = 'Complete the form fields, then submit or cancel.'; +const ZH_FALLBACK = '填写表单字段,然后提交或取消。'; + +function inLocale(language: string, body: React.ReactElement) { + return render( + + {body} + , + ); +} + +/** + * The dialog's accessible description, resolved the way a screen reader does: + * `aria-describedby` on the `dialog` node, dereferenced. + * + * Written by hand rather than via `toHaveAccessibleDescription` so the failure + * message names the string actually found. + */ +async function accessibleDescription(): Promise { + const dialog = await screen.findByRole('dialog'); + const ids = (dialog.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean); + return ids + .map((id) => dialog.ownerDocument.getElementById(id)?.textContent ?? '') + .join(' ') + .trim(); +} + +const modal = (description?: string) => ( + +); + +const drawer = (description?: string) => ( + +); + +describe('form dialog sr-only description fallback resolves from the bundle (objectui#4024)', () => { + it('zh-CN modal: the accessible description is the translated fallback', async () => { + inLocale('zh', modal()); + await waitFor(async () => expect(await accessibleDescription()).toBe(ZH_FALLBACK)); + }); + + it('zh-CN drawer: the accessible description is the translated fallback', async () => { + inLocale('zh', drawer()); + await waitFor(async () => expect(await accessibleDescription()).toBe(ZH_FALLBACK)); + }); + + it('en modal: unchanged English sentence (must-not-change)', async () => { + inLocale('en', modal()); + await waitFor(async () => expect(await accessibleDescription()).toBe(EN_FALLBACK)); + }); + + it('an authored description still wins over the fallback, in any locale', async () => { + inLocale('zh', modal('Only the fields marked required.')); + await waitFor(async () => + expect(await accessibleDescription()).toBe('Only the fields marked required.'), + ); + }); +}); + +/** + * The English no-provider fallback is pinned in a FILE OF ITS OWN — + * `dialogDescriptionFallback.i18n-4024.no-provider.test.tsx`. `createI18n` + * registers a react-i18next module-global default that survives `cleanup()`, so + * a no-provider case sharing this file would resolve against whichever locale + * ran last (objectstack#5505/#5506, recorded on + * `packages/components/src/__tests__/sheet-dialog-close-i18n.test.tsx`). + */ diff --git a/packages/plugin-grid/src/useColumnSummary.i18n-4024.no-provider.test.tsx b/packages/plugin-grid/src/useColumnSummary.i18n-4024.no-provider.test.tsx new file mode 100644 index 0000000000..f5a4ec7346 --- /dev/null +++ b/packages/plugin-grid/src/useColumnSummary.i18n-4024.no-provider.test.tsx @@ -0,0 +1,61 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The aggregate footer's provider-less fallback stays ENGLISH — objectui#4024, + * and the #4514 trap. + * + * `useColumnSummary` is a PUBLIC export of `@object-ui/plugin-grid` + * (`src/index.tsx:35`) and its sibling suite `useColumnSummary.test.tsx` asserts + * `/Sum: /` with only a `LocalizationProvider` mounted — no i18n provider at + * all. Routing the prefix through `createSafeTranslation` rather than + * `useObjectTranslation` is what keeps that suite, and every downstream + * consumer's provider-less test, rendering English instead of + * `grid.summary.sum`. + * + * Separate FILE, not a separate `it`: `createI18n` registers a react-i18next + * module-global default that survives `cleanup()`, so a no-provider case + * sharing a file with locale-mounted renders resolves against whichever locale + * ran last (objectstack#5505/#5506). + * + * ## Red-first prediction (written BEFORE the first run) + * + * PASSES before and after — a must-not-change pin. It goes red only if the fix + * reaches for `useObjectTranslation` directly. + */ + +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useColumnSummary } from './useColumnSummary'; + +const DATA = [{ amount: 1000 }, { amount: 234 }, { amount: null }]; + +describe('aggregate footer with no I18nProvider (objectui#4024 / #4514)', () => { + it('renders the English prefix and separator, never a raw bundle key', () => { + const cols: any[] = [{ field: 'amount', summary: 'sum' }]; + const { result } = renderHook(() => useColumnSummary(cols, DATA)); + const label = result.current.summaries.get('amount')?.label ?? ''; + + expect(label).toBe('Sum: 1,234'); + expect(label).not.toContain('grid.summary'); + }); + + it('every kind keeps an English prefix with no provider', () => { + for (const [kind, expected] of [ + ['avg', 'Avg: 617'], + ['min', 'Min: 234'], + ['max', 'Max: 1,000'], + ['count', 'Count: 3'], + ['count_unique', 'Unique: 2'], + ] as const) { + const cols: any[] = [{ field: 'amount', summary: kind }]; + const { result } = renderHook(() => useColumnSummary(cols, DATA)); + expect(result.current.summaries.get('amount')?.label).toBe(expected); + } + }); +}); diff --git a/packages/plugin-grid/src/useColumnSummary.i18n-4024.test.tsx b/packages/plugin-grid/src/useColumnSummary.i18n-4024.test.tsx new file mode 100644 index 0000000000..385caab1e8 --- /dev/null +++ b/packages/plugin-grid/src/useColumnSummary.i18n-4024.test.tsx @@ -0,0 +1,148 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The list/grid aggregate footer's PREFIX speaks the session locale — + * objectui#4024 (migrated from objectstack#5084). + * + * The card's sharpest observation is that this footer was already half fixed: + * `Avg: 39%` and `Sum: 119,200` have a correctly locale-formatted NUMBER sitting + * behind a hardcoded English `TYPE_LABELS` prefix. The aggregate kind is known + * to the renderer, so the prefix was one bundle lookup away. + * + * ## The number formatting is NOT this card's surface + * + * `#4589` landed the number-display policy in `packages/core/src/utils/**`, and + * the currency/percent/decimal behaviour here is pinned by the sibling + * `useColumnSummary.test.tsx`. This change touches the PREFIX and the + * label/value JOIN only; every numeric assertion below is a must-not-change, + * restated here so a prefix change that disturbed a number would go red in the + * same file that claims the prefix. + * + * ## Why a parameterized pattern rather than a translated prefix + `': '` + * + * Concatenating a translated word with a hardcoded `': '` hands the locale the + * word and keeps the punctuation in English. ja/zh use a fullwidth colon, and + * ar runs right-to-left, so the separator is translatable content. The join is + * therefore its own key — `grid.summary.pattern: '{{label}}: {{value}}'` — and + * a pack that wants 「合计:119,200」 owns the whole shape. This mirrors the + * `collaboration.resolvedSuffix` note already in `en.ts`: "Appended to the + * count, separator included, so a translator owns the whole phrase rather than + * inheriting an English-shaped ` · ` glue." + * + * ## Red-first prediction (written BEFORE the first run) + * + * Pre-fix, on `origin/main`: + * - every zh case FAILS — `TYPE_LABELS[type]` returns the English 'Sum' / + * 'Avg' / 'Count' and `formatSummaryLabel` joins with a literal ': ', so + * the label is 'Sum: 1,234' and the expected '合计' is absent; + * - the en cases and the must-not-change numeric cases PASS pre-fix (the + * English literal equals the `en` pack value — the same blindness the + * switcher has). + */ + +import { describe, it, expect } from 'vitest'; +import * as React from 'react'; +import { renderHook } from '@testing-library/react'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { useColumnSummary } from './useColumnSummary'; + +const DATA = [{ amount: 1000 }, { amount: 234 }, { amount: null }]; + +function inLocale(language: string) { + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); +} + +function labelFor(summary: string, language: string, extra: Record = {}): string { + const cols: any[] = [{ field: 'amount', summary, ...extra }]; + const { result } = renderHook(() => useColumnSummary(cols, DATA), { + wrapper: inLocale(language), + }); + return result.current.summaries.get('amount')?.label ?? ''; +} + +describe('aggregate footer prefix resolves from the bundle (objectui#4024)', () => { + it('zh-CN: sum / avg / min / max carry translated prefixes', () => { + expect(labelFor('sum', 'zh')).toContain('合计'); + expect(labelFor('avg', 'zh')).toContain('平均值'); + expect(labelFor('min', 'zh')).toContain('最小值'); + expect(labelFor('max', 'zh')).toContain('最大值'); + }); + + it('zh-CN: the count family lands too — the card asked for all five kinds', () => { + expect(labelFor('count', 'zh')).toContain('计数'); + expect(labelFor('count_empty', 'zh')).toContain('空值'); + expect(labelFor('count_filled', 'zh')).toContain('非空'); + expect(labelFor('count_unique', 'zh')).toContain('去重'); + }); + + it('zh-CN: the percent family lands, and keeps its own % unit', () => { + const empty = labelFor('percent_empty', 'zh'); + expect(empty).toContain('空值'); + expect(empty).toMatch(/%/); + }); + + it('ja-JP: a second pack proves this is a bundle lookup, not a zh special case', () => { + expect(labelFor('sum', 'ja')).toContain('合計'); + }); + + it('zh-CN: no English prefix survives anywhere in the label', () => { + for (const kind of ['sum', 'avg', 'min', 'max', 'count', 'count_unique']) { + expect(labelFor(kind, 'zh')).not.toMatch(/Sum|Avg|Min|Max|Count|Unique/); + } + }); + + it('en: unchanged prefixes and separator (must-not-change)', () => { + expect(labelFor('sum', 'en')).toBe('Sum: 1,234'); + expect(labelFor('avg', 'en')).toBe('Avg: 617'); + expect(labelFor('count', 'en')).toBe('Count: 3'); + expect(labelFor('count_unique', 'en')).toBe('Unique: 2'); + }); + + it('the NUMBER keeps flowing through the existing formatter (must-not-change, #4589)', () => { + // Currency column, tenant default CNY — pinned by useColumnSummary.test.tsx; + // restated here so a prefix change that disturbed the number goes red in + // the file that claims the prefix. + const cols: any[] = [{ field: 'amount', summary: 'sum', type: 'currency' }]; + const { result } = renderHook(() => useColumnSummary(cols, DATA), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + const label = result.current.summaries.get('amount')?.label ?? ''; + expect(label).toContain('合计'); + expect(label).toMatch(/[¥]|CN¥|CNY/); + expect(label).toMatch(/1,234/); + }); + + it('an unknown aggregation still renders its raw kind, not a bundle key', () => { + // `SUPPORTED_SUMMARY_TYPES` gates the map, so an unsupported name never + // reaches the label — but the `|| type` arm inside the formatter is the + // backstop, and it must not start echoing `grid.summary.< junk >`. + const cols: any[] = [{ field: 'amount', summary: 'sum' }]; + const { result } = renderHook(() => useColumnSummary(cols, DATA), { + wrapper: inLocale('en'), + }); + expect(result.current.summaries.get('amount')?.label).not.toContain('grid.summary'); + }); +}); + +/** + * The English no-provider fallback is pinned in a FILE OF ITS OWN — + * `useColumnSummary.i18n-4024.no-provider.test.tsx`. `createI18n` registers a + * react-i18next module-global default that survives `cleanup()`, so a + * no-provider case sharing this file would resolve against whichever locale ran + * last (objectstack#5505/#5506, recorded on + * `packages/components/src/__tests__/sheet-dialog-close-i18n.test.tsx`). + */ diff --git a/packages/plugin-grid/src/useColumnSummary.ts b/packages/plugin-grid/src/useColumnSummary.ts index b2868c0e26..5cd4bfc979 100644 --- a/packages/plugin-grid/src/useColumnSummary.ts +++ b/packages/plugin-grid/src/useColumnSummary.ts @@ -9,7 +9,7 @@ import { useMemo } from 'react'; import type { ListColumn } from '@object-ui/types'; import type { ColumnSummary } from '@objectstack/spec/ui'; -import { useLocalization, resolveFieldCurrency } from '@object-ui/i18n'; +import { useLocalization, resolveFieldCurrency, createSafeTranslation } from '@object-ui/i18n'; /** * Aggregation functions for the column footer — the spec's `ColumnSummary` @@ -18,10 +18,10 @@ import { useLocalization, resolveFieldCurrency } from '@object-ui/i18n'; * * The eleven members used to be spelled out here under a comment promising they * were "kept in lockstep with the spec enum" — a promise nothing enforced. They - * are the enum now, which turns `TYPE_LABELS` below into the thing that reports - * a divergence: it is a total `Record`, so a member - * the spec adds is a compile error naming the missing label instead of a footer - * cell that renders blank. + * are the enum now, which turns `TYPE_LABEL_KEYS` below into the thing that + * reports a divergence: it is a total `Record`, so a + * member the spec adds is a compile error naming the missing label key instead + * of a footer cell that renders blank. */ export type ColumnSummaryType = ColumnSummary; @@ -67,31 +67,81 @@ const NON_NUMERIC_TYPES = new Set([ /** Aggregations whose result is a percentage (0-100), not a value in the column's unit. */ const PERCENT_TYPES = new Set(['percent_empty', 'percent_filled']); -const TYPE_LABELS: Record = { +/** + * Bundle key per aggregation — objectui#4024. + * + * The footer's NUMBER was already locale-formatted while the PREFIX in front of + * it was a hardcoded English literal, so a zh-CN console read `Avg: 39%` with a + * correctly formatted `39%`. The aggregate kind is known here, so the prefix was + * one bundle lookup away. + * + * Still a total `Record`, which is the property the + * header above describes: a member the spec adds is a compile error naming the + * missing key, rather than a footer cell that renders blank. + * + * `none` maps to the empty string, not to a key — it is the spec's explicit + * opt-out and never reaches a label (`useColumnSummary` skips it), so giving it + * a key would put an unreachable entry in ten packs. + */ +const TYPE_LABEL_KEYS: Record = { none: '', - count: 'Count', - count_empty: 'Empty', - count_filled: 'Filled', - count_unique: 'Unique', - // The trailing `%` is what distinguishes these from the count-family pair. - percent_empty: 'Empty', - percent_filled: 'Filled', - sum: 'Sum', - avg: 'Avg', - min: 'Min', - max: 'Max', + count: 'grid.summary.count', + count_empty: 'grid.summary.countEmpty', + count_filled: 'grid.summary.countFilled', + count_unique: 'grid.summary.countUnique', + // The trailing `%` is what distinguishes these from the count-family pair — + // which is why the two families deliberately share a word in every pack. + percent_empty: 'grid.summary.percentEmpty', + percent_filled: 'grid.summary.percentFilled', + sum: 'grid.summary.sum', + avg: 'grid.summary.avg', + min: 'grid.summary.min', + max: 'grid.summary.max', }; +/** + * English fallbacks for the provider-less path (the objectui#4514 trap). + * + * `useColumnSummary` is a PUBLIC export of this package and its own sibling + * suite (`useColumnSummary.test.tsx`) asserts `/Sum: /` with only a + * `LocalizationProvider` mounted. `createSafeTranslation` is what keeps that — + * and every downstream consumer's provider-less test — rendering English + * instead of `grid.summary.sum`. + */ +const SUMMARY_DEFAULT_TRANSLATIONS: Record = { + 'grid.summary.pattern': '{{label}}: {{value}}', + 'grid.summary.count': 'Count', + 'grid.summary.countEmpty': 'Empty', + 'grid.summary.countFilled': 'Filled', + 'grid.summary.countUnique': 'Unique', + 'grid.summary.percentEmpty': 'Empty', + 'grid.summary.percentFilled': 'Filled', + 'grid.summary.sum': 'Sum', + 'grid.summary.avg': 'Avg', + 'grid.summary.min': 'Min', + 'grid.summary.max': 'Max', +}; + +const useSummaryTranslation = createSafeTranslation( + SUMMARY_DEFAULT_TRANSLATIONS, + 'grid.summary.sum', +); + +/** The translator shape `formatSummaryLabel` needs — i18next's `t`, narrowed. */ +type SummaryTranslate = (key: string, options?: Record) => string; + /** * Every aggregation name the renderer computes. A Set rather than an `in` check - * against TYPE_LABELS, because `in` also matches inherited keys — a column + * against TYPE_LABEL_KEYS, because `in` also matches inherited keys — a column * configured with `summary: 'toString'` would otherwise read as supported. * * Exported so a test can assert it covers the spec's `ColumnSummarySchema` * exactly: a name the schema accepts but this set omits validates at authoring * time and then renders a blank footer cell. */ -export const SUPPORTED_SUMMARY_TYPES: ReadonlySet = new Set(Object.keys(TYPE_LABELS)); +export const SUPPORTED_SUMMARY_TYPES: ReadonlySet = new Set( + Object.keys(TYPE_LABEL_KEYS), +); /** * Emptiness test for the count/percent aggregations. Matches the convention @@ -217,21 +267,41 @@ function computeAggregation(type: string, rows: SummaryRow[], field: string): nu * aggregations are plain cardinalities and percent aggregations carry their own * unit, so neither may inherit the column's currency or percent formatting — * `count_unique` on a currency column reads "3", not "$3.00". + * + * ## The label/value JOIN is a bundle key too (objectui#4024) + * + * The three arms below used to build `` `${label}: ${formatted}` ``, which hands + * the locale the WORD and keeps the PUNCTUATION in English. ja/zh set a + * fullwidth colon and ar runs right-to-left, so the separator is translatable + * content: `grid.summary.pattern` owns the whole shape and a pack is free to + * spell it 「合计:119,200」. Same reasoning as `collaboration.resolvedSuffix` + * in the packs — "separator included, so a translator owns the whole phrase + * rather than inheriting an English-shaped glue". + * + * The NUMBER is untouched: every `toLocaleString` / `Intl.NumberFormat` call + * below is exactly as it was, and stays #4589's surface rather than this + * card's. */ function formatSummaryLabel( type: string, value: number | null, + t: SummaryTranslate, column?: { type?: string; currency?: string; defaultCurrency?: string; currencyConfig?: { defaultCurrency?: string }; precision?: number | null; scale?: number | null }, tenantDefault?: string, ): string { if (value === null) return ''; - const label = TYPE_LABELS[type as ColumnSummaryType] || type; + const labelKey = TYPE_LABEL_KEYS[type as ColumnSummaryType]; + // `|| type` is the backstop for an aggregation with no key — it echoes the + // raw kind, as before, never a half-resolved `grid.summary.`. + const label = labelKey ? t(labelKey) : type; + const join = (formatted: string): string => + t('grid.summary.pattern', { label, value: formatted }); if (PERCENT_TYPES.has(type)) { - return `${label}: ${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}%`; + return join(`${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}%`); } if (NON_NUMERIC_TYPES.has(type)) { - return `${label}: ${value.toLocaleString()}`; + return join(value.toLocaleString()); } const colType = column?.type; @@ -266,7 +336,7 @@ function formatSummaryLabel( } else { formatted = value.toLocaleString(); } - return `${label}: ${formatted}`; + return join(formatted); } /** @@ -287,6 +357,9 @@ export function useColumnSummary( // Tenant default currency (ADR-0053) backstops a currency column that // declares no explicit code, so the footer agrees with the cells above it. const { currency: tenantCurrency } = useLocalization(); + // Aggregate-prefix bundle lookups (objectui#4024). Provider-safe: with no + // I18nProvider this resolves the English defaults table, never a raw key. + const { t } = useSummaryTranslation(); return useMemo(() => { const summaries = new Map(); @@ -323,10 +396,10 @@ export function useColumnSummary( summaries.set(col.field, { field: col.field, value: result, - label: formatSummaryLabel(config.type, result, columnHints, tenantCurrency), + label: formatSummaryLabel(config.type, result, t, columnHints, tenantCurrency), }); } return { summaries, hasSummary: summaries.size > 0 }; - }, [columns, data, fieldMetadata, tenantCurrency]); + }, [columns, data, fieldMetadata, tenantCurrency, t]); } diff --git a/packages/plugin-list/src/ViewSwitcher.tsx b/packages/plugin-list/src/ViewSwitcher.tsx index dfffd09552..f0c5eb9271 100644 --- a/packages/plugin-list/src/ViewSwitcher.tsx +++ b/packages/plugin-list/src/ViewSwitcher.tsx @@ -8,6 +8,7 @@ import * as React from 'react'; import { cn, Popover, PopoverContent, PopoverTrigger } from '@object-ui/components'; +import { createSafeTranslation } from '@object-ui/i18n'; import { Grid, LayoutGrid, @@ -54,18 +55,79 @@ const VIEW_ICONS: Record = { tree: , }; -const VIEW_LABELS: Record = { - grid: 'Grid', - kanban: 'Kanban', - gallery: 'Gallery', - calendar: 'Calendar', - timeline: 'Timeline', - gantt: 'Gantt', - map: 'Map', - chart: 'Chart', - tree: 'Tree', +/** + * Bundle key per visualization — objectui#4024. + * + * These are NOT new keys. `console.objectView.viewType*` already exists in all + * ten packs and already carries exactly these words: the create-view picker + * (`packages/app-shell/src/views/CreateViewDialog.tsx:88-96`) has resolved them + * through the bundle for months. This switcher naming the same nine + * visualizations with a private English copy was the drift, and reusing the + * pack's key is what keeps the picker's 「画廊」 and the switcher's 「画廊」 the + * same word in nine languages instead of two tables free to diverge. + * + * A plugin package reading a `console.*` key has precedent in this repo, in + * this very namespace: `packages/plugin-view/src/ObjectView.tsx:83` resolves + * `console.objectView.new`. + */ +const VIEW_LABEL_KEYS: Record = { + grid: 'console.objectView.viewTypeGrid', + kanban: 'console.objectView.viewTypeKanban', + gallery: 'console.objectView.viewTypeGallery', + calendar: 'console.objectView.viewTypeCalendar', + timeline: 'console.objectView.viewTypeTimeline', + gantt: 'console.objectView.viewTypeGantt', + map: 'console.objectView.viewTypeMap', + chart: 'console.objectView.viewTypeChart', + tree: 'console.objectView.viewTypeTree', +}; + +/** + * English fallbacks, used when no `I18nProvider` is mounted. + * + * `createSafeTranslation` rather than a bare `useObjectTranslation`: a large + * amount of existing coverage addresses these controls by their English name + * with no provider (`__tests__/ListView.test.tsx` among them), and a raw + * `console.objectView.viewTypeGrid` there would break all of it. This is the + * #4514 provider-less trap, and the table below is the pack value's stand-in on + * that path — the same shape `ObjectGrid` and `ListView` already use. + */ +const VIEW_LABEL_DEFAULTS: Record = { + 'console.objectView.viewTypeGrid': 'Grid', + 'console.objectView.viewTypeKanban': 'Kanban', + 'console.objectView.viewTypeGallery': 'Gallery', + 'console.objectView.viewTypeCalendar': 'Calendar', + 'console.objectView.viewTypeTimeline': 'Timeline', + 'console.objectView.viewTypeGantt': 'Gantt', + 'console.objectView.viewTypeMap': 'Map', + 'console.objectView.viewTypeChart': 'Chart', + 'console.objectView.viewTypeTree': 'Tree', }; +const useViewSwitcherTranslation = createSafeTranslation( + VIEW_LABEL_DEFAULTS, + 'console.objectView.viewTypeGrid', +); + +/** + * Resolve every visualization's label once per render. + * + * Returns a total `Record` so the three call sites per button + * (visible span, `aria-label`, `title`) stay a plain map lookup and cannot + * drift apart — and so a `ViewType` added to the union is a compile error + * naming the missing key rather than a button labelled `undefined`. + */ +function useViewLabels(): Record { + const { t } = useViewSwitcherTranslation(); + return React.useMemo(() => { + const out = {} as Record; + for (const [view, key] of Object.entries(VIEW_LABEL_KEYS) as [ViewType, string][]) { + out[view] = t(key); + } + return out; + }, [t]); +} + /** * Compact dropdown form of the visualization switcher (Airtable-style): * a single "List ▾" button in the toolbar's right cluster that opens a @@ -80,6 +142,7 @@ export const ViewSwitcherDropdown: React.FC = ({ animated = true, }) => { const [open, setOpen] = React.useState(false); + const VIEW_LABELS = useViewLabels(); const handleViewChange = React.useCallback( (view: ViewType) => { @@ -186,6 +249,8 @@ export const ViewSwitcher: React.FC = ({ className, animated = true, }) => { + const VIEW_LABELS = useViewLabels(); + const handleViewChange = React.useCallback( (view: ViewType) => { if (!animated || view === currentView) { diff --git a/packages/plugin-list/src/__tests__/ViewSwitcher.i18n-4024.no-provider.test.tsx b/packages/plugin-list/src/__tests__/ViewSwitcher.i18n-4024.no-provider.test.tsx new file mode 100644 index 0000000000..7c25feb95d --- /dev/null +++ b/packages/plugin-list/src/__tests__/ViewSwitcher.i18n-4024.no-provider.test.tsx @@ -0,0 +1,58 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The switcher's provider-less fallback stays ENGLISH — objectui#4024, and the + * #4514 trap this card was warned about. + * + * Wiring `VIEW_LABELS` through the bundle is only safe because + * `createSafeTranslation` keeps a provider-less host rendering English rather + * than a raw `console.objectView.viewTypeGrid`. A large amount of existing + * coverage addresses these controls by their English name with no + * `I18nProvider` mounted (`packages/plugin-list/src/__tests__/ListView.test.tsx` + * among them), so this is a hard constraint, not a nicety. + * + * ## Why this is a separate FILE and not another `it` next door + * + * `createI18n` registers its instance as react-i18next's module-global default + * and that registration survives unmount and `cleanup()`. A no-provider render + * sharing a file with locale-mounted renders resolves against whichever locale + * ran last — measured, and recorded on + * `packages/components/src/__tests__/sheet-dialog-close-i18n.test.tsx` + * (objectstack#5505/#5506), where the fallback case failed naming a button + * "Cerrar" under a test that mounted no provider at all. + * + * ## Red-first prediction (written BEFORE the first run) + * + * PASSES both before and after the fix — this is a must-not-change pin, not + * evidence of the change. It goes red only if the fix reaches for + * `useObjectTranslation` directly instead of the safe factory. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { ViewSwitcherDropdown } from '../ViewSwitcher'; + +afterEach(() => cleanup()); + +describe('view switcher with no I18nProvider (objectui#4024 / #4514)', () => { + it('renders the English labels, never a raw bundle key', () => { + render( + {}} + animated={false} + />, + ); + + expect(screen.getByRole('tab', { name: 'Grid' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: 'Gallery' })).toBeTruthy(); + expect(screen.queryByText(/console\.objectView\.viewType/)).toBeNull(); + }); +}); diff --git a/packages/plugin-list/src/__tests__/ViewSwitcher.i18n-4024.test.tsx b/packages/plugin-list/src/__tests__/ViewSwitcher.i18n-4024.test.tsx new file mode 100644 index 0000000000..d9f3210efd --- /dev/null +++ b/packages/plugin-list/src/__tests__/ViewSwitcher.i18n-4024.test.tsx @@ -0,0 +1,123 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The list-view mode switcher speaks the session locale — objectui#4024 + * (migrated from objectstack#5084). + * + * `ViewSwitcher.tsx` carried `VIEW_LABELS`, a hardcoded English + * `Record` used three ways per button: the visible span, the + * `aria-label`, and the `title`. So on a zh-CN console the switcher read + * "Grid" / "Gallery" beside a fully translated toolbar, and there was nothing + * an app could author to reach it. + * + * ## No new keys — this was a wiring gap, not a missing capability + * + * `console.objectView.viewType{Grid,Kanban,Gallery,…}` already exists in all + * ten packs and already carries exactly these words: the create-view picker + * (`packages/app-shell/src/views/CreateViewDialog.tsx:88-96`) has resolved them + * through the bundle for months. The switcher naming the same nine + * visualizations with a private English copy is the drift; reusing the pack's + * key is what keeps the picker's 「画廊」 and the switcher's 「画廊」 the same + * word in nine languages. The triage seat on objectstack#5084 predicted this + * ("i18n 包已有 `viewTypeGrid`/`viewTypeGallery` 词条,疑似未接线") and it holds. + * + * A plugin package reading a `console.*` key has precedent in this repo, in + * this very namespace: `packages/plugin-view/src/ObjectView.tsx:83` resolves + * `console.objectView.new`. + * + * ## Red-first prediction (written BEFORE the first run) + * + * Pre-fix, on `origin/main`: + * - the two zh cases FAIL — the component renders the English literal + * 'Grid' / 'Gallery' from `VIEW_LABELS`, never consulting the bundle, so + * `getByRole('tab', { name: '表格' })` finds nothing; + * - the en case and the no-provider case PASS — the English literal happens + * to equal the `en` pack value, which is precisely why nothing caught this. + * + * That asymmetry is the point: only the translated assertion can go red here, + * so the English one is kept as the must-not-change half rather than as + * evidence of the fix. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import * as React from 'react'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { ViewSwitcher, ViewSwitcherDropdown } from '../ViewSwitcher'; + +afterEach(() => cleanup()); + +function inLocale(language: string, body: React.ReactElement) { + return render( + + {body} + , + ); +} + +/** Two visualizations → the segmented control (`role="tab"`). */ +function segmented() { + return ( + {}} + animated={false} + /> + ); +} + +describe('list-view mode switcher resolves its labels from the bundle (objectui#4024)', () => { + it('zh-CN: the segmented control announces 表格 / 画廊', async () => { + inLocale('zh', segmented()); + // Accessible-name queries, not text queries: the label is the button's + // `aria-label`, which is what assistive tech announces and what a text + // query would miss on the `sm:`-hidden span. + expect(await screen.findByRole('tab', { name: '表格' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: '画廊' })).toBeTruthy(); + }); + + it('zh-CN: the full button-row switcher announces 表格 / 画廊', async () => { + inLocale( + 'zh', + {}} + animated={false} + />, + ); + expect(await screen.findByRole('button', { name: '表格' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '画廊' })).toBeTruthy(); + }); + + it('ja-JP: the segmented control announces グリッド / ギャラリー', async () => { + inLocale('ja', segmented()); + expect(await screen.findByRole('tab', { name: 'グリッド' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: 'ギャラリー' })).toBeTruthy(); + }); + + it('en: unchanged — Grid / Gallery (must-not-change)', async () => { + inLocale('en', segmented()); + expect(await screen.findByRole('tab', { name: 'Grid' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: 'Gallery' })).toBeTruthy(); + }); +}); + +/** + * The English no-provider fallback is pinned in a FILE OF ITS OWN — + * `ViewSwitcher.i18n-4024.no-provider.test.tsx`. + * + * `createI18n` registers its instance as react-i18next's module-global + * default, and that registration survives unmount and `cleanup()`, so a + * "no provider" render placed HERE would silently resolve against whichever + * locale the cases above mounted last. This is not a guess: it is the warning + * `sheet-dialog-close-i18n.test.tsx` records after being bitten by it + * (objectstack#5505/#5506), and it applies verbatim to this file. + */