Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
367ce12
ui: save the language choice before the subscribers are told
rorychatt Oct 31, 2026
23fb041
back office: build the app list again when the language or the region…
rorychatt Oct 31, 2026
84cb143
back office: the related-record buttons follow the language of an ope…
rorychatt Oct 31, 2026
8271b96
back office: four more memos that translate list the language in thei…
rorychatt Oct 31, 2026
d9d1668
back office: server queries ask again when the locale changes
rorychatt Oct 31, 2026
1787afe
back office: the sidebar tooltip of a record shows the description in…
rorychatt Oct 31, 2026
a9053cf
back office: a test fails a useMemo that translates without the langu…
rorychatt Oct 31, 2026
911a975
e2e: switching the language without a reload leaves nothing in the ol…
rorychatt Oct 31, 2026
197cb67
Follow the language in the dashboard and list-plan memos that sort an…
rorychatt Oct 31, 2026
9df0c51
back office: the memo guard finds the translating functions from the …
rorychatt Oct 31, 2026
6f0e6d7
back office: renaming an app no longer makes its field lose what is t…
rorychatt Oct 31, 2026
a45cbf4
back office: ask the server again after a language switch only where …
rorychatt Oct 31, 2026
0d1a394
back office: an open record stays mounted while the list around it as…
rorychatt Oct 31, 2026
5773706
back office: the record panel words its errors when it draws them
rorychatt Oct 31, 2026
6c7b38f
docs: say what the memo guard covers and list what the language switc…
rorychatt Oct 31, 2026
4b39b5f
e2e: the keep-the-record-open test opens the record from the list
rorychatt Oct 31, 2026
9c4ddee
e2e: the rename test holds the field element from before the rename
rorychatt Oct 31, 2026
c8131b4
e2e: the refusal banner test switches to Swedish first and back to En…
rorychatt Oct 31, 2026
bbe8ece
ModelPage: the plan memo says why it has no language (the range bound…
rorychatt Oct 31, 2026
4a2b5a2
guard: scope at the hook, typed helpers, type arguments, marker posit…
rorychatt Oct 31, 2026
7097f4e
useRecord: forget the remembered row when the get says the record is …
rorychatt Oct 31, 2026
9e9c1f7
comments and docs: what the request carries, what resolve writes, mem…
rorychatt Oct 31, 2026
ecf9932
e2e: the refusal paths, the unsorted board and the remembered row of …
rorychatt Oct 31, 2026
9a9aa91
ISSUES D14: the masker has no notion of JSX text (probed)
rorychatt Oct 31, 2026
cf2159b
guard: header bindings only, the same scope in the graph, deps by arg…
rorychatt Oct 31, 2026
2e6f8e6
useRecord: forget the row when the panel closes, as well as when the …
rorychatt Oct 31, 2026
409dac2
e2e: the unsorted board is told apart by body, and a deleted record d…
rorychatt Oct 31, 2026
6a01d76
docs: what a request carries, D9 PlanDialog and the connectors, D10, …
rorychatt Oct 31, 2026
f72739a
guard header: a function type binds nothing, and the graph reads func…
rorychatt Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<DField[]>(() => [...])`; 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 (`<p>don't {t("x")}</p>`) blanks the rest of the line, and a `/` right after a `}` (`<Icon size={14} />`) 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 (`<T extends (x: number) => 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: <why>` 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 |
Expand Down
26 changes: 24 additions & 2 deletions docs/brand/WORKFLOWS-LISTS-DASHBOARDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <why>` 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
Expand Down
12 changes: 9 additions & 3 deletions web/apps/backoffice/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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)}
</a>
Expand Down
3 changes: 2 additions & 1 deletion web/apps/backoffice/src/Chrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hit[]>(() => {
const all: Hit[] = [];
for (const a of apps) {
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions web/apps/backoffice/src/ControlPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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] });
Expand Down
19 changes: 14 additions & 5 deletions web/apps/backoffice/src/Customize.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>>({});
const real = apps.filter((a) => a.id !== "customize");
const layout = settings.apps ?? {};
const hidden = new Set(layout.hidden ?? []);
Expand Down Expand Up @@ -441,12 +444,18 @@ function AppsTab({ apps, settings, isAdmin, token, onSettings, notify }: Props)
<li key={a.id} className="row">
<AppIcon app={a} size={36} />
<input
defaultValue={a.name}
value={typed[a.id] ?? a.name}
aria-label={t("Name of {name}", { name: a.name })}
onBlur={(e) =>
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)),
);
}}
/>
<input
type="color"
Expand Down
3 changes: 3 additions & 0 deletions web/apps/backoffice/src/ModelPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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.
// i18n-memo-ok: a range bound is read the same in every language and region (a dot or a comma is
// taken as the decimal mark); the only reader of the i18n store is fmt.exponent(), the digits of
// the instance currency, which a change of language or region does not change
const plan = useMemo(() => toServer(state, fields), [state, fields]);
const grouped = state.group.length > 0;
const serverMode = view === "list" && onServer(plan, state);
Expand Down
5 changes: 3 additions & 2 deletions web/apps/backoffice/src/ModuleBuilder.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<DField[]>(() => [
Expand Down Expand Up @@ -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, `^<installed version>`.
Expand Down
Loading
Loading