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:
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();
+ }
+});