taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 01cc312b83210b1eaaa606d7f4c94e6ebc3a2275
parent 74e46857a714c345691d303a7ebfafca3b29cbee
Author: Florian Dold <dold@taler.net>
Date:   Mon,  7 Sep 2026 15:20:13 +0200

bank web UI: select the visit language from the URL

Apply supported lang query values before saved and browser preferences
without changing the saved language. A manual selection replaces the
visit override and persists normally.

Issue: https://bugs.taler.net/n/7309

Diffstat:
Mpackages/libeufin-bank-webui/README.md | 10++++++++++
Mpackages/libeufin-bank-webui/src/app.tsx | 7++++++-
Mpackages/web-util/src/context/translation.ts | 23+++++++++++++++++------
Mpackages/web-util/src/hooks/useLang.test.tsx | 107++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 139 insertions(+), 8 deletions(-)

diff --git a/packages/libeufin-bank-webui/README.md b/packages/libeufin-bank-webui/README.md @@ -12,6 +12,16 @@ Run `./configure --prefix=PREFIX && make install` to build and install the WebUI under `PREFIX/share/libeufin-bank-webui`. `DESTDIR` is supported for staged installations. +## Language selection + +Open `/webui/?lang=de` to select a supported language for the current visit. +The URL selection takes precedence over saved and browser preferences without +changing the saved preference. Empty or unsupported values are ignored. +Regional codes such as `de_CH` and `de-CH` select the same supported locale. +The language switcher replaces the visit selection and saves the new preference +normally. Reloading the URL applies its language again. Put the query before +any hash route, for example `/webui/?lang=de#/register`. + ## Testing By default, the bank UI expects the backend at `window.origin`. Open `#/dev` diff --git a/packages/libeufin-bank-webui/src/app.tsx b/packages/libeufin-bank-webui/src/app.tsx @@ -68,7 +68,12 @@ const WITH_LOCAL_STORAGE_CACHE = false; export function App() { return ( - <TranslationProvider source={strings}> + <TranslationProvider + source={strings} + initialLanguageOverride={ + new URLSearchParams(window.location.search).get("lang") ?? undefined + } + > <ConfiguredApp /> </TranslationProvider> ); diff --git a/packages/web-util/src/context/translation.ts b/packages/web-util/src/context/translation.ts @@ -20,7 +20,7 @@ import { setupI18n, } from "@gnu-taler/taler-util"; import { ComponentChildren, createContext, h, VNode } from "preact"; -import { useContext, useEffect, useMemo, useRef } from "preact/hooks"; +import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { strings as webUtilStrings, StringsType } from "../i18n/strings.js"; import { useLang } from "../hooks/index.js"; import { Locale } from "date-fns"; @@ -78,6 +78,8 @@ function canonicalLanguageCode(language: string): string { interface Props { initial?: string; + /** Applied once on mount, without saving; a manual selection replaces it. */ + initialLanguageOverride?: string; children: ComponentChildren; /** ONLY USER FOR TESTING */ forceLang__testing?: string; @@ -87,6 +89,7 @@ interface Props { // Outmost UI wrapper. export const TranslationProvider = ({ initial, + initialLanguageOverride, children, forceLang__testing: forceLang, source, @@ -118,10 +121,16 @@ export const TranslationProvider = ({ { en: 100 } as Record<string, number>, ); - const { value: lang, update: changeLanguage } = useLang( - initial, - completeness, - ); + const [languageOverride, setLanguageOverride] = useState(() => { + if (!initialLanguageOverride) return undefined; + const requested = canonicalLanguageCode(initialLanguageOverride); + return Object.hasOwn(supportedLang, requested) ? requested : undefined; + }); + const { value: lang, update: saveLanguage } = useLang(initial, completeness); + function changeLanguage(language: string): void { + setLanguageOverride(undefined); + saveLanguage(language); + } const changeLanguageRef = useRef(changeLanguage); changeLanguageRef.current = changeLanguage; @@ -130,7 +139,9 @@ export const TranslationProvider = ({ changeLanguageRef.current(forceLang); } }, [forceLang]); - const effectiveLang = canonicalLanguageCode(forceLang ?? lang); + const effectiveLang = canonicalLanguageCode( + forceLang ?? languageOverride ?? lang, + ); setupI18n(effectiveLang, mergedSource); const dateLocale = diff --git a/packages/web-util/src/hooks/useLang.test.tsx b/packages/web-util/src/hooks/useLang.test.tsx @@ -53,8 +53,9 @@ function catalog(lang: string): StringsType { }; } -function Harness() { +function Harness({ onRender }: { onRender?: (lang: string) => void }) { const context = useTranslationContext(); + onRender?.(context.lang); return ( <div> <output data-testid="language">{context.lang}</output> @@ -100,3 +101,107 @@ test("an explicit de-CH selection is persisted intact", async () => { await window.happyDOM.abort(); } }); + +for (const [requested, expected] of [ + ["de_CH", "de-CH"], + ["DE-ch", "de-CH"], + ["en", "en"], + ["fr", "fr"], +] as const) { + test(`visit language ${requested} overrides preferences without saving`, async () => { + const window = installDom("de-CH"); + window.localStorage.setItem("lang-preference", JSON.stringify("de")); + const { cleanup, render } = await import("@testing-library/preact"); + const rendered: string[] = []; + try { + const view = render( + <TranslationProvider + source={{ + de: catalog("de"), + de_CH: catalog("de-CH"), + fr: { ...catalog("fr"), completeness: 1 }, + }} + initialLanguageOverride={requested} + > + <Harness onRender={(lang) => rendered.push(lang)} /> + </TranslationProvider>, + ); + assert.equal(rendered[0], expected); + assert.equal(view.getByTestId("language").textContent, expected); + assert.equal(window.localStorage.getItem("lang-preference"), '"de"'); + } finally { + cleanup(); + await window.happyDOM.abort(); + } + }); +} + +for (const requested of [undefined, "", "xx", "toString", "__proto__"]) { + test(`invalid or absent visit language ${requested} keeps normal selection`, async () => { + const window = installDom("de-CH"); + const { cleanup, render } = await import("@testing-library/preact"); + const source = { de_CH: catalog("de-CH") }; + try { + const view = render( + <TranslationProvider + source={source} + initialLanguageOverride={requested} + > + <Harness /> + </TranslationProvider>, + ); + assert.equal(view.getByTestId("language").textContent, "de-CH"); + assert.equal(window.localStorage.getItem("lang-preference"), null); + cleanup(); + window.localStorage.setItem("lang-preference", '"en"'); + const savedView = render( + <TranslationProvider + source={source} + initialLanguageOverride={requested} + > + <Harness /> + </TranslationProvider>, + ); + assert.equal(savedView.getByTestId("language").textContent, "en"); + } finally { + cleanup(); + await window.happyDOM.abort(); + } + }); +} + +test("manual selection replaces the visit override through navigation and rerenders", async () => { + const window = installDom("en"); + window.history.replaceState(null, "", "/webui/?lang=fr#/register"); + const { cleanup, render } = await import("@testing-library/preact"); + const source = { de_CH: catalog("de-CH"), fr: catalog("fr") }; + function App() { + return ( + <TranslationProvider + source={source} + initialLanguageOverride={ + new URLSearchParams(window.location.search).get("lang") ?? undefined + } + > + <Harness /> + </TranslationProvider> + ); + } + try { + const view = render(<App />); + assert.equal(view.getByTestId("language").textContent, "fr"); + assert.equal(window.localStorage.getItem("lang-preference"), null); + await act(() => view.getByText("Swiss").click()); + window.location.hash = "#/public-accounts"; + view.rerender(<App />); + assert.equal(view.getByTestId("language").textContent, "de-CH"); + assert.equal(window.localStorage.getItem("lang-preference"), '"de-CH"'); + cleanup(); + const reloaded = render(<App />); + assert.equal(reloaded.getByTestId("language").textContent, "fr"); + assert.equal(window.localStorage.getItem("lang-preference"), '"de-CH"'); + } finally { + cleanup(); + await window.happyDOM.abort(); + } +});