Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions django_admin_react/templates/admin_react/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
{% if legacy_admin_prefix %}<meta name="dar-legacy-admin-prefix" content="{{ legacy_admin_prefix }}" />{% endif %}
<meta name="dar-brand-title" content="{{ brand_title }}" />
{% if brand_logo_url %}<meta name="dar-brand-logo" content="{{ 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 %}
<meta name="dar-language" content="{{ active_language }}" />
{% comment %}
The tab <title> uses the AdminSite's site_title (Django's tab-title
source); the sidebar header reads dar-brand-title (site_header). Both
Expand Down
30 changes: 30 additions & 0 deletions django_admin_react/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).

Expand Down
10 changes: 10 additions & 0 deletions frontend/apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,23 @@ 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';

// Apply the saved (or system-default) light/dark theme before React
// 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="...">``;
Expand Down
15 changes: 8 additions & 7 deletions frontend/packages/form/src/ShuttleSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions frontend/packages/ui/src/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
92 changes: 92 additions & 0 deletions frontend/packages/ui/src/i18n.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions frontend/packages/ui/src/i18n/en.json
Original file line number Diff line number Diff line change
@@ -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."
}
47 changes: 47 additions & 0 deletions frontend/packages/ui/src/i18n/es.json
Original file line number Diff line number Diff line change
@@ -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."
}
Loading
Loading