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 ```` 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
+// `` (#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('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 ````;
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 (
{
const pk = pkOf(c);
if (pk !== null) addOne(pk);
}}
- actionLabel="Choose all"
+ actionLabel={t('Choose all')}
onAction={() => addMany(visibleAvail)}
filter={availFilter}
setFilter={setAvailFilter}
/>
{
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"
/>
{
+ // 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>;
+
+const CATALOGS: Readonly> = {
+ 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 ````. 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
+// ``; 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 ```` 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 ```` 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()