diff --git a/.changeset/console-chrome-i18n-4024.md b/.changeset/console-chrome-i18n-4024.md new file mode 100644 index 000000000..9116dc93a --- /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 e4c90d18c..c4664d9a7 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 9e8646a70..1b06dbf73 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 909197bf2..44e3287cb 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 000000000..2894537c8 --- /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 000000000..70153ad01 --- /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 000000000..f7e1b76f6 --- /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 acdfdf15a..ed15503b9 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 d765e6407..9c941313b 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 9282c84a7..d90360f54 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 70fdf871d..212c19f79 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 f45902a93..30f1627cc 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 b6aa79ec1..825bbc061 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 99b5ac3e5..645554a92 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 50625d62d..717c92aec 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 c6e041189..e5f89dd89 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 5b007d7c6..b57355fbd 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 9a70a5e2b..c306c6d3f 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 6bcbf18e2..deb96654a 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 74129b638..e1c113a3c 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 60c655a48..854146e57 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 000000000..87a4b7c64 --- /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 000000000..0ac3f0abe --- /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 000000000..f5a4ec734 --- /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 000000000..385caab1e --- /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 b2868c0e2..5cd4bfc97 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 dfffd0955..f0c5eb927 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 000000000..7c25feb95 --- /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 000000000..d9f3210ef --- /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. + */