Skip to content

Commit 5c293d9

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
feat(spa): chrome i18n message-catalog infrastructure (#630) + 1.7.0 (#649)
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 ``<meta name="dar-language">``. 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'; <Button>{t('Save')}</Button> ``` ``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 ``<meta name="dar-language">`` 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: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1bc79e9 commit 5c293d9

13 files changed

Lines changed: 502 additions & 8 deletions

File tree

django_admin_react/templates/admin_react/index.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@
2727
{% if legacy_admin_prefix %}<meta name="dar-legacy-admin-prefix" content="{{ legacy_admin_prefix }}" />{% endif %}
2828
<meta name="dar-brand-title" content="{{ brand_title }}" />
2929
{% if brand_logo_url %}<meta name="dar-brand-logo" content="{{ brand_logo_url }}" />{% endif %}
30+
{% comment %}
31+
Active locale (#630). The SPA hydrates its message catalog
32+
from this — the chrome strings ("Add", "Search", "Loading…")
33+
render in the user's language. LocaleMiddleware sets
34+
`translation.get_language()` for the request; without it the
35+
value collapses to `settings.LANGUAGE_CODE`.
36+
{% endcomment %}
37+
<meta name="dar-language" content="{{ active_language }}" />
3038
{% comment %}
3139
The tab <title> uses the AdminSite's site_title (Django's tab-title
3240
source); the sidebar header reads dar-brand-title (site_header). Both

django_admin_react/views.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,15 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
117117
"brand_logo_url": _resolve_brand_logo(admin_site),
118118
"primary_color": _resolve_primary_color(admin_site),
119119
"initial_theme": _resolve_initial_theme(request),
120+
# SPA chrome i18n (#630). The active language code from
121+
# ``LocaleMiddleware`` (or ``settings.LANGUAGE_CODE``
122+
# when middleware isn't installed) — the SPA reads it
123+
# from ``<meta name="dar-language">`` and hydrates the
124+
# matching message catalog so its chrome strings
125+
# ("Add", "Search", "Loading…") render in the user's
126+
# language, matching what the API package already
127+
# surfaces for ``verbose_name`` / ``help_text``.
128+
"active_language": _resolve_active_language(),
120129
},
121130
)
122131
# The SPA shell must never be cached: it references the
@@ -345,6 +354,27 @@ def _resolve_primary_color(admin_site: Any) -> str:
345354
return dar_conf.DEFAULT_PRIMARY_COLOR
346355

347356

357+
def _resolve_active_language() -> str:
358+
"""The locale code the SPA hydrates its message catalog from (#630).
359+
360+
Uses Django's translation machinery — ``LocaleMiddleware``
361+
activates the request's language before the view runs, so
362+
``translation.get_language()`` returns the resolved active
363+
code (e.g. ``"es"``, ``"pt-br"``, ``"fr"``). When the middleware
364+
isn't in the stack, ``get_language()`` returns
365+
``settings.LANGUAGE_CODE`` (Django's default fallback). Either
366+
way the SPA gets a real two- or five-character code and
367+
``loadCatalog`` either matches it or falls back to English at
368+
the SPA boundary — no Python-side fallback chain needed.
369+
"""
370+
from django.utils import translation
371+
372+
code = translation.get_language() or "en"
373+
# Strip whitespace; Django won't return whitespace-only but be
374+
# defensive — the value lands directly into a meta tag.
375+
return code.strip()
376+
377+
348378
def _resolve_initial_theme(request: HttpRequest) -> str | None:
349379
"""The theme to paint on the server-rendered shell (#84).
350380

frontend/apps/web/src/main.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,23 @@ import { BrowserRouter } from 'react-router-dom';
55
import { ApiClient, ApiProvider, RegistryProvider } from '@dar/data';
66
import { initTheme } from '@dar/settings';
77

8+
import { loadCatalog } from '@dar/ui';
9+
810
import { App } from './App';
911
import './index.css';
1012

1113
// Apply the saved (or system-default) light/dark theme before React
1214
// mounts so the first paint is already in the right theme — no flash.
1315
initTheme();
1416

17+
// Hydrate the chrome message catalog from the server-rendered
18+
// `<meta name="dar-language">` (#630). Synchronous — the catalogs
19+
// are bundled into the SPA JS so the first React render already
20+
// carries translated strings; no fetch / loading state, no FOUC.
21+
loadCatalog(
22+
document.querySelector<HTMLMetaElement>('meta[name="dar-language"]')?.content?.trim(),
23+
);
24+
1525
// The mount is the consumer-chosen URL prefix (e.g. `/admin-react/`,
1626
// `/admin2/`, `/staff/`). The backend's ``SpaIndexView`` writes it to
1727
// the ``index.html`` template as ``<meta name="dar-mount" content="...">``;

frontend/packages/form/src/ShuttleSelect.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import { useId, useMemo, useState } from 'react';
2626

2727
import type { FieldChoice, WriteValue } from '@dar/data';
28+
import { t } from '@dar/ui';
2829

2930
interface ShuttleSelectProps {
3031
/** Stable id prefix for the search inputs (a11y labelling). */
@@ -133,29 +134,29 @@ export function ShuttleSelect({
133134
return (
134135
<div className={containerClass}>
135136
<Pane
136-
title={`Available ${label}`}
137+
title={`${t('Available')} ${label}`}
137138
searchId={availId}
138139
items={visibleAvail}
139-
emptyMessage="No matches."
140+
emptyMessage={t('No matches.')}
140141
onItemActivate={(c) => {
141142
const pk = pkOf(c);
142143
if (pk !== null) addOne(pk);
143144
}}
144-
actionLabel="Choose all"
145+
actionLabel={t('Choose all')}
145146
onAction={() => addMany(visibleAvail)}
146147
filter={availFilter}
147148
setFilter={setAvailFilter}
148149
/>
149150
<Pane
150-
title={`Chosen ${label}`}
151+
title={`${t('Chosen')} ${label}`}
151152
searchId={chosenId}
152153
items={visibleChosen}
153-
emptyMessage="Nothing selected yet."
154+
emptyMessage={t('Nothing selected yet.')}
154155
onItemActivate={(c) => {
155156
const pk = pkOf(c);
156157
if (pk !== null) removeOne(pk);
157158
}}
158-
actionLabel="Remove all"
159+
actionLabel={t('Remove all')}
159160
onAction={() => removeMany(visibleChosen)}
160161
filter={chosenFilter}
161162
setFilter={setChosenFilter}
@@ -218,7 +219,7 @@ function Pane({
218219
type="search"
219220
value={filter}
220221
onChange={(e) => setFilter(e.target.value)}
221-
placeholder="Filter"
222+
placeholder={t('Filter')}
222223
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-gray-500"
223224
/>
224225
<ul
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Lock the SPA chrome i18n behaviour (#630):
2+
// 1. `t(en)` returns the English source string when the catalog
3+
// has no entry for it (graceful degradation; consumers see
4+
// legible text mid-migration as keys land).
5+
// 2. `loadCatalog(lang)` swaps the active catalog by exact code,
6+
// then by language stem (`es-AR` → `es`), then no-ops on
7+
// unknown codes (keeps English).
8+
// 3. The bundled non-English catalogs actually translate the
9+
// sample keys we expect — sanity check that the JSON files
10+
// aren't empty / corrupted in the build pipeline.
11+
import { beforeEach, describe, expect, it } from 'vitest';
12+
13+
import {
14+
_activeCatalogForTests,
15+
_setActiveCatalogForTests,
16+
loadCatalog,
17+
t,
18+
} from './i18n';
19+
20+
beforeEach(() => {
21+
// Reset to the bundled English catalog before each test so cases
22+
// don't leak state through the shared module-level binding.
23+
loadCatalog('en');
24+
});
25+
26+
describe('t (source-as-key translation lookup)', () => {
27+
it('returns the English source when the active catalog has no entry', () => {
28+
_setActiveCatalogForTests({});
29+
expect(t('Brand new string the catalog has never seen')).toBe(
30+
'Brand new string the catalog has never seen',
31+
);
32+
});
33+
34+
it('returns the translation when the catalog has an entry', () => {
35+
_setActiveCatalogForTests({ Add: 'Añadir' });
36+
expect(t('Add')).toBe('Añadir');
37+
});
38+
39+
it('falls back to the English source for a partial catalog', () => {
40+
_setActiveCatalogForTests({ Add: 'Añadir' });
41+
expect(t('Add')).toBe('Añadir'); // translated
42+
expect(t('Search')).toBe('Search'); // not in this fixture → English
43+
});
44+
});
45+
46+
describe('loadCatalog (language → catalog selection)', () => {
47+
it('matches an exact language code', () => {
48+
loadCatalog('es');
49+
expect(t('Add')).toBe('Añadir');
50+
});
51+
52+
it('matches by language stem when the full code is unknown (es-AR → es)', () => {
53+
loadCatalog('es-AR');
54+
expect(t('Add')).toBe('Añadir');
55+
});
56+
57+
it('matches case-insensitively (ES → es)', () => {
58+
loadCatalog('ES');
59+
expect(t('Add')).toBe('Añadir');
60+
});
61+
62+
it('matches pt-br to the pt catalog', () => {
63+
loadCatalog('pt-br');
64+
expect(t('Save')).toBe('Salvar');
65+
});
66+
67+
it('falls back to English (no-op) on unknown codes', () => {
68+
_setActiveCatalogForTests({ Add: 'Añadir' }); // start non-English
69+
loadCatalog('zz-ZZ'); // unknown — leaves the previous catalog active
70+
expect(t('Add')).toBe('Añadir');
71+
});
72+
73+
it('is a no-op when language is null / empty', () => {
74+
loadCatalog('es');
75+
loadCatalog(null);
76+
expect(t('Add')).toBe('Añadir'); // still Spanish
77+
loadCatalog('');
78+
expect(t('Add')).toBe('Añadir'); // still Spanish
79+
});
80+
});
81+
82+
describe('bundled catalog sanity (#630 ship-set)', () => {
83+
it('Spanish translates the common chrome strings', () => {
84+
loadCatalog('es');
85+
expect(t('Add')).toBe('Añadir');
86+
expect(t('Save')).toBe('Guardar');
87+
expect(t('Cancel')).toBe('Cancelar');
88+
expect(t('Loading…')).toBe('Cargando…');
89+
});
90+
91+
it('Portuguese translates the common chrome strings', () => {
92+
loadCatalog('pt');
93+
expect(t('Add')).toBe('Adicionar');
94+
expect(t('Save')).toBe('Salvar');
95+
expect(t('Delete')).toBe('Excluir');
96+
});
97+
98+
it('French translates the common chrome strings', () => {
99+
loadCatalog('fr');
100+
expect(t('Add')).toBe('Ajouter');
101+
expect(t('Save')).toBe('Enregistrer');
102+
expect(t('Delete')).toBe('Supprimer');
103+
});
104+
105+
it('English keeps the source strings unchanged (source-as-key)', () => {
106+
loadCatalog('en');
107+
// The English catalog only has the _comment; every real string
108+
// round-trips through the source-as-key fallback.
109+
expect(t('Add')).toBe('Add');
110+
expect(t('Anything not in the catalog')).toBe('Anything not in the catalog');
111+
});
112+
113+
it('exposes the active catalog for debugging tests', () => {
114+
loadCatalog('es');
115+
const catalog = _activeCatalogForTests();
116+
expect(catalog.Add).toBe('Añadir');
117+
});
118+
});

frontend/packages/ui/src/i18n.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// SPA chrome i18n (#630). Minimum-viable message-catalog
2+
// infrastructure so the package's own user-visible strings ("Add",
3+
// "Search", "Save and continue editing", "Loading…") can be
4+
// translated per the request's active locale — not just the
5+
// API-payload `verbose_name` / `help_text` that already comes back
6+
// translated when `LocaleMiddleware` is in the consumer's MIDDLEWARE
7+
// stack.
8+
//
9+
// Design:
10+
// - Catalog keys are the English source string itself (gettext
11+
// convention). Removes a layer of indirection: refactoring a
12+
// string in JSX doesn't require a parallel catalog-key change.
13+
// - The active catalog is hydrated once at boot from the bundled
14+
// JSON files keyed by the `dar-language` meta tag the server
15+
// renders into `index.html`. Missing key → English source
16+
// string returned (graceful degradation; the operator sees
17+
// legible text even mid-migration as keys land).
18+
// - Synchronous API. The catalog is bundled into the SPA JS so
19+
// there's no fetch / loading state — the first paint already
20+
// carries the translations.
21+
//
22+
// Adding a new language: drop a JSON file under `src/i18n/` keyed
23+
// by the locale code (matches Django's `LANGUAGE_CODE`), import it
24+
// in `loadCatalog`, ship.
25+
26+
import enCatalog from './i18n/en.json';
27+
import esCatalog from './i18n/es.json';
28+
import ptCatalog from './i18n/pt.json';
29+
import frCatalog from './i18n/fr.json';
30+
31+
type Catalog = Readonly<Record<string, string>>;
32+
33+
const CATALOGS: Readonly<Record<string, Catalog>> = {
34+
en: enCatalog,
35+
es: esCatalog,
36+
pt: ptCatalog,
37+
'pt-br': ptCatalog,
38+
fr: frCatalog,
39+
};
40+
41+
let activeCatalog: Catalog = enCatalog;
42+
43+
/**
44+
* Hydrate the active catalog from the language code the server
45+
* rendered into ``<meta name="dar-language">``. Falls back to
46+
* English when the code is unknown / unset / malformed.
47+
*
48+
* Matching is exact first, then language-stem only (``es-AR`` →
49+
* tries ``es-AR``, then ``es``). The fallback chain is one level
50+
* deep — enough for ``language-region`` codes; over-engineered
51+
* regional-fallback chains aren't shipped here.
52+
*/
53+
export function loadCatalog(language: string | null | undefined): void {
54+
if (!language) return;
55+
const key = language.toLowerCase();
56+
const exact = CATALOGS[key];
57+
if (exact) {
58+
activeCatalog = exact;
59+
return;
60+
}
61+
const stem = key.split('-')[0];
62+
const fallback = stem ? CATALOGS[stem] : undefined;
63+
if (fallback) {
64+
activeCatalog = fallback;
65+
}
66+
}
67+
68+
/**
69+
* Translate the English source string ``en`` to the active
70+
* catalog's translation, falling back to ``en`` itself when the
71+
* key isn't in the catalog.
72+
*
73+
* The English text is BOTH the source and the catalog key
74+
* (gettext convention). Editing the JSX-side string without
75+
* updating the catalog gracefully degrades to English; CI lint
76+
* can grep for keys present in catalogs but not in source to
77+
* surface dead translations.
78+
*/
79+
export function t(en: string): string {
80+
return activeCatalog[en] ?? en;
81+
}
82+
83+
/** Test-only: swap the active catalog. Production code calls
84+
* ``loadCatalog`` once at boot from ``main.tsx``. */
85+
export function _setActiveCatalogForTests(catalog: Catalog): void {
86+
activeCatalog = catalog;
87+
}
88+
89+
/** Test-only: read the currently active language's catalog. */
90+
export function _activeCatalogForTests(): Catalog {
91+
return activeCatalog;
92+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"_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."
3+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"_comment": "Spanish (es) catalog. Keys are the English source string; values are the translation. Missing keys fall back to English at runtime.",
3+
4+
"Add": "Añadir",
5+
"Search": "Buscar",
6+
"Save": "Guardar",
7+
"Save and continue editing": "Guardar y seguir editando",
8+
"Save and add another": "Guardar y añadir otro",
9+
"Save as new": "Guardar como nuevo",
10+
"Edit": "Editar",
11+
"Delete": "Eliminar",
12+
"Cancel": "Cancelar",
13+
"Confirm": "Confirmar",
14+
"Refresh": "Refrescar",
15+
"Loading…": "Cargando…",
16+
"Clear all": "Limpiar todo",
17+
"Clear all filters": "Limpiar todos los filtros",
18+
"Customize columns": "Personalizar columnas",
19+
"Layout": "Diseño",
20+
"Layout is already at default": "El diseño ya está en el valor predeterminado",
21+
"Reset": "Restablecer",
22+
"Done": "Hecho",
23+
"Discard": "Descartar",
24+
"Save changes": "Guardar cambios",
25+
"Saving…": "Guardando…",
26+
"Sign in": "Iniciar sesión",
27+
"Sign out": "Cerrar sesión",
28+
"Username": "Nombre de usuario",
29+
"Password": "Contraseña",
30+
"History": "Historial",
31+
"Available": "Disponibles",
32+
"Chosen": "Elegidos",
33+
"Choose all": "Elegir todo",
34+
"Remove all": "Quitar todo",
35+
"Filter": "Filtrar",
36+
"No matches.": "Sin coincidencias.",
37+
"Nothing selected yet.": "Aún no hay nada seleccionado.",
38+
"Open in a new tab": "Abrir en una pestaña nueva",
39+
"Click to open in a new tab": "Clic para abrir en una pestaña nueva",
40+
"Dismiss": "Descartar",
41+
"Look up related object in a new tab": "Buscar objeto relacionado en pestaña nueva",
42+
"Lookup ↗": "Buscar ↗",
43+
"Action failed.": "La acción falló.",
44+
"The action could not be completed.": "No se pudo completar la acción.",
45+
"Couldn't load the list": "No se pudo cargar la lista",
46+
"This page requires JavaScript.": "Esta página requiere JavaScript."
47+
}

0 commit comments

Comments
 (0)