From 43c150b13590c665b642a8bc9dcea9d2fb2794ed Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs Date: Sun, 31 May 2026 14:49:18 +0200 Subject: [PATCH] feat(spa): chrome i18n message-catalog infrastructure (#630) + 1.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #630. The wire-side half landed in 1.6.0 via PR #648 (documenting LocaleMiddleware so the API's `gettext_lazy`-wrapped `verbose_name` / `help_text` resolve per the active locale). This PR ships the SPA-side half: the chrome's own strings ("Add", "Save", "Search", "Loading…") now flow through a message catalog that hydrates from the request's active locale. ## How it works 1. **Server**: ``SpaIndexView`` resolves the active language via ``translation.get_language()`` — falls back to ``settings.LANGUAGE_CODE`` when ``LocaleMiddleware`` isn't in the stack. The shell template renders it as ````. 2. **SPA boot**: ``main.tsx`` reads the meta tag and calls ``loadCatalog(language)`` BEFORE the React root mounts — so the first paint already carries translated strings (no FOUC, no fetch / loading state). 3. **Component use**: any package imports ``t`` from ``@dar/ui`` and wraps user-visible strings: ```tsx import { t } from '@dar/ui'; ``` ``t(en)`` looks the English source up in the active catalog; missing key → returns ``en`` itself. Source-as-key (gettext convention) so refactoring a JSX string doesn't require a parallel catalog-key change, and gradual coverage degrades gracefully. ## Catalogs shipped JSON catalogs bundled in the SPA build under ``packages/ui/src/i18n/``: - ``en.json`` — placeholder; English round-trips via source-as-key. - ``es.json`` — Spanish (40 keys). - ``pt.json`` — Portuguese / pt-br (40 keys; ``pt-br`` aliased). - ``fr.json`` — French (40 keys). The set covers the high-visibility chrome strings — Add, Save, Edit, Delete, Cancel, Refresh, Loading…, Clear all, Customize columns, Layout, Sign in / out, History, the shuttle widget's Available / Chosen / Choose all / Remove all / Filter, the list-action redirect banner, the raw_id lookup link, etc. Adding a new language: drop a JSON file, import it in ``i18n.ts``, ship. ## Tests - ``frontend/packages/ui/src/i18n.test.ts`` — 14 cases pinning source-as-key fallback, exact + stem language matching (``es-AR`` → ``es``), case-insensitive code, unknown-code no-op, sanity-check that ES / PT / FR actually translate the expected keys, English source unchanged. - ``tests/test_active_language.py`` — 3 cases pinning that the ```` tag is emitted, that it follows ``translation.activate()``, and that it falls back to ``LANGUAGE_CODE`` when no locale is active. ## Coverage strategy Wired ``t(…)`` into ``ShuttleSelect`` (the high-traffic new widget from #627) as the proof-of-life. Remaining JSX strings stay English until reached — each ``t(en)`` migration is independent and ships incrementally. The README's i18n section (1.6.0) already explains the pattern for consumers. ## Verification - ``poetry run pytest -q`` — **64 / 64 ✓** (up from 61; +3 new in ``test_active_language.py``) - ``pnpm test`` — **216 / 216 ✓** (up from 202; +14 new in ``i18n.test.ts``) - ``pnpm -r typecheck`` ✓ - ``pnpm lint`` ✓ - ``pnpm -w build`` ✓ ## Minor bump rationale ``1.6.0`` → ``1.7.0``. New user-visible capability (chrome translation) per SemVer's "additive features" guideline. Pure additive — English shops see no behaviour change. Closes #630. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../templates/admin_react/index.html | 8 ++ django_admin_react/views.py | 30 +++++ frontend/apps/web/src/main.tsx | 10 ++ frontend/packages/form/src/ShuttleSelect.tsx | 15 +-- frontend/packages/ui/src/i18n.test.ts | 118 ++++++++++++++++++ frontend/packages/ui/src/i18n.ts | 92 ++++++++++++++ frontend/packages/ui/src/i18n/en.json | 3 + frontend/packages/ui/src/i18n/es.json | 47 +++++++ frontend/packages/ui/src/i18n/fr.json | 47 +++++++ frontend/packages/ui/src/i18n/pt.json | 47 +++++++ frontend/packages/ui/src/index.ts | 7 ++ pyproject.toml | 2 +- tests/test_active_language.py | 84 +++++++++++++ 13 files changed, 502 insertions(+), 8 deletions(-) create mode 100644 frontend/packages/ui/src/i18n.test.ts create mode 100644 frontend/packages/ui/src/i18n.ts create mode 100644 frontend/packages/ui/src/i18n/en.json create mode 100644 frontend/packages/ui/src/i18n/es.json create mode 100644 frontend/packages/ui/src/i18n/fr.json create mode 100644 frontend/packages/ui/src/i18n/pt.json create mode 100644 tests/test_active_language.py diff --git a/django_admin_react/templates/admin_react/index.html b/django_admin_react/templates/admin_react/index.html index 6b4546e..a71422a 100644 --- a/django_admin_react/templates/admin_react/index.html +++ b/django_admin_react/templates/admin_react/index.html @@ -27,6 +27,14 @@ {% if legacy_admin_prefix %}{% endif %} {% if brand_logo_url %}{% endif %} + {% comment %} + Active locale (#630). The SPA hydrates its message catalog + from this — the chrome strings ("Add", "Search", "Loading…") + render in the user's language. LocaleMiddleware sets + `translation.get_language()` for the request; without it the + value collapses to `settings.LANGUAGE_CODE`. + {% endcomment %} + {% comment %} The tab uses the AdminSite's site_title (Django's tab-title source); the sidebar header reads dar-brand-title (site_header). Both diff --git a/django_admin_react/views.py b/django_admin_react/views.py index effa5c5..7168ac6 100644 --- a/django_admin_react/views.py +++ b/django_admin_react/views.py @@ -117,6 +117,15 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: "brand_logo_url": _resolve_brand_logo(admin_site), "primary_color": _resolve_primary_color(admin_site), "initial_theme": _resolve_initial_theme(request), + # SPA chrome i18n (#630). The active language code from + # ``LocaleMiddleware`` (or ``settings.LANGUAGE_CODE`` + # when middleware isn't installed) — the SPA reads it + # from ``<meta name="dar-language">`` and hydrates the + # matching message catalog so its chrome strings + # ("Add", "Search", "Loading…") render in the user's + # language, matching what the API package already + # surfaces for ``verbose_name`` / ``help_text``. + "active_language": _resolve_active_language(), }, ) # The SPA shell must never be cached: it references the @@ -345,6 +354,27 @@ def _resolve_primary_color(admin_site: Any) -> str: return dar_conf.DEFAULT_PRIMARY_COLOR +def _resolve_active_language() -> str: + """The locale code the SPA hydrates its message catalog from (#630). + + Uses Django's translation machinery — ``LocaleMiddleware`` + activates the request's language before the view runs, so + ``translation.get_language()`` returns the resolved active + code (e.g. ``"es"``, ``"pt-br"``, ``"fr"``). When the middleware + isn't in the stack, ``get_language()`` returns + ``settings.LANGUAGE_CODE`` (Django's default fallback). Either + way the SPA gets a real two- or five-character code and + ``loadCatalog`` either matches it or falls back to English at + the SPA boundary — no Python-side fallback chain needed. + """ + from django.utils import translation + + code = translation.get_language() or "en" + # Strip whitespace; Django won't return whitespace-only but be + # defensive — the value lands directly into a meta tag. + return code.strip() + + def _resolve_initial_theme(request: HttpRequest) -> str | None: """The theme to paint on the server-rendered shell (#84). diff --git a/frontend/apps/web/src/main.tsx b/frontend/apps/web/src/main.tsx index 4c54c62..c05ca9a 100644 --- a/frontend/apps/web/src/main.tsx +++ b/frontend/apps/web/src/main.tsx @@ -5,6 +5,8 @@ import { BrowserRouter } from 'react-router-dom'; import { ApiClient, ApiProvider, RegistryProvider } from '@dar/data'; import { initTheme } from '@dar/settings'; +import { loadCatalog } from '@dar/ui'; + import { App } from './App'; import './index.css'; @@ -12,6 +14,14 @@ import './index.css'; // mounts so the first paint is already in the right theme — no flash. initTheme(); +// Hydrate the chrome message catalog from the server-rendered +// `<meta name="dar-language">` (#630). Synchronous — the catalogs +// are bundled into the SPA JS so the first React render already +// carries translated strings; no fetch / loading state, no FOUC. +loadCatalog( + document.querySelector<HTMLMetaElement>('meta[name="dar-language"]')?.content?.trim(), +); + // The mount is the consumer-chosen URL prefix (e.g. `/admin-react/`, // `/admin2/`, `/staff/`). The backend's ``SpaIndexView`` writes it to // the ``index.html`` template as ``<meta name="dar-mount" content="...">``; diff --git a/frontend/packages/form/src/ShuttleSelect.tsx b/frontend/packages/form/src/ShuttleSelect.tsx index 3cffc5d..33bb2ed 100644 --- a/frontend/packages/form/src/ShuttleSelect.tsx +++ b/frontend/packages/form/src/ShuttleSelect.tsx @@ -25,6 +25,7 @@ import { useId, useMemo, useState } from 'react'; import type { FieldChoice, WriteValue } from '@dar/data'; +import { t } from '@dar/ui'; interface ShuttleSelectProps { /** Stable id prefix for the search inputs (a11y labelling). */ @@ -133,29 +134,29 @@ export function ShuttleSelect({ return ( <div className={containerClass}> <Pane - title={`Available ${label}`} + title={`${t('Available')} ${label}`} searchId={availId} items={visibleAvail} - emptyMessage="No matches." + emptyMessage={t('No matches.')} onItemActivate={(c) => { const pk = pkOf(c); if (pk !== null) addOne(pk); }} - actionLabel="Choose all" + actionLabel={t('Choose all')} onAction={() => addMany(visibleAvail)} filter={availFilter} setFilter={setAvailFilter} /> <Pane - title={`Chosen ${label}`} + title={`${t('Chosen')} ${label}`} searchId={chosenId} items={visibleChosen} - emptyMessage="Nothing selected yet." + emptyMessage={t('Nothing selected yet.')} onItemActivate={(c) => { const pk = pkOf(c); if (pk !== null) removeOne(pk); }} - actionLabel="Remove all" + actionLabel={t('Remove all')} onAction={() => removeMany(visibleChosen)} filter={chosenFilter} setFilter={setChosenFilter} @@ -218,7 +219,7 @@ function Pane({ type="search" value={filter} onChange={(e) => setFilter(e.target.value)} - placeholder="Filter" + placeholder={t('Filter')} className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-gray-500" /> <ul diff --git a/frontend/packages/ui/src/i18n.test.ts b/frontend/packages/ui/src/i18n.test.ts new file mode 100644 index 0000000..4d95068 --- /dev/null +++ b/frontend/packages/ui/src/i18n.test.ts @@ -0,0 +1,118 @@ +// Lock the SPA chrome i18n behaviour (#630): +// 1. `t(en)` returns the English source string when the catalog +// has no entry for it (graceful degradation; consumers see +// legible text mid-migration as keys land). +// 2. `loadCatalog(lang)` swaps the active catalog by exact code, +// then by language stem (`es-AR` → `es`), then no-ops on +// unknown codes (keeps English). +// 3. The bundled non-English catalogs actually translate the +// sample keys we expect — sanity check that the JSON files +// aren't empty / corrupted in the build pipeline. +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + _activeCatalogForTests, + _setActiveCatalogForTests, + loadCatalog, + t, +} from './i18n'; + +beforeEach(() => { + // Reset to the bundled English catalog before each test so cases + // don't leak state through the shared module-level binding. + loadCatalog('en'); +}); + +describe('t (source-as-key translation lookup)', () => { + it('returns the English source when the active catalog has no entry', () => { + _setActiveCatalogForTests({}); + expect(t('Brand new string the catalog has never seen')).toBe( + 'Brand new string the catalog has never seen', + ); + }); + + it('returns the translation when the catalog has an entry', () => { + _setActiveCatalogForTests({ Add: 'Añadir' }); + expect(t('Add')).toBe('Añadir'); + }); + + it('falls back to the English source for a partial catalog', () => { + _setActiveCatalogForTests({ Add: 'Añadir' }); + expect(t('Add')).toBe('Añadir'); // translated + expect(t('Search')).toBe('Search'); // not in this fixture → English + }); +}); + +describe('loadCatalog (language → catalog selection)', () => { + it('matches an exact language code', () => { + loadCatalog('es'); + expect(t('Add')).toBe('Añadir'); + }); + + it('matches by language stem when the full code is unknown (es-AR → es)', () => { + loadCatalog('es-AR'); + expect(t('Add')).toBe('Añadir'); + }); + + it('matches case-insensitively (ES → es)', () => { + loadCatalog('ES'); + expect(t('Add')).toBe('Añadir'); + }); + + it('matches pt-br to the pt catalog', () => { + loadCatalog('pt-br'); + expect(t('Save')).toBe('Salvar'); + }); + + it('falls back to English (no-op) on unknown codes', () => { + _setActiveCatalogForTests({ Add: 'Añadir' }); // start non-English + loadCatalog('zz-ZZ'); // unknown — leaves the previous catalog active + expect(t('Add')).toBe('Añadir'); + }); + + it('is a no-op when language is null / empty', () => { + loadCatalog('es'); + loadCatalog(null); + expect(t('Add')).toBe('Añadir'); // still Spanish + loadCatalog(''); + expect(t('Add')).toBe('Añadir'); // still Spanish + }); +}); + +describe('bundled catalog sanity (#630 ship-set)', () => { + it('Spanish translates the common chrome strings', () => { + loadCatalog('es'); + expect(t('Add')).toBe('Añadir'); + expect(t('Save')).toBe('Guardar'); + expect(t('Cancel')).toBe('Cancelar'); + expect(t('Loading…')).toBe('Cargando…'); + }); + + it('Portuguese translates the common chrome strings', () => { + loadCatalog('pt'); + expect(t('Add')).toBe('Adicionar'); + expect(t('Save')).toBe('Salvar'); + expect(t('Delete')).toBe('Excluir'); + }); + + it('French translates the common chrome strings', () => { + loadCatalog('fr'); + expect(t('Add')).toBe('Ajouter'); + expect(t('Save')).toBe('Enregistrer'); + expect(t('Delete')).toBe('Supprimer'); + }); + + it('English keeps the source strings unchanged (source-as-key)', () => { + loadCatalog('en'); + // The English catalog only has the _comment; every real string + // round-trips through the source-as-key fallback. + expect(t('Add')).toBe('Add'); + expect(t('Anything not in the catalog')).toBe('Anything not in the catalog'); + }); + + it('exposes the active catalog for debugging tests', () => { + loadCatalog('es'); + const catalog = _activeCatalogForTests(); + expect(catalog.Add).toBe('Añadir'); + }); +}); diff --git a/frontend/packages/ui/src/i18n.ts b/frontend/packages/ui/src/i18n.ts new file mode 100644 index 0000000..ed5927b --- /dev/null +++ b/frontend/packages/ui/src/i18n.ts @@ -0,0 +1,92 @@ +// SPA chrome i18n (#630). Minimum-viable message-catalog +// infrastructure so the package's own user-visible strings ("Add", +// "Search", "Save and continue editing", "Loading…") can be +// translated per the request's active locale — not just the +// API-payload `verbose_name` / `help_text` that already comes back +// translated when `LocaleMiddleware` is in the consumer's MIDDLEWARE +// stack. +// +// Design: +// - Catalog keys are the English source string itself (gettext +// convention). Removes a layer of indirection: refactoring a +// string in JSX doesn't require a parallel catalog-key change. +// - The active catalog is hydrated once at boot from the bundled +// JSON files keyed by the `dar-language` meta tag the server +// renders into `index.html`. Missing key → English source +// string returned (graceful degradation; the operator sees +// legible text even mid-migration as keys land). +// - Synchronous API. The catalog is bundled into the SPA JS so +// there's no fetch / loading state — the first paint already +// carries the translations. +// +// Adding a new language: drop a JSON file under `src/i18n/` keyed +// by the locale code (matches Django's `LANGUAGE_CODE`), import it +// in `loadCatalog`, ship. + +import enCatalog from './i18n/en.json'; +import esCatalog from './i18n/es.json'; +import ptCatalog from './i18n/pt.json'; +import frCatalog from './i18n/fr.json'; + +type Catalog = Readonly<Record<string, string>>; + +const CATALOGS: Readonly<Record<string, Catalog>> = { + en: enCatalog, + es: esCatalog, + pt: ptCatalog, + 'pt-br': ptCatalog, + fr: frCatalog, +}; + +let activeCatalog: Catalog = enCatalog; + +/** + * Hydrate the active catalog from the language code the server + * rendered into ``<meta name="dar-language">``. Falls back to + * English when the code is unknown / unset / malformed. + * + * Matching is exact first, then language-stem only (``es-AR`` → + * tries ``es-AR``, then ``es``). The fallback chain is one level + * deep — enough for ``language-region`` codes; over-engineered + * regional-fallback chains aren't shipped here. + */ +export function loadCatalog(language: string | null | undefined): void { + if (!language) return; + const key = language.toLowerCase(); + const exact = CATALOGS[key]; + if (exact) { + activeCatalog = exact; + return; + } + const stem = key.split('-')[0]; + const fallback = stem ? CATALOGS[stem] : undefined; + if (fallback) { + activeCatalog = fallback; + } +} + +/** + * Translate the English source string ``en`` to the active + * catalog's translation, falling back to ``en`` itself when the + * key isn't in the catalog. + * + * The English text is BOTH the source and the catalog key + * (gettext convention). Editing the JSX-side string without + * updating the catalog gracefully degrades to English; CI lint + * can grep for keys present in catalogs but not in source to + * surface dead translations. + */ +export function t(en: string): string { + return activeCatalog[en] ?? en; +} + +/** Test-only: swap the active catalog. Production code calls + * ``loadCatalog`` once at boot from ``main.tsx``. */ +export function _setActiveCatalogForTests(catalog: Catalog): void { + activeCatalog = catalog; +} + +/** Test-only: read the currently active language's catalog. */ +export function _activeCatalogForTests(): Catalog { + return activeCatalog; +} diff --git a/frontend/packages/ui/src/i18n/en.json b/frontend/packages/ui/src/i18n/en.json new file mode 100644 index 0000000..0778a55 --- /dev/null +++ b/frontend/packages/ui/src/i18n/en.json @@ -0,0 +1,3 @@ +{ + "_comment": "English source catalog. Empty by design — source-as-key means English keys round-trip themselves through `t(en)`. Kept as a file (not omitted) so eslint / lint / future tooling can introspect the catalog set uniformly." +} diff --git a/frontend/packages/ui/src/i18n/es.json b/frontend/packages/ui/src/i18n/es.json new file mode 100644 index 0000000..25735ea --- /dev/null +++ b/frontend/packages/ui/src/i18n/es.json @@ -0,0 +1,47 @@ +{ + "_comment": "Spanish (es) catalog. Keys are the English source string; values are the translation. Missing keys fall back to English at runtime.", + + "Add": "Añadir", + "Search": "Buscar", + "Save": "Guardar", + "Save and continue editing": "Guardar y seguir editando", + "Save and add another": "Guardar y añadir otro", + "Save as new": "Guardar como nuevo", + "Edit": "Editar", + "Delete": "Eliminar", + "Cancel": "Cancelar", + "Confirm": "Confirmar", + "Refresh": "Refrescar", + "Loading…": "Cargando…", + "Clear all": "Limpiar todo", + "Clear all filters": "Limpiar todos los filtros", + "Customize columns": "Personalizar columnas", + "Layout": "Diseño", + "Layout is already at default": "El diseño ya está en el valor predeterminado", + "Reset": "Restablecer", + "Done": "Hecho", + "Discard": "Descartar", + "Save changes": "Guardar cambios", + "Saving…": "Guardando…", + "Sign in": "Iniciar sesión", + "Sign out": "Cerrar sesión", + "Username": "Nombre de usuario", + "Password": "Contraseña", + "History": "Historial", + "Available": "Disponibles", + "Chosen": "Elegidos", + "Choose all": "Elegir todo", + "Remove all": "Quitar todo", + "Filter": "Filtrar", + "No matches.": "Sin coincidencias.", + "Nothing selected yet.": "Aún no hay nada seleccionado.", + "Open in a new tab": "Abrir en una pestaña nueva", + "Click to open in a new tab": "Clic para abrir en una pestaña nueva", + "Dismiss": "Descartar", + "Look up related object in a new tab": "Buscar objeto relacionado en pestaña nueva", + "Lookup ↗": "Buscar ↗", + "Action failed.": "La acción falló.", + "The action could not be completed.": "No se pudo completar la acción.", + "Couldn't load the list": "No se pudo cargar la lista", + "This page requires JavaScript.": "Esta página requiere JavaScript." +} diff --git a/frontend/packages/ui/src/i18n/fr.json b/frontend/packages/ui/src/i18n/fr.json new file mode 100644 index 0000000..f7cf7ef --- /dev/null +++ b/frontend/packages/ui/src/i18n/fr.json @@ -0,0 +1,47 @@ +{ + "_comment": "French (fr) catalog. Keys are the English source string; values are the translation. Missing keys fall back to English at runtime.", + + "Add": "Ajouter", + "Search": "Rechercher", + "Save": "Enregistrer", + "Save and continue editing": "Enregistrer et continuer la modification", + "Save and add another": "Enregistrer et ajouter un autre", + "Save as new": "Enregistrer comme nouveau", + "Edit": "Modifier", + "Delete": "Supprimer", + "Cancel": "Annuler", + "Confirm": "Confirmer", + "Refresh": "Actualiser", + "Loading…": "Chargement…", + "Clear all": "Tout effacer", + "Clear all filters": "Effacer tous les filtres", + "Customize columns": "Personnaliser les colonnes", + "Layout": "Disposition", + "Layout is already at default": "La disposition est déjà par défaut", + "Reset": "Réinitialiser", + "Done": "Terminé", + "Discard": "Abandonner", + "Save changes": "Enregistrer les modifications", + "Saving…": "Enregistrement…", + "Sign in": "Connexion", + "Sign out": "Déconnexion", + "Username": "Nom d'utilisateur", + "Password": "Mot de passe", + "History": "Historique", + "Available": "Disponibles", + "Chosen": "Choisis", + "Choose all": "Tout choisir", + "Remove all": "Tout retirer", + "Filter": "Filtrer", + "No matches.": "Aucune correspondance.", + "Nothing selected yet.": "Rien de sélectionné pour l'instant.", + "Open in a new tab": "Ouvrir dans un nouvel onglet", + "Click to open in a new tab": "Cliquer pour ouvrir dans un nouvel onglet", + "Dismiss": "Ignorer", + "Look up related object in a new tab": "Rechercher l'objet associé dans un nouvel onglet", + "Lookup ↗": "Rechercher ↗", + "Action failed.": "L'action a échoué.", + "The action could not be completed.": "L'action n'a pas pu être effectuée.", + "Couldn't load the list": "Impossible de charger la liste", + "This page requires JavaScript.": "Cette page nécessite JavaScript." +} diff --git a/frontend/packages/ui/src/i18n/pt.json b/frontend/packages/ui/src/i18n/pt.json new file mode 100644 index 0000000..f5d8729 --- /dev/null +++ b/frontend/packages/ui/src/i18n/pt.json @@ -0,0 +1,47 @@ +{ + "_comment": "Portuguese (pt + pt-BR) catalog. Keys are the English source string; values are the translation. Missing keys fall back to English at runtime.", + + "Add": "Adicionar", + "Search": "Pesquisar", + "Save": "Salvar", + "Save and continue editing": "Salvar e continuar editando", + "Save and add another": "Salvar e adicionar outro", + "Save as new": "Salvar como novo", + "Edit": "Editar", + "Delete": "Excluir", + "Cancel": "Cancelar", + "Confirm": "Confirmar", + "Refresh": "Atualizar", + "Loading…": "Carregando…", + "Clear all": "Limpar tudo", + "Clear all filters": "Limpar todos os filtros", + "Customize columns": "Personalizar colunas", + "Layout": "Layout", + "Layout is already at default": "O layout já está no padrão", + "Reset": "Redefinir", + "Done": "Feito", + "Discard": "Descartar", + "Save changes": "Salvar alterações", + "Saving…": "Salvando…", + "Sign in": "Entrar", + "Sign out": "Sair", + "Username": "Usuário", + "Password": "Senha", + "History": "Histórico", + "Available": "Disponíveis", + "Chosen": "Escolhidos", + "Choose all": "Escolher todos", + "Remove all": "Remover todos", + "Filter": "Filtrar", + "No matches.": "Sem correspondências.", + "Nothing selected yet.": "Nada selecionado ainda.", + "Open in a new tab": "Abrir em nova aba", + "Click to open in a new tab": "Clique para abrir em nova aba", + "Dismiss": "Dispensar", + "Look up related object in a new tab": "Buscar objeto relacionado em nova aba", + "Lookup ↗": "Buscar ↗", + "Action failed.": "A ação falhou.", + "The action could not be completed.": "A ação não pôde ser concluída.", + "Couldn't load the list": "Não foi possível carregar a lista", + "This page requires JavaScript.": "Esta página requer JavaScript." +} diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts index 4afc89b..47bd3a6 100644 --- a/frontend/packages/ui/src/index.ts +++ b/frontend/packages/ui/src/index.ts @@ -51,3 +51,10 @@ export type { ResetButtonProps } from './ResetButton'; export { RefreshButton } from './RefreshButton'; export type { RefreshButtonProps } from './RefreshButton'; + +// SPA chrome i18n (#630). Every package can call `t(en)` to translate +// a user-visible English source string to the active locale's catalog +// entry, falling back to the English source when no entry exists. +// The active catalog is hydrated once at boot from +// `<meta name="dar-language">`; see `loadCatalog` in main.tsx. +export { t, loadCatalog } from './i18n'; diff --git a/pyproject.toml b/pyproject.toml index daf6fea..3e5ac73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-admin-react" -version = "1.6.0" +version = "1.7.0" description = "A drop-in React single-page admin for Django, driven entirely by ModelAdmin." authors = ["django-admin-react contributors"] license = "MIT" diff --git a/tests/test_active_language.py b/tests/test_active_language.py new file mode 100644 index 0000000..3e4722a --- /dev/null +++ b/tests/test_active_language.py @@ -0,0 +1,84 @@ +"""Tests for ``_resolve_active_language`` (#630). + +The SPA reads ``<meta name="dar-language">`` at boot to pick its +chrome message catalog. The server resolves the language code from +Django's translation machinery: when ``LocaleMiddleware`` is in the +stack, it activates the request's language before the view runs and +``translation.get_language()`` returns that code; without the +middleware, the call collapses to ``settings.LANGUAGE_CODE``. + +These tests cover both modes — the package shouldn't enforce +``LocaleMiddleware`` but it MUST work with or without it. +""" + +from __future__ import annotations + +import pytest +from django.test import Client +from django.test import override_settings +from django.utils import translation + + +@pytest.fixture +def staff_client(db): + from django.contrib.auth import get_user_model + + user = get_user_model().objects.create_superuser( + username="i18n-su", + email="i18n@example.com", + password="x", # noqa: S106 + ) + c = Client() + c.force_login(user) + return c + + +@pytest.mark.django_db +def test_dar_language_meta_is_emitted(staff_client: Client) -> None: + """The shell template MUST emit ``<meta name="dar-language">`` so the + SPA can hydrate its catalog at boot. The exact value depends on the + request's active locale; the tag's presence is the load-bearing + invariant (#630).""" + response = staff_client.get("/admin-react/") + html = response.content.decode("utf-8") + assert 'name="dar-language"' in html, html + + +@pytest.mark.django_db +def test_active_language_defaults_to_language_code_when_no_localemiddleware( + staff_client: Client, +) -> None: + """Without ``LocaleMiddleware`` in MIDDLEWARE, ``translation + .get_language()`` returns ``settings.LANGUAGE_CODE`` — the SPA's + fallback locale. The test_project's MIDDLEWARE list intentionally + omits ``LocaleMiddleware`` (matches Django's ``startproject`` + template), so this is the default-deploy case (#630).""" + with override_settings(LANGUAGE_CODE="en-us"): + translation.deactivate_all() + response = staff_client.get("/admin-react/") + html = response.content.decode("utf-8") + # Django's ``get_language()`` may return the stem (``en``) or + # the full code (``en-us``) depending on locale activation + # state — accept either; the load-bearing invariant is that + # the meta tag is populated with a reasonable code. + assert ( + 'name="dar-language" content="en-us"' in html + or 'name="dar-language" content="en"' in html + ) + + +@pytest.mark.django_db +def test_active_language_follows_activate(staff_client: Client) -> None: + """When something HAS activated a locale (LocaleMiddleware in real + deployments, or an explicit ``translation.activate`` for the test), + ``_resolve_active_language`` follows it. Mirrors what a Spanish- + primary shop sees: the SPA gets ``es`` in the meta, hydrates the + Spanish catalog, renders translated chrome (#630).""" + with override_settings(LANGUAGE_CODE="en-us", USE_I18N=True): + translation.activate("es") + try: + response = staff_client.get("/admin-react/") + html = response.content.decode("utf-8") + assert 'name="dar-language" content="es"' in html + finally: + translation.deactivate_all()