From 367ce127b740c9c20317a28b277e772e18c41921 Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:37:54 +0100 Subject: [PATCH 01/29] ui: save the language choice before the subscribers are told setLanguage called activate(), which emits, and wrote the choice to localStorage afterwards. useI18n() reads personalLang() while it renders, so right after a switch the person had no choice of their own and "Use the instance default" was missing until a reload. activate() now runs a persist callback after the catalogue has loaded and before it emits; a catalogue that fails to load still saves nothing. Co-Authored-By: Claude Sonnet 5.5 --- web/packages/ui/src/i18n/store.ts | 23 +++-- web/packages/ui/src/i18n/switch.test.ts | 124 ++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 web/packages/ui/src/i18n/switch.test.ts diff --git a/web/packages/ui/src/i18n/store.ts b/web/packages/ui/src/i18n/store.ts index 78add39e..8f78c4df 100644 --- a/web/packages/ui/src/i18n/store.ts +++ b/web/packages/ui/src/i18n/store.ts @@ -187,11 +187,18 @@ export async function initI18n(opts: { loaders: Loaders[]; doc?: PageDocument }) if (!own) writeCookie(S.lang); } -async function activate(lang: Lang) { +/** + * Make `lang` the active language and tell the subscribers. `persist` runs after the catalogue has + * loaded and the state is set but before the subscribers are told: they read the person's saved + * choice (`personalLang()`, in `useI18n()`) while they render, so it has to be saved by then. A + * language whose catalogue fails to load throws before `persist`, so it is never saved. + */ +async function activate(lang: Lang, persist?: () => void) { const messages = await loadCatalogue(lang); S.lang = lang; S.messages = messages; applyDocument(); + persist?.(); emit(); } @@ -202,11 +209,15 @@ export function provideCatalogue(lang: Lang, messages: Catalogue) { /** The person chooses a language: loaded, applied, remembered and sent to the server as a cookie. */ export async function setLanguage(lang: Lang, persist = true): Promise { - await activate(lang); - if (persist && !isPseudo(lang)) { - lsSet(LS_LANG, lang); - writeCookie(lang); - } + await activate( + lang, + persist && !isPseudo(lang) + ? () => { + lsSet(LS_LANG, lang); + writeCookie(lang); + } + : undefined, + ); } /** Forget the personal choice; the instance default or the browser decides again. */ diff --git a/web/packages/ui/src/i18n/switch.test.ts b/web/packages/ui/src/i18n/switch.test.ts new file mode 100644 index 00000000..a491407b --- /dev/null +++ b/web/packages/ui/src/i18n/switch.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + getLang, + initI18n, + personalLang, + resetLanguage, + setInstanceDefaults, + setLanguage, +} from "./index.ts"; +import { subscribe } from "./store.ts"; + +// A component renders when the store tells it the language changed, and what it reads while it +// renders (`useI18n()` reads `personalLang()`) must already be the new state. What happens, in what +// order, is recorded in `log`: a write to the storage or the cookie, and each time a subscriber runs. +const log: string[] = []; +/** What a subscriber that reads the saved choice, as `useI18n()` does, saw each time it ran. */ +const seen: (string | null)[] = []; + +/** A browser with a storage, a cookie jar and a document, all writing to `log`. */ +function browser(saved?: string) { + const store = new Map(); + if (saved) store.set("obop-lang", saved); + vi.stubGlobal("localStorage", { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => { + log.push(`storage:${k}=${v}`); + store.set(k, v); + }, + removeItem: (k: string) => { + log.push(`storage:${k} removed`); + store.delete(k); + }, + }); + vi.stubGlobal("document", { + documentElement: { lang: "", dir: "" }, + set cookie(v: string) { + log.push(`cookie:${v.split(";")[0]}`); + }, + }); + vi.stubGlobal("location", { search: "", protocol: "http:" }); + vi.stubGlobal("navigator", { languages: ["en-US"], language: "en-US" }); +} + +let unsubscribe = () => {}; +function watch() { + unsubscribe = subscribe(() => { + seen.push(personalLang()); + log.push("subscribers"); + }); +} + +afterEach(async () => { + unsubscribe(); + vi.unstubAllGlobals(); + log.length = 0; + seen.length = 0; + await setInstanceDefaults({}); + await initI18n({ loaders: [] }); + await setLanguage("en", false); +}); + +describe("choosing a language", () => { + it("is saved before the subscribers run, so they see the choice", async () => { + browser(); + watch(); + await setLanguage("de"); + expect(getLang()).toBe("de"); + expect(seen).toEqual(["de"]); + expect(log).toEqual(["storage:obop-lang=de", "cookie:obop_lang=de", "subscribers"]); + }); + + it("is seen by a person who had no choice and one who is changing it", async () => { + browser(); + watch(); + expect(personalLang()).toBeNull(); + await setLanguage("fi"); + await setLanguage("sv"); + expect(seen).toEqual(["fi", "sv"]); + expect(personalLang()).toBe("sv"); + }); + + it("saves nothing when asked not to, and never saves a pseudo-locale", async () => { + browser(); + watch(); + await setLanguage("de", false); + await setLanguage("en-XA"); + await setLanguage("ar-XB"); + expect(getLang()).toBe("ar-XB"); + expect(personalLang()).toBeNull(); + expect(seen).toEqual([null, null, null]); + expect(log).toEqual(["subscribers", "subscribers", "subscribers"]); + }); + + it("is not saved when its catalogue cannot be loaded, and nobody is told", async () => { + browser("de"); + await initI18n({ + loaders: [{ "./es/messages.json": () => Promise.reject(new Error("offline")) }], + }); + log.length = 0; + watch(); + await expect(setLanguage("es")).rejects.toThrow("offline"); + expect(getLang()).toBe("de"); + expect(personalLang()).toBe("de"); + expect(seen).toEqual([]); + expect(log).toEqual([]); + }); +}); + +describe("going back to the default", () => { + it("forgets the choice before the subscribers run, so they see none", async () => { + browser("de"); + await initI18n({ loaders: [] }); + expect(getLang()).toBe("de"); + watch(); + log.length = 0; + await resetLanguage(); + expect(personalLang()).toBeNull(); + expect(getLang()).toBe("en"); // the browser asks for English + expect(seen).toEqual([null]); + expect(log[0]).toBe("storage:obop-lang removed"); + expect(log).toContain("subscribers"); + expect(log.indexOf("storage:obop-lang removed")).toBeLessThan(log.indexOf("subscribers")); + }); +}); From 23fb041270460901c505f08008ae82696ca20b32 Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:38:05 +0100 Subject: [PATCH 02/29] back office: build the app list again when the language or the region changes The apps (names, tile order, top bar, sidebar header, launcher, command palette) came from a useMemo that listed the modules, the settings and isAdmin but not the language, so after a live switch they kept the old language until a reload. Add lang and locale to its dependencies. The name field of the Apps tab in Customize is uncontrolled (defaultValue) and a browser keeps what a field was made with, so with the list rebuilt a plain focus and blur saved the old name for everyone as a custom name. key={a.name} makes the field again when the name changes. Co-Authored-By: Claude Sonnet 5.5 --- web/apps/backoffice/src/App.tsx | 10 ++++++++-- web/apps/backoffice/src/Customize.tsx | 3 +++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/web/apps/backoffice/src/App.tsx b/web/apps/backoffice/src/App.tsx index cb0fff11..3d3da386 100644 --- a/web/apps/backoffice/src/App.tsx +++ b/web/apps/backoffice/src/App.tsx @@ -1,5 +1,5 @@ import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { setInstanceDefaults, t } from "@obop/ui"; +import { setInstanceDefaults, t, useI18n } from "@obop/ui"; import { loadSettings, meta, type ModuleMeta, type Settings } from "./api.ts"; import { AppIcon, Launcher, Palette, TopBar, appBlurb, appHref, buildApps } from "./Chrome.tsx"; import { ConnectorsApp, ConnectorsNav } from "./connectors/ConnectorsApp.tsx"; @@ -170,7 +170,13 @@ export function App() { const isAdmin = modules.some( (m) => m.id === "platform" && m.models.some((x) => x.name === "module"), ); - const apps = useMemo(() => buildApps(modules, settings, isAdmin), [modules, settings, isAdmin]); + // The names of the apps and their order are translated and sorted when the list is built, so it is + // built again when the language or the region changes (the root renders again, but a memo keeps its value). + const { lang, locale } = useI18n(); + const apps = useMemo( + () => buildApps(modules, settings, isAdmin), + [modules, settings, isAdmin, lang, locale], + ); const app = apps.find((a) => a.id === route.app); // Everyone may set their own theme and read the brand book; the rest of Customize is for admins. const personalCustomize = diff --git a/web/apps/backoffice/src/Customize.tsx b/web/apps/backoffice/src/Customize.tsx index a77ef7ea..3e140e65 100644 --- a/web/apps/backoffice/src/Customize.tsx +++ b/web/apps/backoffice/src/Customize.tsx @@ -441,6 +441,9 @@ function AppsTab({ apps, settings, isAdmin, token, onSettings, notify }: Props)
  • From 84cb1432b696da370e9ee041e1c794889447f639 Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:38:16 +0100 Subject: [PATCH 03/29] back office: the related-record buttons follow the language of an open record The memo of the smart buttons (titles of the related models, tooltips) listed d.models and the model name, so a switch with the account menu on an open record kept the old titles. Co-Authored-By: Claude Sonnet 5.5 --- web/apps/backoffice/src/RecordPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/apps/backoffice/src/RecordPanel.tsx b/web/apps/backoffice/src/RecordPanel.tsx index 4336425b..e6746c13 100644 --- a/web/apps/backoffice/src/RecordPanel.tsx +++ b/web/apps/backoffice/src/RecordPanel.tsx @@ -290,7 +290,7 @@ export function RecordPanel({ return out.map((r) => (models.get(r.title)?.size ?? 0) > 1 ? { ...r, title: `${r.app}: ${r.title}` } : r, ); - }, [d.models, model.name]); + }, [d.models, model.name, d.lang]); return ( <> From 8271b96595c634a24bfb48ee6f4594182b557758 Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:38:28 +0100 Subject: [PATCH 04/29] back office: four more memos that translate list the language in their dependencies The command palette, the search suggestions of the control panel, the model choices of the module builder and the readable models of the widget form build translated texts in a useMemo. Only the module builder's could show the old language (its memo depends on the modules alone, so a switch with the account menu on that tab kept the old names); the others were saved by an input that changes on every render, or by the app list, which now changes with the language. Co-Authored-By: Claude Sonnet 5.5 --- web/apps/backoffice/src/Chrome.tsx | 3 ++- web/apps/backoffice/src/ControlPanel.tsx | 5 +++-- web/apps/backoffice/src/ModuleBuilder.tsx | 5 +++-- web/apps/backoffice/src/dashboards/WidgetForm.tsx | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/web/apps/backoffice/src/Chrome.tsx b/web/apps/backoffice/src/Chrome.tsx index 30113f42..86b6f49f 100644 --- a/web/apps/backoffice/src/Chrome.tsx +++ b/web/apps/backoffice/src/Chrome.tsx @@ -234,6 +234,7 @@ type Hit = { key: string; title: string; hint: string; to: string; app?: App }; export function Palette({ apps, onClose }: { apps: App[]; onClose: () => void }) { const [q, setQ] = useState(""); const [sel, setSel] = useState(0); + const { lang } = useI18n(); const hits = useMemo(() => { const all: Hit[] = []; for (const a of apps) { @@ -307,7 +308,7 @@ export function Palette({ apps, onClose }: { apps: App[]; onClose: () => void }) // Every word has to match somewhere in the title, so "write module" finds "How to write a custom module". const words = needle.split(/\s+/).filter(Boolean); return all.filter((h) => words.every((w) => h.title.toLowerCase().includes(w))).slice(0, 12); - }, [apps, q]); + }, [apps, q, lang]); useEffect(() => setSel(0), [q]); const go = (h: Hit | undefined) => { if (!h) return; diff --git a/web/apps/backoffice/src/ControlPanel.tsx b/web/apps/backoffice/src/ControlPanel.tsx index 60155766..0bd10170 100644 --- a/web/apps/backoffice/src/ControlPanel.tsx +++ b/web/apps/backoffice/src/ControlPanel.tsx @@ -1,5 +1,5 @@ import { useId, useMemo, useRef, useState, type ReactNode } from "react"; -import { inSentence, t, type IconName } from "@obop/ui"; +import { inSentence, t, useI18n, type IconName } from "@obop/ui"; import type { FieldMeta } from "./api.ts"; import type { Favorite } from "./favorites.ts"; import { FilterPanel } from "./FilterPanel.tsx"; @@ -32,6 +32,7 @@ type Props = { /** Breadcrumbs, the faceted search box with its Filters/Group By/Favorites panel, and the view switcher. */ export function ControlPanel(p: Props) { const { state, setState, fields, lookup } = p; + const { lang } = useI18n(); const [text, setText] = useState(""); const [open, setOpen] = useState(false); const [suggest, setSuggest] = useState(false); @@ -60,7 +61,7 @@ export function ControlPanel(p: Props) { cond: { op: "contains", f: f.name, v: q }, }); return all; - }, [text, searchable]); + }, [text, searchable, lang]); const add = (c: Cond) => { setState({ ...state, facets: [...state.facets, c] }); diff --git a/web/apps/backoffice/src/ModuleBuilder.tsx b/web/apps/backoffice/src/ModuleBuilder.tsx index 5ff14de9..75831f21 100644 --- a/web/apps/backoffice/src/ModuleBuilder.tsx +++ b/web/apps/backoffice/src/ModuleBuilder.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { msg, t } from "@obop/ui"; +import { msg, t, useI18n } from "@obop/ui"; import { call, getCoreVersion, type Kind, type ModuleMeta } from "./api.ts"; import { label as labelOf } from "./format.ts"; import { appTitle, modelTitle } from "./labels.ts"; @@ -174,6 +174,7 @@ export function manifest( } export function BuildTab({ token, modules, onModulesChanged, notify }: Props) { + const { lang } = useI18n(); const [name, setName] = useState(() => t("Fleet check")); const [modelName, setModelName] = useState(() => t("Inspection")); const [fields, setFields] = useState(() => [ @@ -201,7 +202,7 @@ export function BuildTab({ token, modules, onModulesChanged, notify }: Props) { text: `${appTitle(m)}: ${modelTitle(x, m.id)}`, })), ), - [modules], + [modules, lang], ); // The versions of what is installed, so the ranges the module declares start from what it was // built against: the core's series, and for each module it links to, `^`. diff --git a/web/apps/backoffice/src/dashboards/WidgetForm.tsx b/web/apps/backoffice/src/dashboards/WidgetForm.tsx index a7e9e65f..4134170c 100644 --- a/web/apps/backoffice/src/dashboards/WidgetForm.tsx +++ b/web/apps/backoffice/src/dashboards/WidgetForm.tsx @@ -65,7 +65,7 @@ function useReadable() { out.push({ model: m, text: `${a.name} › ${modelTitle(m, a.module.id)}` }); } return out; - }, [d.apps]); + }, [d.apps, d.lang]); } export function WidgetForm(p: { From d9d1668b730949d8a0b29d827af8ba2e6bcce3fa Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:38:28 +0100 Subject: [PATCH 05/29] back office: server queries ask again when the locale changes The server sorts text in the collation of the locale it is told (api.ts sends it at fetch time), but the keys of useServerRows, useAggregate and the Kanban columns did not hold the locale, so a list sorted on a text column kept the old order after a switch. Co-Authored-By: Claude Sonnet 5.5 --- web/apps/backoffice/src/useAggregate.ts | 13 ++++++++----- web/apps/backoffice/src/useServer.ts | 9 ++++++--- web/apps/backoffice/src/views/KanbanView.tsx | 8 +++++--- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/web/apps/backoffice/src/useAggregate.ts b/web/apps/backoffice/src/useAggregate.ts index 21fa0349..2ff3f348 100644 --- a/web/apps/backoffice/src/useAggregate.ts +++ b/web/apps/backoffice/src/useAggregate.ts @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { useI18n } from "@obop/ui"; import { aggregate, type AggGroup, @@ -25,9 +26,10 @@ export type Aggregated = { const idle: Omit = { groups: [], total: 0, loaded: false, loading: false }; /** - * Groups and totals from `.search` with `aggregate` (D4). It asks again when the query - * changes and after any successful write, ignores answers that arrive late and keeps the previous - * answer on screen while the next one loads. + * Groups and totals from `.search` with `aggregate` (D4). It asks again when the query or the + * locale changes (the server orders the groups in the collation of the locale it is told) and after + * any successful write, ignores answers that arrive late and keeps the previous answer on screen + * while the next one loads. */ export function useAggregate(opts: { enabled: boolean; @@ -37,11 +39,12 @@ export function useAggregate(opts: { measures: AggMeasure[]; }): Aggregated { const d = useData(); + const { locale } = useI18n(); const [st, setSt] = useState>(idle); const [attempt, setAttempt] = useState(0); const seq = useRef(0); const { enabled, model, filter, groupBy, measures } = opts; - const key = JSON.stringify([model, filter, groupBy, measures]); + const key = JSON.stringify([model, filter, groupBy, measures, locale]); useEffect(() => { if (!enabled) return; @@ -53,7 +56,7 @@ export function useAggregate(opts: { setSt((s) => ({ ...s, loading: false, loaded: true, error: r.error, status: r.status })); else setSt({ groups: r.groups, total: r.total, loaded: true, loading: false }); }); - // The key stands for the model, filter, grouping and measures. + // The key stands for the model, filter, grouping, measures and locale. // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, d.token, key, d.mutations, attempt]); diff --git a/web/apps/backoffice/src/useServer.ts b/web/apps/backoffice/src/useServer.ts index 163b79d2..fe540040 100644 --- a/web/apps/backoffice/src/useServer.ts +++ b/web/apps/backoffice/src/useServer.ts @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { useI18n } from "@obop/ui"; import { call, search, type Row, type ServerFilter, type ServerOrder } from "./api.ts"; import { useData } from "./data.tsx"; @@ -17,7 +18,8 @@ export type ServerRows = { const idle: ServerRows = { rows: [], total: 0, denied: [], loaded: false, loading: false }; /** - * One page of a model from `.search`. It asks again when the query changes and after any + * One page of a model from `.search`. It asks again when the query changes, when the locale + * changes (the server sorts text in the collation of the locale it is told) and after any * successful write, ignores answers that arrive late, and keeps the previous page on screen while * the next one loads so the table does not flash. */ @@ -30,10 +32,11 @@ export function useServerRows(opts: { limit: number; }): ServerRows { const d = useData(); + const { locale } = useI18n(); const [st, setSt] = useState(idle); const seq = useRef(0); const { enabled, model, filter, order, offset, limit } = opts; - const key = JSON.stringify([model, filter, order, offset, limit]); + const key = JSON.stringify([model, filter, order, offset, limit, locale]); useEffect(() => { if (!enabled) return; @@ -55,7 +58,7 @@ export function useServerRows(opts: { loading: false, }); }); - // The key stands for model, filter, order, offset and limit. + // The key stands for model, filter, order, offset, limit and locale. // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, d.token, key, d.mutations]); diff --git a/web/apps/backoffice/src/views/KanbanView.tsx b/web/apps/backoffice/src/views/KanbanView.tsx index f3913bfb..46424784 100644 --- a/web/apps/backoffice/src/views/KanbanView.tsx +++ b/web/apps/backoffice/src/views/KanbanView.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from "react"; -import { fmt as fmtLocale, inSentence, t } from "@obop/ui"; +import { fmt as fmtLocale, inSentence, t, useI18n } from "@obop/ui"; import { search, type FieldMeta, type Row, type ServerFilter } from "../api.ts"; import { Cell } from "../fields.tsx"; import { labelField, useData } from "../data.tsx"; @@ -69,7 +69,8 @@ type ColumnState = { rows: Row[]; total: number; loading: boolean; error?: strin /** * The cards of each column from `.search`, one page per column (D4): a column asks for its * first cards and again for more when "Load more" raises its limit. Columns refetch after any - * write or when the search changes; the cards already shown stay until the new ones arrive. + * write or when the search or the locale (the server sorts the cards in its collation) changes; the + * cards already shown stay until the new ones arrive. */ function useColumnCards(opts: { enabled: boolean; @@ -82,11 +83,12 @@ function useColumnCards(opts: { limits: Record; }) { const d = useData(); + const { locale } = useI18n(); const { enabled, model, base, order, field, keys, folded, limits } = opts; const [cols, setCols] = useState>({}); const seen = useRef(new Map()); const seq = useRef(0); - const baseSig = JSON.stringify([model, base, order, field?.name, d.mutations]); + const baseSig = JSON.stringify([model, base, order, field?.name, d.mutations, locale]); const [attempt, setAttempt] = useState(0); useEffect(() => { From 1787afe6ab372e9d34816227f92c6a612a70efa5 Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:38:29 +0100 Subject: [PATCH 06/29] back office: the sidebar tooltip of a record shows the description in the language title={m.description} was English in every language; describeFor(m) picks the description of the active language (all bundled models have one in de, fr, pl, fi, sv and es) and falls back to the English one. Co-Authored-By: Claude Sonnet 5.5 --- web/apps/backoffice/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/apps/backoffice/src/App.tsx b/web/apps/backoffice/src/App.tsx index 3d3da386..f600f5c1 100644 --- a/web/apps/backoffice/src/App.tsx +++ b/web/apps/backoffice/src/App.tsx @@ -1,5 +1,5 @@ import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { setInstanceDefaults, t, useI18n } from "@obop/ui"; +import { describeFor, setInstanceDefaults, t, useI18n } from "@obop/ui"; import { loadSettings, meta, type ModuleMeta, type Settings } from "./api.ts"; import { AppIcon, Launcher, Palette, TopBar, appBlurb, appHref, buildApps } from "./Chrome.tsx"; import { ConnectorsApp, ConnectorsNav } from "./connectors/ConnectorsApp.tsx"; @@ -339,7 +339,7 @@ export function App() { key={m.name} href={href({ app: app.id, model: m.name })} aria-current={!onBoard && model?.name === m.name ? "page" : undefined} - title={m.description} + title={describeFor(m)} > {modelTitle(m, app.module.id)} From a9053cf6828329ca842f54b85a121132f6030e0d Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:38:48 +0100 Subject: [PATCH 07/29] back office: a test fails a useMemo that translates without the language in its dependencies Reads the source of the apps and the design system, finds every useMemo and memo, and fails when the function it is given calls t, tx, a label helper, compareText or a fmt formatter and the dependencies name neither lang nor locale. A memo that is right although it looks wrong (modules/view.ts: the texts are made by the handlers it returns) says why in an i18n-memo-ok comment. On main it flags seven memos: the app list and the smart buttons (the bug), the four fixed in the previous commits, and the exempt one. Co-Authored-By: Claude Sonnet 5.5 --- docs/brand/WORKFLOWS-LISTS-DASHBOARDS.md | 5 +- web/apps/backoffice/src/i18nDeps.test.ts | 345 +++++++++++++++++++++++ web/apps/backoffice/src/modules/view.ts | 1 + 3 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 web/apps/backoffice/src/i18nDeps.test.ts diff --git a/docs/brand/WORKFLOWS-LISTS-DASHBOARDS.md b/docs/brand/WORKFLOWS-LISTS-DASHBOARDS.md index e9776356..9fd244d9 100644 --- a/docs/brand/WORKFLOWS-LISTS-DASHBOARDS.md +++ b/docs/brand/WORKFLOWS-LISTS-DASHBOARDS.md @@ -84,7 +84,10 @@ Each has a specimen in the Brand book (Components). - Changing the language does not remount the app (D6): `I18nProvider` clones the root element so the whole tree renders again with the new text, and every component keeps its state: the route, an open record panel, a dialog, a draft. Do not wrap components in `memo` and do not cache translated text - in `useMemo` without `useI18n().lang` in its dependencies. + in `useMemo` without `useI18n().lang` in its dependencies; `web/apps/backoffice/src/i18nDeps.test.ts` + fails a `useMemo` that translates, sorts or formats without `lang` or `locale` there (a deliberate + exception carries `// i18n-memo-ok: `). A query that the server answers in the collation of the + locale (`useServerRows`, `useAggregate`, the Kanban columns) asks again when the locale changes. - A dropped connection never escapes as an unhandled rejection. Every command answers an error reply ("Could not reach the server"); reads (get, list, search, meta) retry twice with a short backoff, writes never; a page-wide banner says the app is offline or the server cannot be reached and clears itself diff --git a/web/apps/backoffice/src/i18nDeps.test.ts b/web/apps/backoffice/src/i18nDeps.test.ts new file mode 100644 index 00000000..943fc3cd --- /dev/null +++ b/web/apps/backoffice/src/i18nDeps.test.ts @@ -0,0 +1,345 @@ +// A guard for one mistake that has already cost a bug: text is translated and sorted by calling the +// global `t()` and friends while a component renders; the language is not a prop or a state, so a +// `useMemo` that calls them keeps the text of the language it was first computed in unless the +// language is one of its dependencies (see the comment of `I18nProvider` in @obop/ui). This test +// reads the source, finds every `useMemo` and `memo` and fails when the function it is given calls +// one of the translating functions below and the dependencies name neither `lang` nor `locale` +// (`d.lang` of the data context and `useI18n()`'s `i18n` count). +// +// `useCallback` is not read: its function runs when it is called, so a `t()` in it reads the language +// then, and a cached function cannot hold an old text. (Reading it would flag five more hooks, every +// one of them lazy in this way: a confirm dialog's default texts, an error message, `nameOf`.) The +// same goes for a function inside a memo that the memo only returns, for example the handlers of a +// card: mark that memo as below. +// +// A hook that is right although it looks wrong is marked in the source, on the line before it or +// inside it, with a comment that says why: +// +// // i18n-memo-ok: the callback runs when the button is pressed, so `t()` is read then +// +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vite-plus/test"; + +/** + * Functions whose result depends on the language or the region: `t` and `tx`, `rich`, the label + * helpers of the back office (labels.ts, format.ts), the collation (`compareText`) and the number, + * money and date formatters of `fmt`. + */ +export const TRANSLATING = [ + "t", + "tx", + "rich", + "appTitle", + "modelTitle", + "fieldTitle", + "fieldTitleByName", + "actionTitle", + "optionLabel", + "shownLabel", + "describeFor", + "labelFor", + "compareText", + // Helpers of the back office that call the above: the list of apps, built from the modules. + "buildApps", + "appBlurb", +]; +/** A call of one of the `names`, or of a formatter of `fmt`. */ +const callsOf = (names: string[]) => + new RegExp(`(? { + for (let k = from; k < to && k < n; k++) if (out[k] !== "\n") out[k] = " "; + }; + const templates: number[] = []; // the brace depth at which each open `${` was entered + let depth = 0; + let prev = ""; // the last character of code, to tell a division from a regular expression + let before = ""; // and the one before it (an arrow `=>` is followed by an expression) + let i = 0; + // The text of a template literal, from `i` to its closing backtick or the next `${`. + const templateText = () => { + const from = i; + while (i < n) { + const c = src[i]!; + if (c === "\\") i += 2; + else if (c === "`") { + blank(from, i); + i++; + prev = "`"; + return; + } else if (c === "$" && src[i + 1] === "{") { + blank(from, i); + templates.push(depth); + i += 2; + prev = "{"; + return; + } else i++; + } + blank(from, n); + }; + while (i < n) { + const c = src[i]!; + const d = src[i + 1]; + if (c === "/" && d === "/") { + const e = src.indexOf("\n", i); + const end = e < 0 ? n : e; + blank(i, end); + i = end; + } else if (c === "/" && d === "*") { + const e = src.indexOf("*/", i + 2); + const end = e < 0 ? n : e + 2; + blank(i, end); + i = end; + } else if (c === '"' || c === "'") { + let j = i + 1; + while (j < n && src[j] !== c && src[j] !== "\n") j += src[j] === "\\" ? 2 : 1; + blank(i + 1, j); + i = src[j] === c ? j + 1 : j; + prev = c; + } else if (c === "`") { + i++; + templateText(); + } else if ( + c === "/" && + (prev === "" || "(,=:[!&|?{};".includes(prev) || (prev === ">" && before === "=")) + ) { + let j = i + 1; + let inClass = false; + while (j < n && src[j] !== "\n" && (inClass || src[j] !== "/")) { + if (src[j] === "\\") j++; + else if (src[j] === "[") inClass = true; + else if (src[j] === "]") inClass = false; + j++; + } + if (src[j] === "/") { + blank(i + 1, j); + i = j + 1; + prev = "/"; + } else i++; + } else { + if (c === "{") depth++; + else if (c === "}") { + if (templates.length && templates[templates.length - 1] === depth) { + templates.pop(); + i++; + templateText(); + continue; + } + depth--; + } + if (!/\s/.test(c)) { + before = prev; + prev = c; + } + i++; + } + } + return out.join(""); +} + +export type Hook = { + hook: string; + line: number; + /** The function passed to the hook, as written. */ + body: string; + /** The dependency list as written; `null` when the hook has none. */ + deps: string | null; + exempt: boolean; +}; + +/** Every `useMemo` and `memo` call of a source file. */ +export function hooks(src: string): Hook[] { + const masked = code(src); + const found: Hook[] = []; + for (const m of masked.matchAll(/(?`, an arrow's `=>` does not close it. + let angle = 0; + for (; i < masked.length; i++) { + if (masked[i] === "<") angle++; + else if (masked[i] === ">" && masked[i - 1] !== "=" && --angle === 0) break; + } + i++; + while (/\s/.test(masked[i] ?? "")) i++; + } + if (masked[i] !== "(") continue; + const start = i + 1; + const args: [number, number][] = []; + let depth = 0; + let from = start; + for (i = start; i < masked.length; i++) { + const c = masked[i]!; + if ("([{".includes(c)) depth++; + else if (")]}".includes(c)) { + if (depth === 0) break; + depth--; + } else if (c === "," && depth === 0) { + args.push([from, i]); + from = i + 1; + } + } + if (masked.slice(from, i).trim()) args.push([from, i]); + const [fn, deps] = args; + if (!fn) continue; + const line = src.slice(0, m.index).split("\n").length; + // The comment may sit on the line before the call or anywhere inside it. + const lineStart = src.lastIndexOf("\n", m.index! - 1); + const before = src.slice(src.lastIndexOf("\n", lineStart - 1) + 1, m.index); + found.push({ + hook: m[1]!, + line, + body: masked.slice(fn[0], fn[1]), + deps: deps ? masked.slice(deps[0], deps[1]).trim() : null, + exempt: EXEMPT.test(before + src.slice(m.index, i)), + }); + } + return found; +} + +/** Why a hook can keep an old language, or `null` when it cannot. */ +export function problem(h: Hook, names: string[] = TRANSLATING): string | null { + if (h.exempt || !callsOf(names).test(h.body)) return null; + if (h.hook === "memo") + return /(? { + it("flags a memo that translates without the language in its dependencies", () => { + const [h] = hooks(` + const a = useMemo(() => buildApps(m).map((x) => t("Open {name}", { name: x })), [m]);`); + expect(problem(h!)).toMatch(/do not name lang or locale/); + }); + + it("accepts the language or the locale among the dependencies, in any spelling", () => { + for (const deps of ["[m, lang]", "[m, d.lang]", "[locale, m]", "[m, i18n]", "[m, i18n.lang]"]) + expect(problem(hooks(`useMemo(() => compareText(a, b), ${deps})`)[0]!), deps).toBeNull(); + }); + + it("looks at every function that translates or sorts", () => { + for (const call of [ + "appTitle(m)", + 'modelTitle(m, "x")', + "fieldTitle(f)", + 'tx("app", n)', + "compareText(a, b)", + "fmt.money(1)", + ]) + expect(problem(hooks(`useMemo(() => ${call}, [m])`)[0]!), call).not.toBeNull(); + }); + + it("handles type arguments, nested brackets, arrow functions in the arguments and a second hook", () => { + const hs = hooks(` + const a = useMemo(() => rows.map((r) => ({ k: [t("x"), 1], f: (u: Map) => u })), [rows, q]); + const b = useMemo(async (x = t("y")) => x, []);`); + expect(hs.map((h) => [h.hook, h.deps])).toEqual([ + ["useMemo", "[rows, q]"], + ["useMemo", "[]"], + ]); + expect(hs.map((h) => problem(h) !== null)).toEqual([true, true]); + }); + + it("ignores what is only text: comments, strings, templates and regular expressions", () => { + const src = [ + "// useMemo(() => t('a'), [])", + "/* useMemo(() => t('a'), []) */", + "const s = \"useMemo(() => t('a'), [])\";", + "const r = /useMemo\\(\\(\\) => t\\('a'\\)/;", + "const q = `${useMemo(() => 1, [])} useMemo(() => t('a'), [])`;", + "const ok = useMemo(() => /['\"]/.test(x) && `a${y}b`, [x, y]);", + ].join("\n"); + // What is code stays (`${y}`), what is text is blank. + expect(hooks(src).map((h) => h.body.trim())).toEqual([ + "() => 1", + "() => / /.test(x) && ` ${y} `", + ]); + expect(hooks(src).map((h) => problem(h))).toEqual([null, null]); + }); + + it("does not take a call of something else for a translation", () => { + for (const body of ["() => item.t(1)", "() => format(1)", "() => split(a)", "() => 'tx(' + a"]) + expect(problem(hooks(`useMemo(${body}, [])`)[0]!), body).toBeNull(); + }); + + it("lets a hook that is right say so, with a reason", () => { + const withReason = "// i18n-memo-ok: runs when pressed\nconst f = useMemo(() => t('x'), []);"; + expect(problem(hooks(withReason)[0]!)).toBeNull(); + const inside = + "const f = useMemo(\n // i18n-memo-ok: the text is built when the row is drawn\n () => t('x'),\n [],\n);"; + expect(problem(hooks(inside)[0]!)).toBeNull(); + // A bare marker is not a reason. + expect( + problem(hooks("// i18n-memo-ok:\nconst f = useMemo(() => t('x'), []);")[0]!), + ).not.toBeNull(); + }); + + it("holds a memo component to using the language hook", () => { + expect(problem(hooks("const C = memo(function C() { return t('x'); });")[0]!)).not.toBeNull(); + expect( + problem(hooks("const C = memo(function C() { useI18n(); return t('x'); });")[0]!), + ).toBeNull(); + }); +}); + +/** Every source file of the apps and of the design system that the back office is made of. */ +function sources(): string[] { + const here = dirname(fileURLToPath(import.meta.url)); + const web = join(here, "../../.."); + const roots = [ + ...readdirSync(join(web, "apps")).map((a) => join(web, "apps", a, "src")), + join(web, "packages/ui/src"), + ]; + const files: string[] = []; + const walk = (dir: string) => { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; // an app without a src directory + } + for (const e of entries) { + const p = join(dir, e.name); + if (e.isDirectory()) walk(p); + else if (/\.tsx?$/.test(e.name) && !/\.test\.tsx?$/.test(e.name)) files.push(p); + } + }; + roots.forEach(walk); + return files.sort(); +} + +describe("the source of the apps", () => { + it("has no memo that translates or sorts without the language in its dependencies", () => { + const web = join(dirname(fileURLToPath(import.meta.url)), "../../.."); + const files = sources().map((file) => ({ file, src: readFileSync(file, "utf8") })); + const flagged: string[] = []; + let seen = 0; + for (const { file, src } of files) { + const hs = hooks(src); + seen += hs.length; + for (const h of hs) { + const why = problem(h); + if (why) + flagged.push(`${relative(web, file).replaceAll("\\", "/")}:${h.line} ${h.hook}: ${why}`); + } + } + // The scan must have found the hooks (a broken reader would find none and pass). + expect(seen).toBeGreaterThan(40); + expect( + flagged, + "Add lang or locale to the dependencies, or mark the hook with i18n-memo-ok: ", + ).toEqual([]); + }); +}); diff --git a/web/apps/backoffice/src/modules/view.ts b/web/apps/backoffice/src/modules/view.ts index 592fd0ce..a143d2f7 100644 --- a/web/apps/backoffice/src/modules/view.ts +++ b/web/apps/backoffice/src/modules/view.ts @@ -104,6 +104,7 @@ export function useModulesView(opts: { return null; }, [route.id, route.tab, data.installed, data.market]); + // i18n-memo-ok: the texts are made when a button is pressed, by the handlers this memo returns const actions: CardActions = useMemo( () => ({ onInstall: (r) => From 911a975955ad3c66cb61a0691ef1d0781a6f00b0 Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:38:50 +0100 Subject: [PATCH 08/29] e2e: switching the language without a reload leaves nothing in the old language Swedish to English on an instance whose default is Swedish and English to Swedish: app names and tile order equal those of the reloaded page, launcher, command palette, sidebar header, aria label and tooltip, top bar, and "Use the instance default" at once. With the account menu: the Apps tab follows and leaving a name field writes no setting (a real rename still saves), the related-record buttons of an open record and a list sorted on the server follow. Co-Authored-By: Claude Sonnet 5.5 --- .../e2e/tests/language-switch.spec.ts | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 web/packages/e2e/tests/language-switch.spec.ts diff --git a/web/packages/e2e/tests/language-switch.spec.ts b/web/packages/e2e/tests/language-switch.spec.ts new file mode 100644 index 00000000..ac579913 --- /dev/null +++ b/web/packages/e2e/tests/language-switch.spec.ts @@ -0,0 +1,294 @@ +import type { Page } from "@playwright/test"; +import { tr } from "../support/catalogue.ts"; +import { languageUrl } from "../support/env.ts"; +import { Api, expect, test } from "../support/fixtures.ts"; + +// Switching the language in the open app, without a reload, must leave nothing in the old language: +// what a reload shows is what the switch shows. The app names and their order are built once from +// the module list and the settings (App.tsx `buildApps`), the smart buttons of a record and a +// server-sorted list are cached per query; each one used to keep the old language until a reload. +// +// Every spec starts with no saved choice (`language: null`): a browser that asks for English. The +// instance of the first describe has OBOP_DEFAULT_LANGUAGE=sv (support/serve.ts, "language"), the +// others use the main instance, whose own default is the browser's. +test.use({ language: null, locale: "en-US", extraHTTPHeaders: { "Accept-Language": "en" } }); + +/** What the catalogue says in `lang` for an English message (English is the message itself). */ +const say = (lang: string, id: string) => (lang === "en" ? id : tr(lang, id)); +/** The name of a built-in app in `lang` (the message context "app", gettext's "\u0004" separator). */ +const appName = (lang: string, en: string) => (lang === "en" ? en : tr(lang, `app\u0004${en}`)); +/** The label of the menu of an app: "{app} menu". */ +const menuLabel = (lang: string, app: string) => say(lang, "{app} menu").replace("{app}", app); +const NATIVE: Record = { en: "English", sv: "Svenska" }; + +type Tile = { app: string; name: string }; + +/** The home screen: its "All apps" grid, in the order shown (the app id comes from the link). */ +async function tiles(page: Page): Promise { + const grid = page.locator(".home .app-grid:not(.small) a.app-tile"); + await expect(grid.first()).toBeVisible(); + return grid.evaluateAll((els) => + els.map((e) => ({ + app: (e.getAttribute("href") ?? "").split("/")[1] ?? "", + name: e.querySelector("strong")?.textContent ?? "", + })), + ); +} + +/** Home through the logo, a client-side navigation: the page is not reloaded. */ +async function goHome(page: Page) { + await page.locator("a.brand").click(); + await expect(page.locator(".home")).toBeVisible(); +} + +/** Customize > Theme, where Language & region lives, through the theme button of the top bar. */ +async function openLanguageSettings(page: Page, lang: string) { + await page.getByRole("button", { name: say(lang, "Theme"), exact: true }).click(); + await expect(page.getByRole("region", { name: say(lang, "Language & region") })).toBeVisible(); +} + +async function pickInSettings(page: Page, from: string, to: string) { + const section = page.getByRole("region", { name: say(from, "Language & region") }); + await section.getByLabel(say(from, "Language")).selectOption(to); + await expect(page.locator("html")).toHaveAttribute("lang", to); +} + +async function pickInAccountMenu(page: Page, from: string, to: string) { + await page.getByRole("button", { name: say(from, "Account"), exact: true }).click(); + await page.getByRole("menuitem", { name: NATIVE[to] }).click(); + await expect(page.locator("html")).toHaveAttribute("lang", to); +} + +/** What the server sends as the description of a model in `lang` (English is the description itself). */ +async function describe(api: Api, model: string, lang: string): Promise { + const r = await api.get("/api/meta", await api.token("root")); + const modules: any[] = Array.isArray(r.json) ? r.json : r.json.modules; + const m = modules.flatMap((x) => x.models).find((x) => x.name === model); + const text = lang === "en" ? m.description : m.descriptions?.[lang]; + expect(text, `a ${lang} description of ${model}`).toBeTruthy(); + return text; +} + +const savedChoice = (page: Page) => page.evaluate(() => localStorage.getItem("obop-lang")); + +/** + * Switch from `from` to `to` in Settings > Language & region and look at everything that is built + * from the app list, in that order, without reloading; then reload and compare with the home screen. + */ +async function switchInSettings(page: Page, api: Api, from: string, to: string) { + await goHome(page); + const before = await tiles(page); + const invoicingFrom = appName(from, "Invoicing"); + expect(before.map((t) => t.name)).toContain(invoicingFrom); + + await openLanguageSettings(page, from); + await pickInSettings(page, from, to); + + // The top bar names the open app (Customize) in the new language at once. + await expect(page.locator(".brand-app")).toHaveText(appName(to, "Customize")); + // The person has a choice of their own now, and the button that undoes it is there at once. + const undo = page.getByRole("button", { name: say(to, "Use the instance default") }); + await expect(undo).toBeVisible(); + expect(await savedChoice(page)).toBe(to); + + // Home: the names and the order of the tiles. + await goHome(page); + const live = await tiles(page); + expect( + live.map((t) => t.app), + "the order of the apps depends on the language", + ).not.toEqual(before.map((t) => t.app)); + const invoicingTo = appName(to, "Invoicing"); + expect(live.find((t) => t.app === "invoicing")?.name).toBe(invoicingTo); + expect(live.map((t) => t.name)).not.toContain(invoicingFrom); + + // The launcher lists the same apps in the same order. + await page.getByRole("button", { name: say(to, "Open the app launcher") }).click(); + const launcher = page.locator(".launcher"); + await expect(launcher).toBeVisible(); + await expect(launcher.locator(".app-grid:not(.small) a.app-tile")).toHaveText( + live.map((t) => t.name), + ); + await page.keyboard.press("Escape"); + await expect(launcher).toHaveCount(0); + + // The command palette: " › " has the app in the new language too. + await page.getByRole("button", { name: say(to, "Search apps, records and actions") }).click(); + const palette = page.locator(".palette"); + await palette.locator("input").fill(invoicingTo); + await expect(palette.getByRole("option").first()).toContainText(invoicingTo); + await expect(palette).not.toContainText(invoicingFrom); + await page.keyboard.press("Escape"); + await expect(palette).toHaveCount(0); + + // The sidebar of an app: its header, its label for screen readers and the top bar. + await page.locator(".home .app-grid:not(.small) a.app-tile", { hasText: invoicingTo }).click(); + await expect(page.locator("nav.sidebar .side-head strong")).toHaveText(invoicingTo); + await expect(page.locator("nav.sidebar")).toHaveAttribute( + "aria-label", + menuLabel(to, invoicingTo), + ); + await expect(page.locator(".brand-app")).toHaveText(invoicingTo); + // The tooltip of a record in the sidebar is the description in the new language. + await expect(page.locator('nav.sidebar a[href="#/invoicing/invoice"]')).toHaveAttribute( + "title", + await describe(api, "invoice", to), + ); + + // What a reload shows is what the switch showed. + await goHome(page); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("lang", to); + expect(await tiles(page)).toEqual(live); + + // "Use the instance default" goes back to the default: no choice of their own, the old language. + await openLanguageSettings(page, to); + await page.getByRole("button", { name: say(to, "Use the instance default") }).click(); + await expect(page.locator("html")).toHaveAttribute("lang", from); + await expect(page.locator(".brand-app")).toHaveText(appName(from, "Customize")); + await expect(page.getByRole("button", { name: say(to, "Use the instance default") })).toHaveCount( + 0, + ); + await expect( + page.getByRole("button", { name: say(from, "Use the instance default") }), + ).toHaveCount(0); + expect(await savedChoice(page)).toBeNull(); + await goHome(page); + expect(await tiles(page)).toEqual(before); +} + +test.describe("switching the language in Settings, without a reload", () => { + test.describe("an instance whose default language is Swedish", () => { + test.use({ baseURL: languageUrl() }); + + test("Swedish to English: the app names and their order, the palette, the launcher, the sidebar", async ({ + page, + signIn, + api, + }) => { + await signIn("root", "/customize/theme?tab=theme"); + await expect(page.locator("html")).toHaveAttribute("lang", "sv"); + expect(await savedChoice(page)).toBeNull(); + await switchInSettings(page, api, "sv", "en"); + }); + }); + + test("English to Swedish: the same, the other way", async ({ page, signIn, api }) => { + await signIn("root", "/customize/theme?tab=theme"); + await expect(page.locator("html")).toHaveAttribute("lang", "en"); + expect(await savedChoice(page)).toBeNull(); + await switchInSettings(page, api, "en", "sv"); + }); +}); + +test.describe("switching the language with the account menu, on a screen that stays open", () => { + test("the Apps tab follows, and leaving a name field as it is writes no setting", async ({ + page, + signIn, + api, + }) => { + const before = (await api.run("root", "setting.get", { id: "instance" })) as { value: string }; + const writes: string[] = []; + page.on("request", (r) => { + if (r.method() === "POST" && r.url().endsWith("/api/setting/update")) writes.push(r.url()); + }); + try { + await signIn("root", "/customize/apps?tab=apps"); + const nameField = (lang: string, app: string) => + page.getByLabel(say(lang, "Name of {name}").replace("{name}", app), { exact: true }); + await expect(nameField("en", "Invoicing")).toHaveValue("Invoicing"); + + await pickInAccountMenu(page, "en", "sv"); + + // Every name, and every label built from it, is Swedish: the field shows what the app is called. + const field = nameField("sv", "Fakturering"); + await expect(field).toHaveValue("Fakturering"); + await expect( + page.getByLabel(say("sv", "Move {name} up").replace("{name}", "Fakturering")), + ).toBeVisible(); + await expect( + page.getByLabel(say("sv", "Colour of {name}").replace("{name}", "Fakturering")), + ).toBeVisible(); + + // Clicking into the field and out of it again changes nothing. Were the field to keep the + // English name it was made with, this would save "Invoicing" as the name for everyone. + await field.focus(); + await field.blur(); + // The save would be sent from the blur handler; give it room to show up before looking. + await page.waitForTimeout(500); + expect(writes, "a setting was written by leaving the field").toEqual([]); + const after = (await api.run("root", "setting.get", { id: "instance" })) as { + value: string; + }; + expect(after.value).toBe(before.value); + + // A real rename still saves, and the field keeps what was typed. + await field.fill("Fakturor"); + await field.blur(); + await expect + .poll(async () => { + const s = (await api.run("root", "setting.get", { id: "instance" })) as { + value: string; + }; + return JSON.parse(s.value || "{}").apps?.names?.invoicing; + }) + .toBe("Fakturor"); + await expect(nameField("sv", "Fakturor")).toHaveValue("Fakturor"); + } finally { + await api.run("root", "setting.update", { id: "instance", value: before.value }); + } + }); + + test("an open record: its related-record buttons follow", async ({ page, signIn, api, uid }) => { + const party = await api.create("root", "party", { name: uid("Related") }); + await signIn("root", `/base/party?id=${party.id}`); + const smart = async () => { + const buttons = page.locator(".smart .smartbtn"); + await expect(buttons.first()).toBeVisible(); + // The counts come from the server; wait until they are all in. + await expect(buttons.filter({ hasText: "…" })).toHaveCount(0); + return buttons.evaluateAll((els) => + els.map((e) => ({ text: e.textContent, title: e.getAttribute("title") })), + ); + }; + const english = await smart(); + expect(english.length).toBeGreaterThan(1); + + await pickInAccountMenu(page, "en", "sv"); + // The panel is the same one: no navigation, no reload. + await expect(page.locator("aside.record")).toBeVisible(); + await expect(page.getByRole("group", { name: say("sv", "Related records") })).toBeVisible(); + const swedish = await smart(); + expect(swedish, "the buttons are in Swedish").not.toEqual(english); + + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("lang", "sv"); + expect(swedish, "what a reload shows").toEqual(await smart()); + }); + + test("a list sorted on the server is sorted in the new language", async ({ + page, + signIn, + api, + scratchModule, + }) => { + const model = await scratchModule({ + title: "Sort data", + fields: [{ name: "title", required: true }], + }); + for (const t of ["Zebra", "Ärger", "Apfel", "Öle", "Item 10", "Item 2"]) + await api.create("root", model, { title: t }); + const sorted = encodeURIComponent(JSON.stringify({ s: { f: "title", dir: "asc" } })); + await signIn("root", `/${model}/${model}?view=list&st=${sorted}`); + const titles = page.locator("table.list tbody tr td:nth-child(2)"); + // English puts the umlauts with their letters. + await expect(titles).toHaveText(["Apfel", "Ärger", "Item 2", "Item 10", "Öle", "Zebra"]); + + await pickInAccountMenu(page, "en", "sv"); + // Swedish puts Ä and Ö after Z. The sort goes to the server with the language, so the list asks again. + await expect(titles).toHaveText(["Apfel", "Item 2", "Item 10", "Zebra", "Ärger", "Öle"]); + + await page.reload(); + await expect(titles).toHaveText(["Apfel", "Item 2", "Item 10", "Zebra", "Ärger", "Öle"]); + }); +}); From 197cb67546b0fad59eb80908b37fd49856d85674 Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:33:46 +0100 Subject: [PATCH 09/29] Follow the language in the dashboard and list-plan memos that sort and parse The dashboard lists sort equal positions by title with the collation of the region, the widget card writes its "not set up correctly" and "no access" texts in resolve(), and the list plan reads range bounds with the decimal separator of the region. All three were memoised without the language, so a switch without a reload kept the old order and the old text. Co-Authored-By: Claude Sonnet 5.5 --- web/apps/backoffice/src/ModelPage.tsx | 7 +++++-- web/apps/backoffice/src/dashboards/DashboardsApp.tsx | 7 +++++-- web/apps/backoffice/src/dashboards/WidgetCard.tsx | 3 ++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/web/apps/backoffice/src/ModelPage.tsx b/web/apps/backoffice/src/ModelPage.tsx index 4514c613..c397a657 100644 --- a/web/apps/backoffice/src/ModelPage.tsx +++ b/web/apps/backoffice/src/ModelPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; -import { fmt, inSentence, msg, t } from "@obop/ui"; +import { fmt, inSentence, msg, t, useI18n } from "@obop/ui"; import { search, type ModelMeta, type Row } from "./api.ts"; import { collect } from "./bulk.ts"; import type { App } from "./Chrome.tsx"; @@ -63,6 +63,7 @@ function storedView(model: string): string { export function ModelPage({ app, model, route, go, extra: appExtra, onNew }: Props) { const d = useData(); + const { locale } = useI18n(); const title = modelTitle(model, app.module.id); const state = useMemo(() => decodeState(route.st), [route.st]); // The dialogs a view opens (bulk edit and delete) share one slot, which the list view rewrites @@ -109,7 +110,9 @@ export function ModelPage({ app, model, route, go, extra: appExtra, onNew }: Pro // the filter is expressible. Anything else (an any-field search, a "contains" on a select, grouping // the list, a sort by a ref) loads the records, up to 5,000, and works in the browser. See // serverQuery.ts. - const plan = useMemo(() => toServer(state, fields), [state, fields]); + // A range bound is read with the decimal separator of the region, so what the server can answer + // depends on the locale too. + const plan = useMemo(() => toServer(state, fields), [state, fields, locale]); const grouped = state.group.length > 0; const serverMode = view === "list" && onServer(plan, state); const aggMode = view !== "list" && plan.rest.length === 0; diff --git a/web/apps/backoffice/src/dashboards/DashboardsApp.tsx b/web/apps/backoffice/src/dashboards/DashboardsApp.tsx index 4b73cc36..1532eb98 100644 --- a/web/apps/backoffice/src/dashboards/DashboardsApp.tsx +++ b/web/apps/backoffice/src/dashboards/DashboardsApp.tsx @@ -16,6 +16,7 @@ import { panelId, rich, t, + useI18n, } from "@obop/ui"; import type { ModelMeta, Reply, Row } from "../api.ts"; import type { App } from "../Chrome.tsx"; @@ -182,6 +183,8 @@ export function DashboardsApp({ owner: string; }) { const d = useData(); + // The order of equal positions is by title, collated in the language of the region. + const { locale } = useI18n(); const boards = useRows("dashboards_dashboard"); const tiles = useRows("dashboards_widget"); const dmodel = d.models.get("dashboards_dashboard")?.model; @@ -201,7 +204,7 @@ export function DashboardsApp({ (boards.rows as unknown as Dashboard[]) .filter((b) => canEdit || b.shared !== false || b.owner === owner) .sort(byPosition), - [boards.rows, canEdit, owner], + [boards.rows, canEdit, owner, locale], ); const current = visible.find((b) => b.id === route.id) ?? visible[0]; const widgets = useMemo( @@ -209,7 +212,7 @@ export function DashboardsApp({ (tiles.rows as unknown as Widget[]) .filter((w) => w.dashboard_id === current?.id) .sort(byPosition), - [tiles.rows, current?.id], + [tiles.rows, current?.id, locale], ); const open = (id: string, view = route.view) => go({ app: app.id, model: BOARD, id, view }); diff --git a/web/apps/backoffice/src/dashboards/WidgetCard.tsx b/web/apps/backoffice/src/dashboards/WidgetCard.tsx index b68dcd09..886c276b 100644 --- a/web/apps/backoffice/src/dashboards/WidgetCard.tsx +++ b/web/apps/backoffice/src/dashboards/WidgetCard.tsx @@ -21,7 +21,8 @@ import { /** A widget's data: resolved against what this person may see, then fetched from the server. */ export function useWidgetData(w: Widget) { const d = useData(); - const r = useMemo(() => resolve(w, d.models), [w, d.models]); + // `resolve` writes the "not set up correctly" and "no access" texts, so it runs again in the new language. + const r = useMemo(() => resolve(w, d.models), [w, d.models, d.lang]); const [got, setGot] = useState(null); const [attempt, setAttempt] = useState(0); const key = JSON.stringify([ From 9df0c5125ab9fbd856c4c1036827fd1b5aeadd7c Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:22:34 +0100 Subject: [PATCH 10/29] back office: the memo guard finds the translating functions from the call graph The guard checked a memo against a hand-written list of names, so it missed every function that translates only through another one (byPosition sorts with compareText, resolve writes texts with t) and a function handed on without a call (.sort(byPosition)). Of the fourteen memos that named the language in their dependencies at 911a975, eight would have passed with it removed. i18nGuard.ts now reads every file of the apps and of packages/ui, resolves imports and re-exports, and takes as translators the functions that use a reader of the i18n store, directly or through others: by call, by reference, as a JSX tag, or as a member such as d.recordLabel and d.refText. i18nDeps.test.ts checks the reader with fixtures, pins the set of translators on the real tree (a missing link fails the test), fails when a store function that reads the language is not classified, and takes the language out of each of the eighteen memos that name it now to see that every one is then flagged. Co-Authored-By: Claude Sonnet 5.5 --- web/apps/backoffice/src/i18nDeps.test.ts | 572 ++++++++--------- web/apps/backoffice/src/i18nGuard.ts | 745 +++++++++++++++++++++++ 2 files changed, 1043 insertions(+), 274 deletions(-) create mode 100644 web/apps/backoffice/src/i18nGuard.ts diff --git a/web/apps/backoffice/src/i18nDeps.test.ts b/web/apps/backoffice/src/i18nDeps.test.ts index 943fc3cd..52bb9751 100644 --- a/web/apps/backoffice/src/i18nDeps.test.ts +++ b/web/apps/backoffice/src/i18nDeps.test.ts @@ -1,244 +1,97 @@ -// A guard for one mistake that has already cost a bug: text is translated and sorted by calling the -// global `t()` and friends while a component renders; the language is not a prop or a state, so a -// `useMemo` that calls them keeps the text of the language it was first computed in unless the -// language is one of its dependencies (see the comment of `I18nProvider` in @obop/ui). This test -// reads the source, finds every `useMemo` and `memo` and fails when the function it is given calls -// one of the translating functions below and the dependencies name neither `lang` nor `locale` -// (`d.lang` of the data context and `useI18n()`'s `i18n` count). +// A guard for one mistake that has already cost a bug (the app list, the smart buttons, the dashboards): +// text is translated, sorted and formatted by calling the global `t()` and the functions built on it +// while a component renders. The language is not a prop or a state, so a `useMemo` that uses any of +// them keeps the text of the language it was first computed in unless the language is one of its +// dependencies (see `I18nProvider` in @obop/ui). // -// `useCallback` is not read: its function runs when it is called, so a `t()` in it reads the language -// then, and a cached function cannot hold an old text. (Reading it would flag five more hooks, every -// one of them lazy in this way: a confirm dialog's default texts, an error message, `nameOf`.) The -// same goes for a function inside a memo that the memo only returns, for example the handlers of a -// card: mark that memo as below. +// `i18nGuard.ts` reads the source of every app and of the design system, works out from the call +// graph which functions translate, sort or format, and this test fails when a `useMemo` (or a `memo` +// component) uses one of them and its dependencies name neither `lang` nor `locale` (`d.lang` of the +// data context and `useI18n()`'s `i18n` count). What it reads and what it does not is at the top of +// that file; ISSUES.md D14 lists the blind spots. // // A hook that is right although it looks wrong is marked in the source, on the line before it or // inside it, with a comment that says why: // // // i18n-memo-ok: the callback runs when the button is pressed, so `t()` is read then // -import { readFileSync, readdirSync } from "node:fs"; -import { dirname, join, relative } from "node:path"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vite-plus/test"; +import { + SEEDS, + analyse, + code, + hooks, + namesLanguage, + problem, + readTree, + unclassifiedReaders, + type Source, +} from "./i18nGuard.ts"; -/** - * Functions whose result depends on the language or the region: `t` and `tx`, `rich`, the label - * helpers of the back office (labels.ts, format.ts), the collation (`compareText`) and the number, - * money and date formatters of `fmt`. - */ -export const TRANSLATING = [ - "t", - "tx", - "rich", - "appTitle", - "modelTitle", - "fieldTitle", - "fieldTitleByName", - "actionTitle", - "optionLabel", - "shownLabel", - "describeFor", - "labelFor", - "compareText", - // Helpers of the back office that call the above: the list of apps, built from the modules. - "buildApps", - "appBlurb", -]; -/** A call of one of the `names`, or of a formatter of `fmt`. */ -const callsOf = (names: string[]) => - new RegExp(`(? { - for (let k = from; k < to && k < n; k++) if (out[k] !== "\n") out[k] = " "; - }; - const templates: number[] = []; // the brace depth at which each open `${` was entered - let depth = 0; - let prev = ""; // the last character of code, to tell a division from a regular expression - let before = ""; // and the one before it (an arrow `=>` is followed by an expression) - let i = 0; - // The text of a template literal, from `i` to its closing backtick or the next `${`. - const templateText = () => { - const from = i; - while (i < n) { - const c = src[i]!; - if (c === "\\") i += 2; - else if (c === "`") { - blank(from, i); - i++; - prev = "`"; - return; - } else if (c === "$" && src[i + 1] === "{") { - blank(from, i); - templates.push(depth); - i += 2; - prev = "{"; - return; - } else i++; - } - blank(from, n); - }; - while (i < n) { - const c = src[i]!; - const d = src[i + 1]; - if (c === "/" && d === "/") { - const e = src.indexOf("\n", i); - const end = e < 0 ? n : e; - blank(i, end); - i = end; - } else if (c === "/" && d === "*") { - const e = src.indexOf("*/", i + 2); - const end = e < 0 ? n : e + 2; - blank(i, end); - i = end; - } else if (c === '"' || c === "'") { - let j = i + 1; - while (j < n && src[j] !== c && src[j] !== "\n") j += src[j] === "\\" ? 2 : 1; - blank(i + 1, j); - i = src[j] === c ? j + 1 : j; - prev = c; - } else if (c === "`") { - i++; - templateText(); - } else if ( - c === "/" && - (prev === "" || "(,=:[!&|?{};".includes(prev) || (prev === ">" && before === "=")) - ) { - let j = i + 1; - let inClass = false; - while (j < n && src[j] !== "\n" && (inClass || src[j] !== "/")) { - if (src[j] === "\\") j++; - else if (src[j] === "[") inClass = true; - else if (src[j] === "]") inClass = false; - j++; - } - if (src[j] === "/") { - blank(i + 1, j); - i = j + 1; - prev = "/"; - } else i++; - } else { - if (c === "{") depth++; - else if (c === "}") { - if (templates.length && templates[templates.length - 1] === depth) { - templates.pop(); - i++; - templateText(); - continue; - } - depth--; - } - if (!/\s/.test(c)) { - before = prev; - prev = c; - } - i++; - } - } - return out.join(""); -} - -export type Hook = { - hook: string; - line: number; - /** The function passed to the hook, as written. */ - body: string; - /** The dependency list as written; `null` when the hook has none. */ - deps: string | null; - exempt: boolean; +const UI: Record = { + "packages/ui/src/index.ts": 'export * from "./i18n/index.ts";', + "packages/ui/src/i18n/index.ts": + 'export { t, fmt, compareText, getLang, setLanguage } from "./store.ts";', + "packages/ui/src/i18n/store.ts": ` + const S = { lang: "en", messages: {} as Record, region: "auto" }; + export function translate(id: string) { return S.messages[id] ?? id; } + export function t(id: string) { return translate(id); } + export const getLang = () => S.lang; + export function formatLocale() { return S.region === "auto" ? S.lang : S.region; } + export const fmt = { money: (n: number) => String(n) + formatLocale() }; + export function compareText(a: string, b: string) { return a.localeCompare(b, formatLocale()); } + export async function setLanguage(lang: string) { S.lang = lang; } + `, }; -/** Every `useMemo` and `memo` call of a source file. */ -export function hooks(src: string): Hook[] { - const masked = code(src); - const found: Hook[] = []; - for (const m of masked.matchAll(/(?`, an arrow's `=>` does not close it. - let angle = 0; - for (; i < masked.length; i++) { - if (masked[i] === "<") angle++; - else if (masked[i] === ">" && masked[i - 1] !== "=" && --angle === 0) break; - } - i++; - while (/\s/.test(masked[i] ?? "")) i++; - } - if (masked[i] !== "(") continue; - const start = i + 1; - const args: [number, number][] = []; - let depth = 0; - let from = start; - for (i = start; i < masked.length; i++) { - const c = masked[i]!; - if ("([{".includes(c)) depth++; - else if (")]}".includes(c)) { - if (depth === 0) break; - depth--; - } else if (c === "," && depth === 0) { - args.push([from, i]); - from = i + 1; - } +const sources = (apps: Record): Source[] => + Object.entries({ ...UI, ...apps }).map(([path, text]) => ({ path, text })); + +/** The problems of every memo in `apps`, as `file:line text`. */ +function flagged(apps: Record): string[] { + const all = sources(apps); + const a = analyse(all); + const out: string[] = []; + for (const s of all) + for (const h of hooks(s.text, a.files.get(s.path)!.masked)) { + const why = problem(a, s.path, h); + if (why) out.push(`${s.path}:${h.line} ${why}`); } - if (masked.slice(from, i).trim()) args.push([from, i]); - const [fn, deps] = args; - if (!fn) continue; - const line = src.slice(0, m.index).split("\n").length; - // The comment may sit on the line before the call or anywhere inside it. - const lineStart = src.lastIndexOf("\n", m.index! - 1); - const before = src.slice(src.lastIndexOf("\n", lineStart - 1) + 1, m.index); - found.push({ - hook: m[1]!, - line, - body: masked.slice(fn[0], fn[1]), - deps: deps ? masked.slice(deps[0], deps[1]).trim() : null, - exempt: EXEMPT.test(before + src.slice(m.index, i)), - }); - } - return found; + return out; } -/** Why a hook can keep an old language, or `null` when it cannot. */ -export function problem(h: Hook, names: string[] = TRANSLATING): string | null { - if (h.exempt || !callsOf(names).test(h.body)) return null; - if (h.hook === "memo") - return /(? { - it("flags a memo that translates without the language in its dependencies", () => { +/* ---------------------------------------------------------------------------------------------- */ +/* The reader */ +/* ---------------------------------------------------------------------------------------------- */ + +describe("the reader of hooks", () => { + it("finds the dependencies of a memo", () => { const [h] = hooks(` - const a = useMemo(() => buildApps(m).map((x) => t("Open {name}", { name: x })), [m]);`); - expect(problem(h!)).toMatch(/do not name lang or locale/); + const a = useMemo(() => rows.map((x) => t("Open {name}", { name: x })), [m]);`); + expect(h!.deps).toBe("[m]"); + expect(namesLanguage(h!.deps)).toBe(false); }); it("accepts the language or the locale among the dependencies, in any spelling", () => { for (const deps of ["[m, lang]", "[m, d.lang]", "[locale, m]", "[m, i18n]", "[m, i18n.lang]"]) - expect(problem(hooks(`useMemo(() => compareText(a, b), ${deps})`)[0]!), deps).toBeNull(); - }); - - it("looks at every function that translates or sorts", () => { - for (const call of [ - "appTitle(m)", - 'modelTitle(m, "x")', - "fieldTitle(f)", - 'tx("app", n)', - "compareText(a, b)", - "fmt.money(1)", - ]) - expect(problem(hooks(`useMemo(() => ${call}, [m])`)[0]!), call).not.toBeNull(); + expect(namesLanguage(hooks(`useMemo(() => 1, ${deps})`)[0]!.deps), deps).toBe(true); + for (const deps of ["[m, language]", "[m, d.version]", "[m, lang2]"]) + expect(namesLanguage(hooks(`useMemo(() => 1, ${deps})`)[0]!.deps), deps).toBe(false); }); it("handles type arguments, nested brackets, arrow functions in the arguments and a second hook", () => { @@ -249,7 +102,6 @@ describe("the guard reads hooks", () => { ["useMemo", "[rows, q]"], ["useMemo", "[]"], ]); - expect(hs.map((h) => problem(h) !== null)).toEqual([true, true]); }); it("ignores what is only text: comments, strings, templates and regular expressions", () => { @@ -260,86 +112,258 @@ describe("the guard reads hooks", () => { "const r = /useMemo\\(\\(\\) => t\\('a'\\)/;", "const q = `${useMemo(() => 1, [])} useMemo(() => t('a'), [])`;", "const ok = useMemo(() => /['\"]/.test(x) && `a${y}b`, [x, y]);", + 'function csv(s) { return /[",\\n]/.test(s) ? `"${s.replace(/"/g, \'""\')}"` : s; }', ].join("\n"); // What is code stays (`${y}`), what is text is blank. expect(hooks(src).map((h) => h.body.trim())).toEqual([ "() => 1", "() => / /.test(x) && ` ${y} `", ]); - expect(hooks(src).map((h) => problem(h))).toEqual([null, null]); - }); - - it("does not take a call of something else for a translation", () => { - for (const body of ["() => item.t(1)", "() => format(1)", "() => split(a)", "() => 'tx(' + a"]) - expect(problem(hooks(`useMemo(${body}, [])`)[0]!), body).toBeNull(); + // and the brackets of what is left balance, which they do not when a string or a regular + // expression is mistaken for code + expect(imbalance(src)).toBe(0); }); it("lets a hook that is right say so, with a reason", () => { - const withReason = "// i18n-memo-ok: runs when pressed\nconst f = useMemo(() => t('x'), []);"; - expect(problem(hooks(withReason)[0]!)).toBeNull(); - const inside = - "const f = useMemo(\n // i18n-memo-ok: the text is built when the row is drawn\n () => t('x'),\n [],\n);"; - expect(problem(hooks(inside)[0]!)).toBeNull(); + const app = (marker: string) => ({ + "apps/x/src/a.tsx": `import { t } from "@obop/ui";\n${marker}\nconst f = useMemo(() => t("x"), []);`, + }); + expect(flagged(app("// i18n-memo-ok: runs when pressed"))).toEqual([]); + expect( + flagged({ + "apps/x/src/a.tsx": + 'import { t } from "@obop/ui";\nconst f = useMemo(\n // i18n-memo-ok: the text is built when the row is drawn\n () => t("x"),\n [],\n);', + }), + ).toEqual([]); // A bare marker is not a reason. + expect(flagged(app("// i18n-memo-ok:"))).toHaveLength(1); + }); +}); + +describe("what counts as translating", () => { + const f = (body: string, extra: Record = {}) => + flagged({ + "apps/x/src/a.tsx": `import { t, fmt, compareText, getLang, setLanguage } from "@obop/ui";\n${body}`, + ...extra, + }); + + it("is a call of t, a formatter of fmt, the collation or the language itself", () => { + for (const use of ['t("x")', "fmt.money(1)", "compareText(a, b)", "getLang()"]) + expect(f(`const v = useMemo(() => ${use}, [m]);`), use).toHaveLength(1); + // with the language in the dependencies it is fine + expect(f('const v = useMemo(() => t("x"), [m, lang]);')).toEqual([]); + expect(f("const v = useMemo(() => fmt.money(1), [m, d.locale]);")).toEqual([]); + }); + + it("is a function that calls one of those, however far, in the same file or another", () => { + const apps = { + "apps/x/src/lib.ts": ` + import { t } from "@obop/ui"; + export const label = (id: string) => t(id); + export function title(m: { name: string }) { return label(m.name); } + export const plain = (a: number) => a + 1;`, + "apps/x/src/mid.ts": ` + import { title as nameOf } from "./lib.ts"; + export const heading = (m: { name: string }) => nameOf(m).toUpperCase();`, + "apps/x/src/barrel.ts": `export { heading as top } from "./mid.ts";\nexport * from "./lib.ts";`, + }; + for (const [use, from] of [ + ["title(m)", `import { title } from "./lib.ts";`], + ["heading(m)", `import { heading } from "./mid.ts";`], + ["top(m)", `import { top } from "./barrel.ts";`], + ["label(m)", `import { label } from "./barrel.ts";`], + ["alias(m)", `import { title as alias } from "./lib.ts";`], + ] as const) + expect( + flagged({ ...apps, "apps/x/src/a.tsx": `${from}\nconst v = useMemo(() => ${use}, [m]);` }), + use, + ).toHaveLength(1); + // a function that does not translate is not flagged, nor one of the same name in another file + expect( + flagged({ + ...apps, + "apps/x/src/a.tsx": `import { plain } from "./lib.ts";\nconst v = useMemo(() => plain(1), [m]);`, + }), + ).toEqual([]); expect( - problem(hooks("// i18n-memo-ok:\nconst f = useMemo(() => t('x'), []);")[0]!), - ).not.toBeNull(); + flagged({ + ...apps, + "apps/x/src/other.ts": "export const title = (m: string) => m;", + "apps/x/src/a.tsx": `import { title } from "./other.ts";\nconst v = useMemo(() => title("a"), [m]);`, + }), + ).toEqual([]); }); - it("holds a memo component to using the language hook", () => { - expect(problem(hooks("const C = memo(function C() { return t('x'); });")[0]!)).not.toBeNull(); + it("is a function that is used and not called: .sort(byPosition), .map(label)", () => { + const lib = `const byPosition = (a: { n: string }, b: { n: string }) => compareText(a.n, b.n); + const label = (id: string) => t(id);`; + expect(f(`${lib}\nconst v = useMemo(() => rows.sort(byPosition), [rows]);`)).toHaveLength(1); + expect(f(`${lib}\nconst v = useMemo(() => ids.map(label), [ids]);`)).toHaveLength(1); + expect( + f(`${lib}\nconst v = useMemo(() => [...rows].sort(byPosition), [rows, locale]);`), + ).toEqual([]); + }); + + it("is a function whose default value calls one: describe(o, lang = getLang())", () => { + const lib = "const describe = (o: { d: string }, lang = getLang()) => o.d + lang;"; + expect(f(`${lib}\nconst v = useMemo(() => describe(o), [o]);`)).toHaveLength(1); + }); + + it("is a component in a cached element, which does not render again", () => { + const lib = 'const Badge = ({ n }: { n: number }) => {t("count")}{n};'; + expect(f(`${lib}\nconst v = useMemo(() => , []);`)).toHaveLength(1); + expect( + f(`${lib}\nconst v = useMemo(() => rows.map((r) => ), [rows]);`), + ).toHaveLength(1); + }); + + it("is a member that the data context builds from a translating function: d.recordLabel, d.refText", () => { + const apps = { + "apps/x/src/data.tsx": ` + import { t } from "@obop/ui"; + export type Ctx = { recordLabel: (m: string) => string; refText: (m: string) => string }; + export const labelOf = (m: string) => t(m); + export function provide(): Ctx { + const recordLabel = (m: string) => labelOf(m); + const refText = (m: string) => recordLabel(m); + return { recordLabel, refText }; + }`, + }; + for (const use of [ + "d.recordLabel(m)", + "d.refText(m)", + "rows.map(d.recordLabel)", + "pick(d.refText, 1)", + ]) + expect( + flagged({ ...apps, "apps/x/src/a.tsx": `const v = useMemo(() => ${use}, [rows]);` }), + use, + ).toHaveLength(1); + // another method that is not handed out by a translating function is left alone expect( - problem(hooks("const C = memo(function C() { useI18n(); return t('x'); });")[0]!), - ).toBeNull(); + flagged({ ...apps, "apps/x/src/a.tsx": "const v = useMemo(() => d.version(m), [rows]);" }), + ).toEqual([]); + }); + + it("is a helper of the component around the memo", () => { + expect( + f(`function C() { + const label = (id: string) => t(id); + const v = useMemo(() => ids.map((i) => label(i)), [ids]); + return v; + }`), + ).toHaveLength(1); + }); + + it("does not mistake a name of its own for the function: t as a variable, a local compareText", () => { + expect( + f("const v = useMemo(() => rows.filter((t) => t.ok).map((t) => t.id), [rows]);"), + ).toEqual([]); + expect( + f(`function C() { + const compareText = 3; + const v = useMemo(() => compareText + 1, []); + return v; + }`), + ).toEqual([]); + // and a key of an object literal is not a use + expect(f("const v = useMemo(() => ({ t: 1, fmt: 2, compareText: 3 }), []);")).toEqual([]); + }); + + it("holds a memo component to using the language hook", () => { + expect(f('const C = memo(function C() { return t("x"); });')).toHaveLength(1); + expect(f('const C = memo(function C() { useI18n(); return t("x"); });')).toEqual([]); + }); + + it("does not follow what only changes the language", () => { + expect(f('const v = useMemo(() => setLanguage("de"), []);')).toEqual([]); }); }); -/** Every source file of the apps and of the design system that the back office is made of. */ -function sources(): string[] { - const here = dirname(fileURLToPath(import.meta.url)); - const web = join(here, "../../.."); - const roots = [ - ...readdirSync(join(web, "apps")).map((a) => join(web, "apps", a, "src")), - join(web, "packages/ui/src"), - ]; - const files: string[] = []; - const walk = (dir: string) => { - let entries; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - return; // an app without a src directory - } - for (const e of entries) { - const p = join(dir, e.name); - if (e.isDirectory()) walk(p); - else if (/\.tsx?$/.test(e.name) && !/\.test\.tsx?$/.test(e.name)) files.push(p); - } - }; - roots.forEach(walk); - return files.sort(); -} +/* ---------------------------------------------------------------------------------------------- */ +/* The source of the apps */ +/* ---------------------------------------------------------------------------------------------- */ describe("the source of the apps", () => { - it("has no memo that translates or sorts without the language in its dependencies", () => { - const web = join(dirname(fileURLToPath(import.meta.url)), "../../.."); - const files = sources().map((file) => ({ file, src: readFileSync(file, "utf8") })); - const flagged: string[] = []; + const tree = readTree(web); + const a = analyse(tree); + const names = new Set([...a.translators].map((k) => k.split("#")[1]!)); + + it("reads every file, and the brackets of each balance once text is blanked", () => { + expect(tree.length).toBeGreaterThan(100); + expect(tree.filter((s) => imbalance(s.text) !== 0).map((s) => s.path)).toEqual([]); + }); + + it("knows every function of the i18n store that reads the language", () => { + // a new reader must be named in SEEDS (it shows a text) or NOT_DISPLAY (it applies a choice) + expect(unclassifiedReaders(tree)).toEqual([]); + for (const seed of SEEDS) + expect(a.translators.has(`packages/ui/src/i18n/store.ts#${seed}`), seed).toBe(true); + }); + + it("finds the functions that translate by what they call, not by a list", () => { + // Each of these reaches the language only through others; one missing means the reader lost a + // link (a changed import, a pattern it cannot read), and the memos that use it are unguarded. + const must = [ + "apps/backoffice/src/labels.ts#appTitle", + "apps/backoffice/src/labels.ts#modelTitle", + "apps/backoffice/src/labels.ts#fieldTitle", + "apps/backoffice/src/format.ts#optionLabel", + "apps/backoffice/src/serverMessage.ts#serverMessage", + "apps/backoffice/src/Chrome.tsx#buildApps", + "apps/backoffice/src/Chrome.tsx#appBlurb", + "apps/backoffice/src/data.tsx#recordLabelOf", + "apps/backoffice/src/dashboards/DashboardsApp.tsx#byPosition", + "apps/backoffice/src/dashboards/widget.ts#resolve", + "apps/backoffice/src/serverQuery.ts#toServer", + "apps/backoffice/src/query.ts#orderKeys", + "apps/backoffice/src/query.ts#groupLabel", + "packages/ui/src/i18n/labels.ts#labelFor", + "packages/ui/src/i18n/labels.ts#inSentence", + "packages/ui/src/i18n/store.ts#compareText", + "packages/ui/src/i18n/store.ts#t", + "packages/ui/src/i18n/store.ts#fmt", + ]; + expect(must.filter((k) => !a.translators.has(k))).toEqual([]); + // members of the data context, which are built inside the provider + for (const m of ["recordLabel", "refText"]) expect(a.members.has(m), m).toBe(true); + // and the set is not everything: functions that only shape data are not in it + for (const plain of ["isEmpty", "keyOf", "slug", "href", "msg", "personalLang", "setLanguage"]) + expect(names.has(plain), plain).toBe(false); + }); + + it("has no memo that translates, sorts or formats without the language in its dependencies", () => { + const found: string[] = []; let seen = 0; - for (const { file, src } of files) { - const hs = hooks(src); - seen += hs.length; - for (const h of hs) { - const why = problem(h); - if (why) - flagged.push(`${relative(web, file).replaceAll("\\", "/")}:${h.line} ${h.hook}: ${why}`); + for (const s of tree) + for (const h of hooks(s.text, a.files.get(s.path)!.masked)) { + seen++; + const why = problem(a, s.path, h); + if (why) found.push(`${s.path}:${h.line} ${h.hook}: ${why}`); } - } // The scan must have found the hooks (a broken reader would find none and pass). expect(seen).toBeGreaterThan(40); expect( - flagged, + found, "Add lang or locale to the dependencies, or mark the hook with i18n-memo-ok: ", ).toEqual([]); }); + + it("explains every memo that names the language: without it the memo would be flagged", () => { + // Take the language out of the dependencies of each hook that has it, in memory. A hook the + // reader cannot explain this way has a dependency that nothing protects. + const unguarded: string[] = []; + let carrying = 0; + for (const s of tree) + for (const h of hooks(s.text, a.files.get(s.path)!.masked)) { + if (!namesLanguage(h.deps) || h.exempt) continue; + carrying++; + const without = h + .deps!.replace(/,?\s*(?:[\w$]+\.)?(?:lang|locale)(?![\w$])/g, "") + .replace(/^\[\s*,/, "["); + if (!problem(a, s.path, { ...h, deps: without })) unguarded.push(`${s.path}:${h.line}`); + } + expect(carrying).toBeGreaterThan(10); + expect(unguarded).toEqual([]); + }); }); diff --git a/web/apps/backoffice/src/i18nGuard.ts b/web/apps/backoffice/src/i18nGuard.ts new file mode 100644 index 00000000..e3af8ae5 --- /dev/null +++ b/web/apps/backoffice/src/i18nGuard.ts @@ -0,0 +1,745 @@ +// The reader behind `i18nDeps.test.ts`. It is test support, not part of the app: nothing imports it +// but that test, so the build leaves it out. +// +// The mistake it looks for: text is translated, sorted and formatted by calling the global `t()` and +// the functions built on it while a component renders. The language is not a prop or a state, so a +// `useMemo` that calls any of them keeps the text of the language it was first computed in unless +// the language is one of its dependencies (see `I18nProvider` in @obop/ui). +// +// Which functions are "any of them" is worked out from the source, not listed by hand: a function +// translates when its body (parameters and default values included) refers to one that does, down +// to the few functions of the i18n store that read the active language (`SEEDS`). The graph is +// resolved through imports and re-exports, so `import { label } from "./format.ts"` is that file's +// `label`, not another file's. What is read: +// +// - a plain name that resolves to a translating function or constant (`appTitle(m)`, and also +// `.sort(byPosition)`, `.map(shownLabel)`, `fmt.money(1)`: a use, not only a call), +// - a JSX tag that resolves to one (``: a cached element does not render again), +// - a member call whose name is a translating function declared inside another one +// (`d.recordLabel(...)`, `d.refText(...)`, built in the data provider). +// +// Not read (see ISSUES.md D14): `useCallback`, `useEffect`, an initial `useState(() => t(...))`, +// state that holds a finished translated string, and a translating function reached only through a +// member of an object whose type the reader cannot see. + +import { readFileSync, readdirSync } from "node:fs"; +import { join, posix } from "node:path"; + +/** `src` with comments, the text of strings and templates and regular expressions blanked (same length). */ +export function code(src: string): string { + const out = src.split(""); + const n = src.length; + const blank = (from: number, to: number) => { + for (let k = from; k < to && k < n; k++) if (out[k] !== "\n") out[k] = " "; + }; + const templates: number[] = []; // the brace depth at which each open `${` was entered + let depth = 0; + let prev = ""; // the last character of code, to tell a division from a regular expression + let before = ""; // and the one before it (an arrow `=>` is followed by an expression) + let i = 0; + // The text of a template literal, from `i` to its closing backtick or the next `${`. + const templateText = () => { + const from = i; + while (i < n) { + const c = src[i]!; + if (c === "\\") i += 2; + else if (c === "`") { + blank(from, i); + i++; + prev = "`"; + return; + } else if (c === "$" && src[i + 1] === "{") { + blank(from, i); + templates.push(depth); + i += 2; + prev = "{"; + return; + } else i++; + } + blank(from, n); + }; + while (i < n) { + const c = src[i]!; + const d = src[i + 1]; + if (c === "/" && d === "/") { + const e = src.indexOf("\n", i); + const end = e < 0 ? n : e; + blank(i, end); + i = end; + } else if (c === "/" && d === "*") { + const e = src.indexOf("*/", i + 2); + const end = e < 0 ? n : e + 2; + blank(i, end); + i = end; + } else if (c === '"' || c === "'") { + let j = i + 1; + while (j < n && src[j] !== c && src[j] !== "\n") j += src[j] === "\\" ? 2 : 1; + blank(i + 1, j); + i = src[j] === c ? j + 1 : j; + prev = c; + } else if (c === "`") { + i++; + templateText(); + } else if ( + c === "/" && + (prev === "" || + "(,=:[!&|?{};".includes(prev) || + (prev === ">" && before === "=") || + /(?:^|[^\w$])(?:return|typeof|case|in|of|else|do|void|delete|throw|yield|await)\s*$/.test( + src.slice(Math.max(0, i - 12), i), + )) + ) { + let j = i + 1; + let inClass = false; + while (j < n && src[j] !== "\n" && (inClass || src[j] !== "/")) { + if (src[j] === "\\") j++; + else if (src[j] === "[") inClass = true; + else if (src[j] === "]") inClass = false; + j++; + } + if (src[j] === "/") { + blank(i + 1, j); + i = j + 1; + prev = "/"; + } else i++; + } else { + if (c === "{") depth++; + else if (c === "}") { + if (templates.length && templates[templates.length - 1] === depth) { + templates.pop(); + i++; + templateText(); + continue; + } + depth--; + } + if (!/\s/.test(c)) { + before = prev; + prev = c; + } + i++; + } + } + return out.join(""); +} + +/* ---------------------------------------------------------------------------------------------- */ +/* Hooks */ +/* ---------------------------------------------------------------------------------------------- */ + +const EXEMPT = /i18n-memo-ok[ \t]*:[ \t]*\S/; +/** What a dependency list must name. */ +const LANGUAGE_DEP = /(?`, an arrow's `=>` does not close it. + let angle = 0; + for (; i < masked.length; i++) { + if (masked[i] === "<") angle++; + else if (masked[i] === ">" && masked[i - 1] !== "=" && --angle === 0) break; + } + i++; + while (/\s/.test(masked[i] ?? "")) i++; + } + if (masked[i] !== "(") continue; + const start = i + 1; + const args: [number, number][] = []; + let depth = 0; + let from = start; + for (i = start; i < masked.length; i++) { + const c = masked[i]!; + if ("([{".includes(c)) depth++; + else if (")]}".includes(c)) { + if (depth === 0) break; + depth--; + } else if (c === "," && depth === 0) { + args.push([from, i]); + from = i + 1; + } + } + if (masked.slice(from, i).trim()) args.push([from, i]); + const [fn, deps] = args; + if (!fn) continue; + const line = src.slice(0, m.index).split("\n").length; + // The comment may sit on the line before the call or anywhere inside it. + const lineStart = src.lastIndexOf("\n", m.index! - 1); + const before = src.slice(src.lastIndexOf("\n", lineStart - 1) + 1, m.index); + found.push({ + hook: m[1]!, + line, + body: masked.slice(fn[0], fn[1]), + deps: deps ? masked.slice(deps[0], deps[1]).trim() : null, + exempt: EXEMPT.test(before + src.slice(m.index, i)), + at: m.index!, + }); + } + return found; +} + +/** Does the dependency list of a hook name the language or the locale? `null` when it has none. */ +export const namesLanguage = (deps: string | null): boolean | null => + deps === null ? null : LANGUAGE_DEP.test(deps); + +/* ---------------------------------------------------------------------------------------------- */ +/* Declarations */ +/* ---------------------------------------------------------------------------------------------- */ + +export type Source = { path: string; text: string }; + +type Decl = { + name: string; + /** Index range in the masked text: the declaration with its parameters and body. */ + start: number; + end: number; + /** A function (or a constant that holds one), as against a plain value such as `fmt`. */ + fn: boolean; +}; + +/** The bracket depth before each character of `masked`, and after the last. */ +function depthsOf(masked: string): Int32Array { + const d = new Int32Array(masked.length + 1); + let k = 0; + for (let i = 0; i < masked.length; i++) { + d[i] = k; + const c = masked[i]; + if (c === "{" || c === "(" || c === "[") k++; + else if (c === "}" || c === ")" || c === "]") k--; + } + d[masked.length] = k; + return d; +} + +/** The index of the bracket that closes the one at `open`. */ +function closer(depth: Int32Array, open: number, n: number): number { + for (let j = open + 1; j < n; j++) if (depth[j + 1]! === depth[open]!) return j; + return n - 1; +} + +/** The end of the statement that starts at `from`: its `;` at the same depth, or where the block ends. */ +function statementEnd(masked: string, depth: Int32Array, from: number): number { + const base = depth[from]!; + for (let i = from; i < masked.length; i++) { + if (masked[i] === ";" && depth[i] === base) return i + 1; + if (depth[i + 1]! < base) return i; + } + return masked.length; +} + +/** Declarations of `masked`: `function name` and `const name = ...`, at the top level or inside others. */ +function declarations(masked: string, depth: Int32Array, nested: boolean): Decl[] { + const out: Decl[] = []; + const n = masked.length; + const keep = (i: number) => (nested ? depth[i]! > 0 : depth[i]! === 0); + for (const m of masked.matchAll(/(?" && masked[j - 1] !== "=") angle--; + else if (c === "{" && angle === 0) { + const lead = masked.slice(paramsEnd + 1, j).trimEnd(); + if (!lead.endsWith(":") && !/[|&,<]$/.test(lead)) break; + j = closer(depth, j, n); // a type literal: `(): { a: string } {` + } else if (c === ";" && angle === 0) break; // an overload, no body + } + out.push({ name: m[1]!, start: m.index!, end: closer(depth, j, n) + 1, fn: true }); + } + for (const m of masked.matchAll(/(?`) up to the `=` of the declaration. + let i = m.index! + m[0].length; + let angle = 0; + let paren = 0; + for (; i < n; i++) { + const c = masked[i]!; + if (c === "<") angle++; + else if (c === ">" && masked[i - 1] !== "=") angle--; + else if (c === "(" || c === "[" || c === "{") paren++; + else if (c === ")" || c === "]" || c === "}") paren--; + else if (c === "=" && masked[i + 1] !== ">" && angle <= 0 && paren <= 0) break; + else if (c === ";" && paren <= 0) break; + } + if (masked[i] !== "=") continue; // `const a;` is not valid; a loop variable has no `=` here + const start = i + 1; + const init = masked.slice(start, start + 400); + const fn = + /^\s*(?:async\s+)?function\b/.test(init) || + /^\s*useCallback\s*(?:<[^(]*>)?\s*\(/.test(init) || + (/^\s*(?:async\s+)?(?:<[^>(]*>\s*)?(?:\(|[A-Za-z_$][\w$]*\s*=>)/.test(init) && + /=>/.test(init.split(";")[0]!)); + out.push({ name: m[1]!, start: m.index!, end: statementEnd(masked, depth, start), fn }); + } + return out.sort((a, b) => a.start - b.start); +} + +/* ---------------------------------------------------------------------------------------------- */ +/* Files, imports and exports */ +/* ---------------------------------------------------------------------------------------------- */ + +type Binding = { spec: string; name: string }; + +type File = { + path: string; + masked: string; + depth: Int32Array; + top: Map; + nested: Decl[]; + imports: Map; + reexports: Map; + stars: string[]; +}; + +/** `import { a, b as c, type D } from "spec"`, default and namespace imports. */ +function importsOf(text: string): Map { + const out = new Map(); + const re = /(?:^|\n)\s*import\s+(?:type\s+)?([^;"']*?)\s+from\s*["']([^"']+)["']/g; + for (const m of text.matchAll(re)) { + const spec = m[2]!; + let clause = m[1]!; + const named = /\{([^}]*)\}/.exec(clause); + if (named) { + clause = clause.replace(named[0], ""); + for (const part of named[1]!.split(",")) { + const p = part.trim().replace(/^type\s+/, ""); + if (!p) continue; + const [orig, local] = p.split(/\s+as\s+/); + out.set((local ?? orig)!.trim(), { spec, name: orig!.trim() }); + } + } + const ns = /\*\s+as\s+([\w$]+)/.exec(clause); + if (ns) out.set(ns[1]!, { spec, name: "*" }); + const def = /^\s*([\w$]+)\s*,?\s*$/.exec(clause.replace(/\*\s+as\s+[\w$]+/, "")); + if (def) out.set(def[1]!, { spec, name: "default" }); + } + return out; +} + +/** `export { a, b as c } from "spec"` and `export * from "spec"`. */ +function reexportsOf(text: string): { named: Map; stars: string[] } { + const named = new Map(); + const stars: string[] = []; + for (const m of text.matchAll( + /(?:^|\n)\s*export\s+(?:type\s+)?\{([^}]*)\}\s*from\s*["']([^"']+)["']/g, + )) + for (const part of m[1]!.split(",")) { + const p = part.trim().replace(/^type\s+/, ""); + if (!p) continue; + const [orig, as] = p.split(/\s+as\s+/); + named.set((as ?? orig)!.trim(), { spec: m[2]!, name: orig!.trim() }); + } + for (const m of text.matchAll(/(?:^|\n)\s*export\s+\*\s+from\s*["']([^"']+)["']/g)) + stars.push(m[1]!); + return { named, stars }; +} + +/** Where `@obop/ui` is: the public entry of the design system, read as source like everything else. */ +const UI_ENTRY = "packages/ui/src/index.ts"; + +function parse(s: Source): File { + const masked = code(s.text); + const depth = depthsOf(masked); + const top = new Map(); + for (const d of declarations(masked, depth, false)) if (!top.has(d.name)) top.set(d.name, d); + const re = reexportsOf(s.text); + return { + path: s.path, + masked, + depth, + top, + nested: declarations(masked, depth, true), + imports: importsOf(s.text), + reexports: re.named, + stars: re.stars, + }; +} + +/* ---------------------------------------------------------------------------------------------- */ +/* The graph */ +/* ---------------------------------------------------------------------------------------------- */ + +/** + * The functions of the i18n store that read the active language or region directly. Everything else + * that translates, sorts or formats does so by calling one of these (`t` calls `translate`, + * `compareText` and every `fmt` formatter call `formatLocale`, `inSentence` calls `getLang`). + * `classifyStore` makes sure a new reader in the store is not missed. + */ +export const SEEDS = [ + "translate", + "rawMessage", + "hasTranslation", + "getLang", + "getRegion", + "getDir", + "formatLocale", + "pluralOf", + "STRING_ENV", +]; + +/** + * Functions of the store that read the state without showing it: they apply or save a choice, or + * resolve it. A memo that calls them does not cache a text. + */ +export const NOT_DISPLAY = ["applyDocument", "initI18n", "resetLanguage"]; + +const STORE = "packages/ui/src/i18n/store.ts"; + +/** Top-level functions of the store that read the language, region or catalogue and are in neither list. */ +export function unclassifiedReaders(files: Source[]): string[] { + const store = files.find((f) => f.path === STORE); + if (!store) return [`${STORE} is not in the sources`]; + const f = parse(store); + const reads = /\bS\.(?:lang|messages|region)\b(?!\s*=[^=])/; + const out: string[] = []; + for (const d of f.top.values()) + if (reads.test(f.masked.slice(d.start, d.end)) && ![...SEEDS, ...NOT_DISPLAY].includes(d.name)) + out.push(d.name); + return out.sort(); +} + +export type Analysis = { + files: Map; + /** `path#name` of every top-level function or constant that translates, sorts or formats. */ + translators: Set; + /** For each translator, the one it refers to that made it one (a seed has none). */ + via: Map; + /** Names of functions declared inside another that translate and are handed out: `recordLabel`, `refText`. */ + members: Set; + /** For each top-level declaration (`path#name`), the functions declared inside it that translate. */ + locals: Map>; + /** The nodes that are functions, as against constants such as `fmt`. */ + fns: Set; +}; + +const resolveSpec = (files: Map, from: string, spec: string): string | null => { + if (spec === "@obop/ui") return UI_ENTRY; + if (spec === "@obop/ui/static") return "packages/ui/src/static.ts"; + if (!spec.startsWith(".")) return null; + const base = posix.normalize(posix.join(posix.dirname(from), spec)); + for (const c of [base, `${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`]) + if (files.has(c)) return c; + return null; +}; + +/** The `path#name` of the top-level declaration that `name` exports from `path`, through re-exports. */ +function exported( + files: Map, + path: string, + name: string, + seen = new Set(), +): string | null { + const key = `${path}#${name}`; + if (seen.has(key)) return null; + seen.add(key); + const f = files.get(path); + if (!f) return null; + if (f.top.has(name)) return key; + const re = f.reexports.get(name); + if (re) { + const to = resolveSpec(files, path, re.spec); + const hit = to && exported(files, to, re.name, seen); + if (hit) return hit; + } + // `import { x } from "./y"; export { x };` and `export default`: follow the import of the same name. + const imp = f.imports.get(name); + if (imp && !f.top.has(name)) { + const to = resolveSpec(files, path, imp.spec); + const hit = to && imp.name !== "*" && exported(files, to, imp.name, seen); + if (hit) return hit; + } + for (const star of f.stars) { + const to = resolveSpec(files, path, star); + const hit = to && exported(files, to, name, seen); + if (hit) return hit; + } + return null; +} + +/** The node a plain name refers to in `file`: an import, else a top-level declaration of the file. */ +function resolveName(files: Map, file: File, name: string): string | null { + const imp = file.imports.get(name); + if (imp) { + const to = resolveSpec(files, file.path, imp.spec); + return to && imp.name !== "*" ? exported(files, to, imp.name) : null; + } + return file.top.has(name) ? `${file.path}#${name}` : null; +} + +/** The names a piece of code declares itself: constants, parameters and destructured names. */ +function localsOf(masked: string): Set { + const out = new Set(); + const add = (list: string) => { + for (const part of list.split(",")) { + // `a`, `a = 1`, `a: b`, `...a`, `a?: T`: the name that is bound + const p = part.replace(/^\s*\.\.\./, "").trim(); + const m = /^(?:[A-Za-z_$][\w$]*\s*:\s*)?([A-Za-z_$][\w$]*)/.exec(p); + if (m && !/^(?:type|typeof)$/.test(m[1]!)) out.add(m[1]!); + } + }; + for (const m of masked.matchAll(/(?`, `a =>`, `function f(a, b)` + for (const m of masked.matchAll(/\(([^()]*)\)\s*(?::[^=;{]*?)?=>/g)) { + for (const p of m[1]!.split(/,(?![^{[]*[}\]])/)) { + const q = p.trim(); + if (q.startsWith("{") || q.startsWith("[")) add(q.slice(1, -1)); + else add(q); + } + } + for (const m of masked.matchAll(/(?/g)) out.add(m[1]!); + for (const m of masked.matchAll(/function\s*[\w$]*\s*(?:<[^>(]*>)?\s*\(([^()]*)\)/g)) add(m[1]!); + return out; +} + +export type Refs = { + /** + * Resolved `path#name` of every plain name, JSX tag and `fmt`-like object the code uses. The value + * is false when every use is `name.something`: a function has no members, so that is a variable + * of the same name (`t.filter(...)`), not the function. + */ + plain: Map; + /** Names used as `x.name(...)` or passed as `(x.name)`. */ + members: Set; + /** Every plain name, resolved or not: a function declared inside another has no node of its own. */ + bare: Set; +}; + +/** What `text` (a piece of `file`) refers to. `shadow` names are the code's own and are not imports. */ +function refsOf(files: Map, file: File, text: string, shadow: Set): Refs { + const plain = new Map(); + const members = new Set(); + const bare = new Set(); + const add = (name: string, asObject: boolean) => { + bare.add(name); + if (shadow.has(name)) return; + const key = resolveName(files, file, name); + if (key) plain.set(key, (plain.get(key) ?? false) || !asObject); + }; + for (const m of text.matchAll(/(?= 0 && /\s/.test(text[b]!)) b--; + let a = i + name.length; + while (a < text.length && /\s/.test(text[a]!)) a++; + if ((text[b] === "{" || text[b] === ",") && text[a] === ":" && text[a + 1] !== ":") continue; + add(name, text[a] === "."); + } + // `...name`: the lookbehind above rejects a name after a dot, which a spread is + for (const m of text.matchAll(/\.\.\.\s*([A-Za-z_$][\w$]*)/g)) add(m[1]!, false); + // JSX: ` string` in the data context)? + */ +function exposed(file: File, d: Decl, typed: Set): boolean { + if (BUILT_IN.has(d.name) || !typed.has(d.name)) return false; + const rest = file.masked.slice(0, d.start) + file.masked.slice(d.end); + return new RegExp(String.raw`[{,]\s*${d.name}\s*(?=[,}:])`).test(rest); +} + +/** Parse every file and work out which functions translate, sort or format. */ +export function analyse(sources: Source[]): Analysis { + const files = new Map(); + for (const s of sources) files.set(s.path, parse(s)); + // names that some type or object gives as a function: `name: (...) => ...` + const typed = new Set(); + for (const f of files.values()) + for (const m of f.masked.matchAll( + /(?/g, + )) + typed.add(m[1]!); + + type Node = { key: string; file: File; decl: Decl; refs: Refs; seed: boolean }; + const nodes: Node[] = []; + const own = (file: File, d: Decl) => { + // a function does not shadow itself, and a declaration's parameters are its own + const text = file.masked.slice(d.start, d.end); + const shadow = localsOf(text); + shadow.delete(d.name); + return { text, shadow }; + }; + for (const file of files.values()) + for (const d of file.top.values()) { + const { text, shadow } = own(file, d); + nodes.push({ + key: `${file.path}#${d.name}`, + file, + decl: d, + refs: refsOf(files, file, text, shadow), + seed: file.path === STORE && SEEDS.includes(d.name), + }); + } + + // Functions declared inside others. Each is a local of the one around it (`label` in a component), + // and one that is handed out as a property is a member for `d.name(...)` anywhere. + const inner: { scope: string; file: File; decl: Decl; refs: Refs }[] = []; + for (const file of files.values()) + for (const d of file.nested) { + if (!d.fn) continue; + const outer = [...file.top.values()].find((t) => t.start <= d.start && d.start < t.end); + if (!outer) continue; + const { text, shadow } = own(file, d); + inner.push({ + scope: `${file.path}#${outer.name}`, + file, + decl: d, + refs: refsOf(files, file, text, shadow), + }); + } + + const isFn = new Map(nodes.map((n) => [n.key, n.decl.fn])); + const translators = new Set(nodes.filter((n) => n.seed).map((n) => n.key)); + const via = new Map(); + const members = new Set(); + const locals = new Map>(); // scope -> names of its translating inner functions + for (let grown = true; grown;) { + grown = false; + const reaches = (r: Refs, scope?: string): string | null => { + for (const [k, direct] of r.plain) + if (translators.has(k) && (direct || !isFn.get(k))) return k.split("#")[1]!; + for (const m of r.members) if (members.has(m)) return `.${m}`; + if (scope) for (const b of r.bare) if (locals.get(scope)?.has(b)) return b; + return null; + }; + for (const n of nodes) { + const why = translators.has(n.key) ? null : reaches(n.refs, n.key); + if (why) { + translators.add(n.key); + via.set(n.key, why); + grown = true; + } + } + for (const x of inner) { + const mine = locals.get(x.scope) ?? locals.set(x.scope, new Set()).get(x.scope)!; + if (mine.has(x.decl.name) || !reaches(x.refs, x.scope)) continue; + mine.add(x.decl.name); + grown = true; + } + for (const x of inner) + if ( + !members.has(x.decl.name) && + locals.get(x.scope)?.has(x.decl.name) && + exposed(x.file, x.decl, typed) + ) { + members.add(x.decl.name); + grown = true; + } + } + return { + files, + translators, + via, + members, + locals, + fns: new Set([...isFn].filter(([, f]) => f).map(([k]) => k)), + }; +} + +/** What a hook uses that translates, sorts or formats; empty when nothing. */ +export function usesTranslators(a: Analysis, path: string, hook: Hook): string[] { + const file = a.files.get(path); + if (!file) return []; + // the names of the component around the hook are its own: constants and parameters declared there + let outer = new Set(); + let scope = ""; + for (const d of file.top.values()) + if (d.start <= hook.at && hook.at < d.end) { + outer = localsOf(file.masked.slice(d.start, d.end)); + scope = `${path}#${d.name}`; + } + const shadow = new Set([...outer, ...localsOf(hook.body)]); + const refs = refsOf(a.files, file, hook.body, shadow); + const hit: string[] = []; + for (const [key, direct] of refs.plain) + if (a.translators.has(key) && (direct || !a.fns.has(key))) hit.push(key.split("#")[1]!); + for (const m of refs.members) if (a.members.has(m)) hit.push(`.${m}`); + // a helper of the component around the hook that translates (`const label = (x) => t(x)`) + for (const b of refs.bare) if (a.locals.get(scope)?.has(b)) hit.push(b); + return [...new Set(hit)].sort(); +} + +/** Why a hook can keep an old language, or `null` when it cannot. */ +export function problem(a: Analysis, path: string, h: Hook): string | null { + if (h.exempt) return null; + const uses = usesTranslators(a, path, h); + if (!uses.length) return null; + if (h.hook === "memo") + return /(? join("apps", a, "src")), + "packages/ui/src", + ]; + const out: Source[] = []; + const walk = (dir: string) => { + let entries; + try { + entries = readdirSync(join(web, dir), { withFileTypes: true }); + } catch { + return; // an app without a src directory + } + for (const e of entries) { + const p = posix.join(dir.replaceAll("\\", "/"), e.name); + if (e.isDirectory()) walk(p); + else if (/\.tsx?$/.test(e.name) && !/\.test\.tsx?$/.test(e.name)) + out.push({ path: p, text: readFileSync(join(web, p), "utf8") }); + } + }; + roots.forEach(walk); + return out.sort((a, b) => a.path.localeCompare(b.path)); +} From 6f0e6d7741181095aec61aabdb63852172eaf586 Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:24:37 +0100 Subject: [PATCH 11/29] back office: renaming an app no longer makes its field lose what is typed next The name field of Customize > Apps was keyed by the app's name, so it was made again when its own rename was saved; keystrokes typed after refocusing in that moment were lost. It is a controlled field now: it shows what is typed and not yet saved, else the app's name, so it follows a change of language, a reset of the launcher and a rename saved elsewhere without being made again. The e2e spec for the Apps tab gets stricter in the same change: - the request spy matched an address that ends in /api/setting/update, which the app never sends (it adds ?lang=..), so "no setting was written" could not fail; it matches the path now, and the single write of the spec is the rename, so a write on blur would show as a second one; - the Swedish app name comes from /api/meta (the module's label) as the page builds it, not from a word typed into the test; - no fixed wait before a negative assertion; the order of the writes decides; - the field is the same element after its own rename is saved, and a reset of the launcher puts the name of the language back. Co-Authored-By: Claude Sonnet 5.5 --- web/apps/backoffice/src/Customize.tsx | 22 +++-- .../e2e/tests/language-switch.spec.ts | 95 ++++++++++++------- 2 files changed, 76 insertions(+), 41 deletions(-) diff --git a/web/apps/backoffice/src/Customize.tsx b/web/apps/backoffice/src/Customize.tsx index 3e140e65..60783097 100644 --- a/web/apps/backoffice/src/Customize.tsx +++ b/web/apps/backoffice/src/Customize.tsx @@ -409,6 +409,9 @@ function ThemeTab(props: Props) { } function AppsTab({ apps, settings, isAdmin, token, onSettings, notify }: Props) { + // What is typed in a name field and not yet saved, by app. The field shows the app's name + // otherwise, so it follows the language, a reset of the launcher and a rename saved elsewhere. + const [typed, setTyped] = useState>({}); const real = apps.filter((a) => a.id !== "customize"); const layout = settings.apps ?? {}; const hidden = new Set(layout.hidden ?? []); @@ -441,15 +444,18 @@ function AppsTab({ apps, settings, isAdmin, token, onSettings, notify }: Props)
  • - e.target.value !== a.name && - void save({ ...layout, names: { ...layout.names, [a.id]: e.target.value } }) - } + onChange={(e) => setTyped((m) => ({ ...m, [a.id]: e.target.value }))} + onBlur={async (e) => { + const name = e.target.value; + if (name !== a.name) + await save({ ...layout, names: { ...layout.names, [a.id]: name } }); + // Kept until the save is answered, so the field does not flash the old name. + setTyped((m) => + Object.fromEntries(Object.entries(m).filter(([id]) => id !== a.id)), + ); + }} /> { + const r = await api.get("/api/meta", await api.token("root")); + return Array.isArray(r.json) ? r.json : r.json.modules; +} + +/** + * The name of a module's app in `lang`, as the app builds it: the module's own label in + * `/api/meta` when it has one, else the catalogue's word for the module's English name. + */ +async function moduleApp(api: Api, id: string, lang: string): Promise { + const m = (await modules(api)).find((x) => x.id === id); + expect(m, `module ${id}`).toBeTruthy(); + return m.labels?.[lang] ?? appName(lang, m.name); +} + /** What the server sends as the description of a model in `lang` (English is the description itself). */ async function describe(api: Api, model: string, lang: string): Promise { - const r = await api.get("/api/meta", await api.token("root")); - const modules: any[] = Array.isArray(r.json) ? r.json : r.json.modules; - const m = modules.flatMap((x) => x.models).find((x) => x.name === model); + const m = (await modules(api)).flatMap((x) => x.models).find((x) => x.name === model); const text = lang === "en" ? m.description : m.descriptions?.[lang]; expect(text, `a ${lang} description of ${model}`).toBeTruthy(); return text; @@ -78,7 +93,7 @@ const savedChoice = (page: Page) => page.evaluate(() => localStorage.getItem("ob async function switchInSettings(page: Page, api: Api, from: string, to: string) { await goHome(page); const before = await tiles(page); - const invoicingFrom = appName(from, "Invoicing"); + const invoicingFrom = await moduleApp(api, "invoicing", from); expect(before.map((t) => t.name)).toContain(invoicingFrom); await openLanguageSettings(page, from); @@ -98,7 +113,7 @@ async function switchInSettings(page: Page, api: Api, from: string, to: string) live.map((t) => t.app), "the order of the apps depends on the language", ).not.toEqual(before.map((t) => t.app)); - const invoicingTo = appName(to, "Invoicing"); + const invoicingTo = await moduleApp(api, "invoicing", to); expect(live.find((t) => t.app === "invoicing")?.name).toBe(invoicingTo); expect(live.map((t) => t.name)).not.toContain(invoicingFrom); @@ -182,58 +197,72 @@ test.describe("switching the language in Settings, without a reload", () => { }); test.describe("switching the language with the account menu, on a screen that stays open", () => { - test("the Apps tab follows, and leaving a name field as it is writes no setting", async ({ + test("the Apps tab follows; leaving a name field as it is writes nothing, a rename saves and keeps the field", async ({ page, signIn, api, }) => { - const before = (await api.run("root", "setting.get", { id: "instance" })) as { value: string }; + const settings = async () => + (await api.run("root", "setting.get", { id: "instance" })) as { value: string }; + const before = await settings(); + const names = async () => JSON.parse((await settings()).value || "{}").apps?.names ?? {}; + // Every write of a setting, in the order the page sent them. A request goes out in the handler + // of the event that causes it, so what the page did earlier is listed before what it did later. const writes: string[] = []; page.on("request", (r) => { - if (r.method() === "POST" && r.url().endsWith("/api/setting/update")) writes.push(r.url()); + if (r.method() === "POST" && isCommand(r.url(), "setting", "update")) + writes.push(JSON.parse(r.postData() ?? "{}").value); }); + const en = await moduleApp(api, "invoicing", "en"); + const sv = await moduleApp(api, "invoicing", "sv"); + expect(sv, "the app has another name in Swedish").not.toBe(en); try { await signIn("root", "/customize/apps?tab=apps"); const nameField = (lang: string, app: string) => page.getByLabel(say(lang, "Name of {name}").replace("{name}", app), { exact: true }); - await expect(nameField("en", "Invoicing")).toHaveValue("Invoicing"); + await expect(nameField("en", en)).toHaveValue(en); await pickInAccountMenu(page, "en", "sv"); // Every name, and every label built from it, is Swedish: the field shows what the app is called. - const field = nameField("sv", "Fakturering"); - await expect(field).toHaveValue("Fakturering"); + const field = nameField("sv", sv); + await expect(field).toHaveValue(sv); await expect( - page.getByLabel(say("sv", "Move {name} up").replace("{name}", "Fakturering")), + page.getByLabel(say("sv", "Move {name} up").replace("{name}", sv)), ).toBeVisible(); await expect( - page.getByLabel(say("sv", "Colour of {name}").replace("{name}", "Fakturering")), + page.getByLabel(say("sv", "Colour of {name}").replace("{name}", sv)), ).toBeVisible(); // Clicking into the field and out of it again changes nothing. Were the field to keep the - // English name it was made with, this would save "Invoicing" as the name for everyone. + // English name it was made with, this would save it as the name for everyone. await field.focus(); await field.blur(); - // The save would be sent from the blur handler; give it room to show up before looking. - await page.waitForTimeout(500); - expect(writes, "a setting was written by leaving the field").toEqual([]); - const after = (await api.run("root", "setting.get", { id: "instance" })) as { - value: string; - }; - expect(after.value).toBe(before.value); - - // A real rename still saves, and the field keeps what was typed. - await field.fill("Fakturor"); + + // A real rename saves. It is the first write the page made, so the blur above wrote nothing: + // a write it had sent would be listed before this one. + const renamed = `${sv} 2`; + await field.fill(renamed); await field.blur(); - await expect - .poll(async () => { - const s = (await api.run("root", "setting.get", { id: "instance" })) as { - value: string; - }; - return JSON.parse(s.value || "{}").apps?.names?.invoicing; - }) - .toBe("Fakturor"); - await expect(nameField("sv", "Fakturor")).toHaveValue("Fakturor"); + await expect.poll(async () => (await names()).invoicing).toBe(renamed); + expect(writes.map((w) => JSON.parse(w).apps?.names)).toEqual([{ invoicing: renamed }]); + + // The saved name is in the app list now, and the field is still the one the person was typing + // in: it is not made again, which would drop what is typed next. + const saved = nameField("sv", renamed); + await expect(saved).toHaveValue(renamed); + const node = await saved.elementHandle(); + await saved.focus(); + await page.keyboard.press("End"); + await page.keyboard.type("!"); + await expect(saved).toHaveValue(`${renamed}!`); + expect(await node!.evaluate((n) => n.isConnected), "the field was made again").toBe(true); + await saved.blur(); + await expect.poll(async () => (await names()).invoicing).toBe(`${renamed}!`); + + // Resetting the launcher puts the name of the language back, in the same field. + await page.getByRole("button", { name: say("sv", "Reset the launcher") }).click(); + await expect(nameField("sv", sv)).toHaveValue(sv); } finally { await api.run("root", "setting.update", { id: "instance", value: before.value }); } From a45cbf4eb379f22bf6d504ba16ea0254028a1097 Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:25:18 +0100 Subject: [PATCH 12/29] back office: ask the server again after a language switch only where the request carries the language api.ts puts lang on a search only when it has an order, so a list or a board column without a sort gets the same answer in every language, and the locale in the query keys of useServerRows and the Kanban columns made it fetch the same page again for nothing. The locale is part of those keys only when there is a sort. useAggregate asked again on a switch too, but every consumer builds its own order from the groups (the pivot, the graph, the board totals and the widgets all sort or index them), so the change did nothing; it is back to the version on main. Two e2e tests: a list without a sort sends no request on a switch (the next one is the sorted one, in the new language), and a sorted board puts the cards of a column in the order of the new language. Co-Authored-By: Claude Sonnet 5.5 --- web/apps/backoffice/src/useAggregate.ts | 13 ++--- web/apps/backoffice/src/useServer.ts | 14 +++-- web/apps/backoffice/src/views/KanbanView.tsx | 15 ++++- .../e2e/tests/language-switch.spec.ts | 57 +++++++++++++++++++ 4 files changed, 82 insertions(+), 17 deletions(-) diff --git a/web/apps/backoffice/src/useAggregate.ts b/web/apps/backoffice/src/useAggregate.ts index 2ff3f348..21fa0349 100644 --- a/web/apps/backoffice/src/useAggregate.ts +++ b/web/apps/backoffice/src/useAggregate.ts @@ -1,5 +1,4 @@ import { useEffect, useRef, useState } from "react"; -import { useI18n } from "@obop/ui"; import { aggregate, type AggGroup, @@ -26,10 +25,9 @@ export type Aggregated = { const idle: Omit = { groups: [], total: 0, loaded: false, loading: false }; /** - * Groups and totals from `.search` with `aggregate` (D4). It asks again when the query or the - * locale changes (the server orders the groups in the collation of the locale it is told) and after - * any successful write, ignores answers that arrive late and keeps the previous answer on screen - * while the next one loads. + * Groups and totals from `.search` with `aggregate` (D4). It asks again when the query + * changes and after any successful write, ignores answers that arrive late and keeps the previous + * answer on screen while the next one loads. */ export function useAggregate(opts: { enabled: boolean; @@ -39,12 +37,11 @@ export function useAggregate(opts: { measures: AggMeasure[]; }): Aggregated { const d = useData(); - const { locale } = useI18n(); const [st, setSt] = useState>(idle); const [attempt, setAttempt] = useState(0); const seq = useRef(0); const { enabled, model, filter, groupBy, measures } = opts; - const key = JSON.stringify([model, filter, groupBy, measures, locale]); + const key = JSON.stringify([model, filter, groupBy, measures]); useEffect(() => { if (!enabled) return; @@ -56,7 +53,7 @@ export function useAggregate(opts: { setSt((s) => ({ ...s, loading: false, loaded: true, error: r.error, status: r.status })); else setSt({ groups: r.groups, total: r.total, loaded: true, loading: false }); }); - // The key stands for the model, filter, grouping, measures and locale. + // The key stands for the model, filter, grouping and measures. // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, d.token, key, d.mutations, attempt]); diff --git a/web/apps/backoffice/src/useServer.ts b/web/apps/backoffice/src/useServer.ts index fe540040..3bc21277 100644 --- a/web/apps/backoffice/src/useServer.ts +++ b/web/apps/backoffice/src/useServer.ts @@ -18,10 +18,11 @@ export type ServerRows = { const idle: ServerRows = { rows: [], total: 0, denied: [], loaded: false, loading: false }; /** - * One page of a model from `.search`. It asks again when the query changes, when the locale - * changes (the server sorts text in the collation of the locale it is told) and after any - * successful write, ignores answers that arrive late, and keeps the previous page on screen while - * the next one loads so the table does not flash. + * One page of a model from `.search`. It asks again when the query changes, after any + * successful write and, for a sorted query, when the locale changes (the server sorts text in the + * collation of the locale it is told, and `search` tells it only when there is a sort). It ignores + * answers that arrive late, and keeps the previous page on screen while the next one loads so the + * table does not flash. */ export function useServerRows(opts: { enabled: boolean; @@ -36,7 +37,8 @@ export function useServerRows(opts: { const [st, setSt] = useState(idle); const seq = useRef(0); const { enabled, model, filter, order, offset, limit } = opts; - const key = JSON.stringify([model, filter, order, offset, limit, locale]); + // The locale is part of the query only where the request carries it (`lang`, api.ts): a sort. + const key = JSON.stringify([model, filter, order, offset, limit, order.length ? locale : null]); useEffect(() => { if (!enabled) return; @@ -58,7 +60,7 @@ export function useServerRows(opts: { loading: false, }); }); - // The key stands for model, filter, order, offset, limit and locale. + // The key stands for model, filter, order, offset, limit and, for a sort, the locale. // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, d.token, key, d.mutations]); diff --git a/web/apps/backoffice/src/views/KanbanView.tsx b/web/apps/backoffice/src/views/KanbanView.tsx index 46424784..b3c4926c 100644 --- a/web/apps/backoffice/src/views/KanbanView.tsx +++ b/web/apps/backoffice/src/views/KanbanView.tsx @@ -69,8 +69,9 @@ type ColumnState = { rows: Row[]; total: number; loading: boolean; error?: strin /** * The cards of each column from `.search`, one page per column (D4): a column asks for its * first cards and again for more when "Load more" raises its limit. Columns refetch after any - * write or when the search or the locale (the server sorts the cards in its collation) changes; the - * cards already shown stay until the new ones arrive. + * write, when the search changes and, when the cards are sorted, when the locale changes (the + * server sorts them in the collation of the locale it is told); the cards already shown stay until + * the new ones arrive. */ function useColumnCards(opts: { enabled: boolean; @@ -88,7 +89,15 @@ function useColumnCards(opts: { const [cols, setCols] = useState>({}); const seen = useRef(new Map()); const seq = useRef(0); - const baseSig = JSON.stringify([model, base, order, field?.name, d.mutations, locale]); + // The locale is part of the query only where the request carries it (`lang`, api.ts): a sort. + const baseSig = JSON.stringify([ + model, + base, + order, + field?.name, + d.mutations, + order.length ? locale : null, + ]); const [attempt, setAttempt] = useState(0); useEffect(() => { diff --git a/web/packages/e2e/tests/language-switch.spec.ts b/web/packages/e2e/tests/language-switch.spec.ts index ddbe6781..017e4949 100644 --- a/web/packages/e2e/tests/language-switch.spec.ts +++ b/web/packages/e2e/tests/language-switch.spec.ts @@ -320,4 +320,61 @@ test.describe("switching the language with the account menu, on a screen that st await page.reload(); await expect(titles).toHaveText(["Apfel", "Item 2", "Item 10", "Zebra", "Ärger", "Öle"]); }); + + test("a list with no sort is not asked for again: nothing in its answer depends on the language", async ({ + page, + signIn, + api, + scratchModule, + }) => { + const model = await scratchModule({ + title: "Plain data", + fields: [{ name: "title", required: true }], + }); + for (const t of ["Zebra", "Ärger", "Apfel"]) await api.create("root", model, { title: t }); + await signIn("root", `/${model}/${model}?view=list`); + const titles = page.locator("table.list tbody tr td:nth-child(2)"); + await expect(titles).toHaveCount(3); + + const asked: { order?: unknown[]; lang?: string }[] = []; + page.on("request", (r) => { + if (r.method() === "POST" && isCommand(r.url(), model, "search")) + asked.push(JSON.parse(r.postData() ?? "{}")); + }); + await pickInAccountMenu(page, "en", "sv"); + + // Sort it now (a client-side navigation). The requests are listed in the order they were sent, + // so the one that sorts is the first after the switch when the switch itself asked for nothing. + const sorted = encodeURIComponent(JSON.stringify({ s: { f: "title", dir: "asc" } })); + await page.evaluate((h) => (location.hash = h), `/${model}/${model}?view=list&st=${sorted}`); + await expect(titles).toHaveText(["Apfel", "Zebra", "Ärger"]); + expect(asked).toHaveLength(1); + expect(asked[0]!.order).toHaveLength(1); + expect(asked[0]!.lang).toMatch(/^sv/); + }); + + test("a sorted board orders the cards of a column in the new language", async ({ + page, + signIn, + api, + scratchModule, + }) => { + const model = await scratchModule({ + title: "Board data", + fields: [ + { name: "title", required: true }, + { name: "state", kind: "select", options: ["new", "won"] }, + ], + }); + for (const t of ["Zebra", "Ärger", "Apfel", "Öle"]) + await api.create("root", model, { title: t, state: "new" }); + const sorted = encodeURIComponent(JSON.stringify({ s: { f: "title", dir: "asc" } })); + await signIn("root", `/${model}/${model}?view=kanban&st=${sorted}`); + // Every card is in the one column that has any (the names of the columns are translated). + const cards = page.locator(".kcard .ktitle"); + await expect(cards).toHaveText(["Apfel", "Ärger", "Öle", "Zebra"]); + + await pickInAccountMenu(page, "en", "sv"); + await expect(cards).toHaveText(["Apfel", "Zebra", "Ärger", "Öle"]); + }); }); From 0d1a394d9ab9e0a3f9732ad01db3b44e3c0d6fc7 Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:25:37 +0100 Subject: [PATCH 13/29] back office: an open record stays mounted while the list around it asks again After a language switch a sorted list asks again, and the open record can be missing from the new first page. useRecord then had nothing for a render (the get that fetches it has not answered), ModelPage dropped the record panel, and whatever was typed in it was lost. useRecord remembers the last row it returned for the id and keeps returning it until the get answers; a record that is gone (the get says so) still closes the panel. The e2e test opens a record that is on the first page in English and on the second in Swedish, types in it, switches the language and checks the panel and the draft are still there. Co-Authored-By: Claude Sonnet 5.5 --- web/apps/backoffice/src/useServer.ts | 17 ++++++-- .../e2e/tests/language-switch.spec.ts | 40 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/web/apps/backoffice/src/useServer.ts b/web/apps/backoffice/src/useServer.ts index 3bc21277..ec7deab6 100644 --- a/web/apps/backoffice/src/useServer.ts +++ b/web/apps/backoffice/src/useServer.ts @@ -72,11 +72,17 @@ export function useServerRows(opts: { return st; } -/** One record by id: from the rows already on screen when possible, else fetched with `get`. */ +/** + * One record by id: from the rows already on screen when possible, else fetched with `get`. While + * the rows are being asked for again (a new sort, the language) and the record is no longer among + * them, the last row seen stays until the `get` answers: the page unmounts an open record panel + * the moment this returns nothing, and a draft in it would be lost. + */ export function useRecord(model: string, id: string, onScreen: Row[]): Row | null | undefined { const d = useData(); const have = id && id !== "new" ? onScreen.find((r) => r.id === id) : undefined; const [fetched, setFetched] = useState<{ id: string; row: Row | null } | null>(null); + const last = useRef<{ model: string; id: string; row: Row } | null>(null); const need = !!id && id !== "new" && !have; useEffect(() => { if (!need) return; @@ -88,7 +94,12 @@ export function useRecord(model: string, id: string, onScreen: Row[]): Row | nul live = false; }; }, [need, d.token, model, id, d.mutations]); + const row = + !id || id === "new" ? undefined : (have ?? (fetched?.id === id ? fetched.row : undefined)); + useEffect(() => { + if (row) last.current = { model, id, row }; + }, [row, model, id]); if (!id || id === "new") return undefined; - if (have) return have; - return fetched?.id === id ? fetched.row : undefined; + if (row !== undefined) return row; + return last.current?.model === model && last.current.id === id ? last.current.row : undefined; } diff --git a/web/packages/e2e/tests/language-switch.spec.ts b/web/packages/e2e/tests/language-switch.spec.ts index 017e4949..32663202 100644 --- a/web/packages/e2e/tests/language-switch.spec.ts +++ b/web/packages/e2e/tests/language-switch.spec.ts @@ -378,3 +378,43 @@ test.describe("switching the language with the account menu, on a screen that st await expect(cards).toHaveText(["Apfel", "Zebra", "Ärger", "Öle"]); }); }); + +test.describe("a record panel that stays open while the language changes", () => { + // The name of the panel is translated ("{model} record"): find it by what it is. + const panelOf = (page: Page) => page.locator("aside.record"); + + test("an open record stays open, with what was typed in it, when the sorted list around it asks again", async ({ + page, + signIn, + api, + scratchModule, + }) => { + const model = await scratchModule({ + title: "Open data", + fields: [{ name: "title", required: true }, { name: "note" }], + }); + const open = await api.create("root", model, { title: "Ärger" }); + const others = [ + "Apfel", + ...Array.from({ length: 28 }, (_, i) => `Item ${String(i + 1).padStart(2, "0")}`), + ]; + for (const t of others) await api.create("root", model, { title: t }); + // Pages of 25: the record is on the first page in English (A, Ä, Item...) and on the second in + // Swedish (Ä sorts after Z), so the new first page does not hold it. + await page.addInitScript(() => localStorage.setItem("obop-pagesize", "25")); + const sorted = encodeURIComponent(JSON.stringify({ s: { f: "title", dir: "asc" } })); + await signIn("root", `/${model}/${model}?view=list&st=${sorted}&id=${open.id}`); + const titles = page.locator("table.list tbody tr td:nth-child(2)"); + await expect(titles).toHaveCount(25); + await expect(titles.nth(1)).toHaveText("Ärger"); + const note = page.locator(`#f-${model}-note`); + await note.fill("not saved yet"); + + await pickInAccountMenu(page, "en", "sv"); + // The list has asked again and the record is no longer on its page... + await expect(titles.filter({ hasText: "Ärger" })).toHaveCount(0); + // ...and the panel is the same one, with its draft. + await expect(panelOf(page).locator("h2")).toHaveText("Ärger"); + await expect(note).toHaveValue("not saved yet"); + }); +}); From 5773706e9ebb519f49919fb16dc586e39bf31285 Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:25:52 +0100 Subject: [PATCH 14/29] back office: the record panel words its errors when it draws them The panel kept finished sentences in state: the text of a field that failed its check or was refused by the server, and the banner for a refused move or action. The panel stays open through a language switch, so they stayed in the language they were written in. It keeps what the sentence is made of instead: which fields failed the check (the sentence is made again from the value, by the same check, when drawn), the server's own words for a refused field and for the banner (serverMessage words them when drawn). moveRecord returns the server's words as raw next to the sentence for its callers that show it at once. Two e2e tests: a field that failed its check and a refused move are in the language of the page after a switch. Co-Authored-By: Claude Sonnet 5.5 --- web/apps/backoffice/src/RecordPanel.tsx | 47 +++++++++---- web/apps/backoffice/src/workflow.ts | 5 +- .../e2e/tests/language-switch.spec.ts | 70 +++++++++++++++++++ 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/web/apps/backoffice/src/RecordPanel.tsx b/web/apps/backoffice/src/RecordPanel.tsx index e6746c13..64ef0f9c 100644 --- a/web/apps/backoffice/src/RecordPanel.tsx +++ b/web/apps/backoffice/src/RecordPanel.tsx @@ -25,6 +25,13 @@ type Props = { onSaved: (id: string) => void; }; +const without = (m: Record, name: string): Record => + Object.fromEntries(Object.entries(m).filter(([k]) => k !== name)); + +/** The sentence of a failed check, or nothing when the value is fine. */ +const errorOf = (r: { value: unknown } | { error: string }): string | undefined => + "error" in r ? r.error : undefined; + /** * A smart button: the related records of one model, with their number from the server (D4), so * a related model with thousands of records is never loaded just to be counted. @@ -96,8 +103,16 @@ export function RecordPanel({ const d = useData(); const creating = row === null; const [draft, setDraft] = useState>({}); - const [errors, setErrors] = useState>({}); - const [error, setError] = useState(null); + // What went wrong is kept as names and as the server's own words, and the sentence is written + // when it is drawn: text kept finished would stay in the language it was written in after a + // change of language (the panel keeps its state through one). + const [invalid, setInvalid] = useState>({}); // failed the check at Save + const [refused, setRefused] = useState>({}); // the server's words, by field + const [error, setError] = useState<{ raw: string } | null>(null); // the server's words, for the form + const clearField = (name: string) => { + setInvalid((m) => without(m, name)); + setRefused((m) => without(m, name)); + }; const [busy, setBusy] = useState(false); const [deleting, setDeleting] = useState(false); const panel = useRef(null); @@ -165,9 +180,9 @@ export function RecordPanel({ const f = message.startsWith(prefix) ? fields.find((x) => message.startsWith(`${prefix}${x.name} `)) : undefined; - // The server's English is matched first, then shown in the person's language. - if (f) setErrors((e) => ({ ...e, [f.name]: serverMessage(message) })); - else setError(serverMessage(message)); + // The server's English is matched first, then shown in the person's language (when drawn). + if (f) setRefused((e) => ({ ...e, [f.name]: message })); + else setError({ raw: message }); } async function save() { @@ -181,7 +196,8 @@ export function RecordPanel({ // A value left as stored is not held to limits lowered since it was written (D90). (f) => !creating && !changed(f), ); - setErrors(errs); + setInvalid(Object.fromEntries(Object.keys(errs).map((name) => [name, true as const]))); + setRefused({}); if (Object.keys(errs).length) return d.notify(t("Fix the highlighted fields first"), "error"); const body: Record = {}; for (const f of editable) { @@ -239,7 +255,7 @@ export function RecordPanel({ } else if (out.kind === "approval") d.notify(out.message, "info"); else { // The server's own words (D48), also inline so they stay readable. - setError(out.message); + setError({ raw: out.raw }); d.notify(out.message, "error"); } } @@ -263,7 +279,7 @@ export function RecordPanel({ ); else { const message = serverMessage(r.message); - setError(message); + setError({ raw: r.message }); d.notify( needsApproval ? t("{name} was refused: {reason}", { name, reason: message }) : message, "error", @@ -471,7 +487,11 @@ export function RecordPanel({ .map((f) => { const isMasked_ = masked(f); const fid = `f-${model.name}-${f.name}`; - const err = errors[f.name]; + const err = invalid[f.name] + ? errorOf(fromEdit(f, value(f), currency, !creating && !changed(f))) + : refused[f.name] !== undefined + ? serverMessage(refused[f.name]!) + : undefined; return (