diff --git a/docs/ISSUES.md b/docs/ISSUES.md index a4aec89e..6c8690c0 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -43,6 +43,13 @@ test found, or a limit the docs state. | D5 | Translated MCP and token docs pages (6 languages) are out of date; module card summaries and server blocker messages stay English | done | | D6 | Changing language remounts the app and drops open dialogs | done | | D7 | No native review of any translation | open (needs people) | +| D8 | Toasts and the confirm dialog keep the text they were made with. `notify(text)` in `App.tsx` stores the finished sentence (shown 3.5 s, errors 7 s) and `confirm()` stores the message and both button labels, so what is on screen when the language is switched stays in the old language until it goes. Found in the review of the language-switch fix; not changed | open (low) | +| D9 | Error texts kept as finished sentences in state keep the old language after a switch without a reload: the sign-in and second-factor screens (`SignIn.tsx`: `t(...)`, `twoFactorMessage`, `passkeyMessage`), the dashboards (`DashboardsApp.tsx` `failure`, `WidgetForm.tsx` `error`), the device list in Security (`SecurityAccount.tsx` `load` stores `problem`; its `useCallback` is lazy, what it stores is not) and the inline cell edit (`InlineEdit.tsx`; unreachable today, leaving the cell closes it). The record panel was converted in the language-switch fix to keep the raw message and word it when it is drawn (`serverMessage` at render); the others follow the same pattern. The list is examples, not all: a search for `setError(` and `setFailure(` with a `t(...)` or `serverMessage(...)` argument, or a helper that returns a sentence (`passkeyMessage`, `twoFactorMessage`), also finds `BlobFields.tsx` (upload errors), `updates/UpdatesPage.tsx`, `modules/UpdateDialogs.tsx` (`setFailure`), `modules/PlanDialog.tsx` (`setFailure(serverMessage(r.message))` in the install dialog and in the uninstall dialog; only its preview error, `state.error`, is kept raw and drawn with `serverMessage`), `connectors/Wizard.tsx`, `connectors/Catalog.tsx`, `connectors/Sections.tsx` (the checklist and the register export), `Passkeys.tsx` and `apps/platform/src/App.tsx`. Even the record panel cannot reword a refusal the server already worded in the language of the request (a package assertion, for example): it stays in the language it was sent in until the next request. Read from the code, not driven in a browser | open (low) | +| D10 | The module builder seeds its form with translated sample words (`BuildTab` in `ModuleBuilder.tsx`: the module name "Fleet check" and the model "Inspection", each `useState(() => t(...))`, and the descriptions of the three sample fields, which are translated inside one `useState(() => [...])`; the field names `title`, `inspector` and `due` are fixed). After a switch the form keeps the sample words of the first language, and the manifest it builds carries them. A fix keeps a seed untouched until edited and shows the translated one meanwhile; there is no test of the builder's seeds | open (low) | +| D11 | A decimal typed in a record form is kept as typed text and read at Save with the separators of the language active then, so a draft typed before a language switch can be read differently (same code at 32a0758: `edit.ts`, `numbers.ts` and `format.ts` are unchanged by the fix). Measured with `fmt.parseNumber`, the function `fromEdit` calls, region auto: in a decimal field with 3 decimals, `1,234` typed under English (1234) and saved after switching to Svenska or Deutsch is 1.234, and `1.234` typed under English (1.234) and saved after switching to Deutsch is 1234. With 2 decimals the same text is refused as ambiguous instead (D90), so only a field whose scale equals the digits after the mark is affected silently. Money was not measured. A fix re-reads or re-formats the draft on a switch, or refuses to save a draft typed in another language; not fixed here | open (low) | +| D12 | At load `refresh()` in `App.tsx` calls `setInstanceDefaults({ language, currency })` without awaiting it. When the instance language of the settings differs from the language the page started in, `activate()` fetches that catalogue after `setReady(true)`: a second catalogue request and a second render in another language. Found by reading the code in the review; not measured | open (low) | +| D13 | The end-to-end harness on Windows: `support/env.ts` looks for `target/debug/obop` without `.exe` (set `OBOP_BIN`), and `support/global-teardown.ts` fails with `EPERM` while the servers still hold their SQLite files, so a green run exits 1 and leaves `%TEMP%/obop-e2e-*`. Git Bash rewrites a leading `/` in an environment value: with `OBOP_ADMIN_BASE=/admin/ vp build` the build sees `C:/Program Files/Git/admin/` and writes `/Program Files/Git/admin/...` into `index.html` (measured), so build `dist-admin` from PowerShell or with `MSYS_NO_PATHCONV=1`. CI on Linux is not affected | open (low) | +| D14 | The blind spots of `web/apps/backoffice/src/i18nDeps.test.ts` (reader in `i18nGuard.ts`). It reads `useMemo` and `memo` only, in `apps/*/src` and `packages/ui/src`, and finds translators by what a function uses: a plain name or JSX tag that resolves through imports and re-exports, and member names of functions declared inside another function that are typed as function properties, followed on the receivers `d`, `lk` and `lookup` only (`d.recordLabel`, `lk.refText`; `hash.update(x)` is not `d.update(x)`, while a data-context member called through another name is missed). A name is the code's own, not an import, when the code around the hook declares it (a constant above it in the same blocks, a parameter of the function that holds it) or the hook does. It does not read: `useCallback`, `useEffect` and `useLayoutEffect`, a `useState` initialiser, a ref or a module constant that holds translated text, state that holds a finished sentence (D8 to D10), a translator reached through an object whose type it cannot see or through a computed property (`handlers[key]()`), a custom hook with its own dependency list, and a list that is right because of a dependency that happens to change often (it checks that the language is named, not that the memo is correct otherwise). Shapes checked by hand and found missed: namespace imports (`import * as ui from "@obop/ui"; ui.t(...)`), default imports and `export default`, `React.useMemo(...)` and an aliased hook (`import { useMemo as um }`), a translator held in a local alias, a destructuring or a custom hook (`const tr = t`, `const { t: tr } = ...`, `const text = useT()`), a callback parameter named like a translator inside the memo (`ids.map((t) => t.id)` hides `t` for the whole memo body, so a `t("x")` elsewhere in it is not seen), and a member of the data context that the receiver rule does not match. The masker has no notion of JSX text: an apostrophe in text (`

don't {t("x")}

`) blanks the rest of the line, and a `/` right after a `}` (``) is read as the start of a regular expression and blanks up to the next `/` on the line (probed), so a `t(...)` after either on the same line is not seen. A function type in a parameter annotation (`{ f }: { f: (t: string) => void }`) binds nothing; the same rule gives the shadow of a function in the call graph (its parameters and the constants at the level of its body), and a constant in an inner block of it (an `if`) does not shadow, so such a function counts as a translator (probed). A statement is cut at `;`, `{` and `}`, not at a comma, so a marker above the first property of an object, the first declarator of `const a = useMemo(..), b = useMemo(..)` or, without semicolons, the first of several statements also exempts the ones after it, and a marker above an element of an array or an argument is not honoured (probed). Parameters are read from a block body only: `export const useX = (t) => useMemo(() => t("a"), [])`, `export default function (t) {...}` and `(t): { a: string } => {...}` are flagged though `t` is the parameter (a marker silences them), and a helper declared with type parameters that hold a function type (` void>(a: T) => ...`) is not seen as a function (probed). The check on dependencies is `lang` or `locale` for any use, though they are not the same: `lang` is the language only (text), `locale` is the region too (collation, number and date formats). At least five memos name only `lang` and sort with the collation (`ModelPage.tsx` `sortRows`, `ListView.tsx` `buildGroups`, `GraphView.tsx` and `PivotView.tsx` `orderKeys`, `KanbanView.tsx` `domainOf`), so a change of region alone leaves their order in the old collation (read from the code, not driven); the guard accepts them. `// i18n-memo-ok: ` silences one hook, with a reason of at least three word characters, in a comment directly above its statement or inside the call | open (known limits) | ## E. Platform, trust and discovery | # | Issue | Status | diff --git a/docs/brand/WORKFLOWS-LISTS-DASHBOARDS.md b/docs/brand/WORKFLOWS-LISTS-DASHBOARDS.md index e9776356..0e9dae5f 100644 --- a/docs/brand/WORKFLOWS-LISTS-DASHBOARDS.md +++ b/docs/brand/WORKFLOWS-LISTS-DASHBOARDS.md @@ -83,8 +83,30 @@ Each has a specimen in the Brand book (Components). there when it closes. Wider than that the panel sits beside the list and is not modal. - 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. + record panel, a dialog, a draft. Two things keep old text through a change, and the rules follow + from them. A `useMemo` that translates, sorts or formats keeps its value unless `lang` (text) or + `locale` (collation, number parsing; from `useI18n()`, or `d.lang` of the data context) is among its + dependencies, and a `memo` component that does so renders again only if it calls `useI18n()` in its + body. And state must not hold a finished translated sentence: keep the + raw message or a code and word it when drawing (`RecordPanel` keeps the server's own words and calls + `serverMessage` at render). `web/apps/backoffice/src/i18nDeps.test.ts` guards the first. It reads + every source file of the apps and of `packages/ui`, works out from the call graph which functions + translate, sort or format (those that use a reader of the i18n store, directly or through other + functions: called, handed on as in `.sort(byPosition)`, used as a JSX tag, or a member such as + `d.recordLabel`), and fails a `useMemo` that uses one of them while its dependencies name neither + `lang` nor `locale`, and a `memo` component that uses one without calling `useI18n()` (a deliberate + exception carries `// i18n-memo-ok: ` in a comment above its statement or inside it). It does not + read `useCallback`, `useEffect`, a `useState` initialiser, a ref, or state that holds translated text; + docs/ISSUES.md D8 to D10 and D14 list what is known to slip through. A query that the server answers + in the collation of the locale (a sorted `useServerRows`, the sorted Kanban columns) asks again when + the locale changes. Every command the back office sends (`call` in `api.ts`; not `/api/meta`, sign-in, + uploads or the account endpoints) carries `?lang=` and `Accept-Language`, which the server words its + refusals by. In the body, `search()` adds the `lang` that picks the collation only with an `order`, so + an unsorted query does not ask again. `aggregate()` always adds it, and the server orders the keys of + a text group by it; that does not matter while only select, ref, bool and date fields can be grouped + (`GROUPABLE` in `query.ts`), and the views put the keys in order on the client (`orderKeys`, through + `domainOf`, in memos that carry `d.lang`), so a switch does not ask the aggregates again. A text + field that becomes groupable needs the locale in the key of `useAggregate`. - 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/App.tsx b/web/apps/backoffice/src/App.tsx index cb0fff11..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 } 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"; @@ -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 = @@ -333,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)} 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/Customize.tsx b/web/apps/backoffice/src/Customize.tsx index a77ef7ea..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,12 +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)), + ); + }} /> toServer(state, fields), [state, fields]); const grouped = state.group.length > 0; const serverMode = view === "list" && onServer(plan, state); 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/RecordPanel.tsx b/web/apps/backoffice/src/RecordPanel.tsx index 4336425b..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", @@ -290,7 +306,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 ( <> @@ -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 (