Skip to content

Add internationalisation (i18n) support to the platform UI - #8312

Open
hmjvalineY wants to merge 5 commits into
FlowFuse:mainfrom
hmjvalineY:feat/i18n
Open

Add internationalisation (i18n) support to the platform UI#8312
hmjvalineY wants to merge 5 commits into
FlowFuse:mainfrom
hmjvalineY:feat/i18n

Conversation

@hmjvalineY

@hmjvalineY hmjvalineY commented Aug 27, 2026

Copy link
Copy Markdown

Adds internationalisation to the platform UI: the plumbing, full en and zh-TW locales, and a per-user language preference.

Written fresh against main rather than rebased from feature/i18n-implementation, which is now ~4,700 commits behind and pinned fastify-i18n@^1 (Fastify 4 era) and vue-i18n@^9. The architecture in that branch's docs/contribute/i18n.md is what this follows — library choices, locale directory layout, hierarchical key naming — with the code written against current APIs.

Related Issue(s)

#8311

Scope

This grew well beyond the two pages the issue proposed. The infrastructure alone left a signed-in user reading English on every page, so the extraction was carried through the whole UI. It is now 5 commits, and I am happy to split it if that is easier to review — see the note at the end.

Commit What
1 i18n infrastructure, zh-TW locale, User.language preference, login and sign-up pages
2 Sidebar, user menu, account settings page
3 The rest of the UI — templates, display attributes, JS-defined strings, validation errors, toasts, pluralised counts, role names
4 Audit log, welcome tour, instance state tiles, side-nav tabs
5 Text that wraps around interpolation

Locale files: 2,879 keys, en and zh-TW in step.

What's here

Librariesvue-i18n@^11 on the frontend, fastify-i18n@^3 (over node-polyglot) on the server. Their interpolation syntaxes differ ({name} vs %{name}); that is documented rather than papered over.

Locale filesfrontend/src/locales/<locale>.json and locales/<locale>/common.json.

Both webpack entrypoints. webpack.config.js builds two separate Vue apps, main and setup. The plugin is registered on both, so the first-run setup flow is translated too.

Locale resolution — the user's stored language, then the request or browser locale, then en. The login and sign-up pages render before there is a session, so browser detection is what makes them translatable at all. A user with no stored preference keeps whatever the browser negotiated; signing in does not reset them to English.

LOCALE_ALIASES. fastify-i18n narrows a regional tag onto its base language (en-GB finds en) but does not widen a script-qualified tag onto a regional one, so zh-Hant-TW would fall back to English rather than finding zh-TW. Chrome reports exactly that tag for Traditional Chinese on some platforms. forge/i18n/locales.js maps it. I had assumed the library handled this; a test proved otherwise, which is why the alias map exists.

Strings outside components. Table column headers, dialog copy, route meta titles and prop defaults have no component instance to hang $t off, so frontend/src/i18n.js exports a bare t() for them.

Pluralisation. The ad-hoc pluralize() helper produced 1 Device / 2 Devices by appending s, which cannot work for a locale without plural forms. Those 15 call sites now use vue-i18n's own plural syntax, so en still reads correctly and zh-TW does too.

Audit log. data/audit-events.json stays the source of truth for which events exist and how they group; a new auditEvents namespace holds the labels, keyed by the camel-cased event id. All 165 event ids resolve. The description sentences use named interpolation.

Locale parity tests. test/unit/forge/i18n/locales_spec.js asserts every locale defines the same keys as en, and that frontend/src/i18n.js and forge/i18n/locales.js agree on the supported set. A missing translation fails CI rather than silently rendering English. This mirrors what scripts/lint-colors.js already does for theme files.

Deliberate scope decisions

API error strings are not translated. Several are part of the de-facto contract: frontend/src/pages/account/Create.vue branches on err.response.data.error === 'user registration not enabled', and test/unit/forge/routes/auth/index_spec.js matches the same literal. Translating them would break both. Those responses already carry a stable code field (user_registration_unavailable), which is what callers should branch on — migrating consumers to it, then translating the human-readable text, is separate work. The server plumbing is registered and tested so that work does not start from nothing; the natural first consumer is forge/postoffice/templates/.

RoleNames is left alone. It maps roles to identifiers that code compares against and that feed table search, so it is not display text. The API keeps returning roleName and gains a separate translated roleLabel for the places a role is shown. A unit test caught this when I first changed roleName itself.

utils/pipelineValidation.js is untouched. Its header says it is maintained as a 1:1 copy of forge/lib/pipelineValidation.js, which cannot import the frontend locale.

Also left in English on purpose: Vue component and route name: properties (identifiers, not labels), alt="FlowFuse" and other brand names, enum values such as popup-type, the English label in SUPPORTED_LOCALES (deliberately in its own language), and content that comes from the database — team type names, instance types, stack labels.

User.language is stored server-side, unlike the theme preference which is local-only, because content generated outside a browser session needs it — most obviously the emails in forge/postoffice/templates. Happy to be redirected if you would rather this lived elsewhere; User has no generic preferences mechanism today, so this adds a flat column.

Build and test results

Environment: Windows 11, Node v24.14.0, npm 11.9.0, SQLite.

Rebased onto main at e2ef8dfc4 (#8202, a google-auth-library bump) and re-run in full at 48afd0a2e. Both sides had touched package.json and package-lock.json, so the merged lock was verified with npm install --package-lock-only, which reported nothing to change — declared and resolved versions agree for the bumped dependency and for both i18n libraries. Every result below is from after the rebase, not carried over from before it.

Check Result
npm run lint 0 errors, 11 warnings — identical to the pre-change baseline on this commit (all 11 are pre-existing unused eslint-disable directives in test files)
npm run lint:colors pass
npm run test:unit:forge 3,346 passing, 24 pending, 0 failing (11m)
npm run test:unit:frontend 886 passing across 63 files, 0 failing
npm run test:system 61 passing, 0 failing
npm run build exit 0; both main and setup entrypoints emitted; same 2 pre-existing asset-size warnings
npm run test:docs docs/contribute/i18n.md: 4 links tested, 4 valid, 0 errors. To be precise about this one: the suite as a whole exits non-zero on 342 errors in files this PR does not touch, a good number of them the checker resolving ../../ above the repo root on Windows. This PR adds one docs file and modifies none, so it neither causes nor fixes any of them.

One disclosure on the frontend suite: one run out of five failed two timing-sensitive tests while the forge suite was running concurrently on the same machine (that run's transform time was 20s against a typical 12s). Suites were run one at a time after the rebase and all are clean. Not something this change introduces, but worth stating rather than only reporting the green runs.

Bundle cost: main 4.90 → 5.29 MiB, setup 4.86 → 5.19 MiB — the locale files ship in both entry bundles.

Migration, on SQLite:

  • applies on startup, recorded in MetaVersions as the latest entry
  • PRAGMA table_info('Users') confirms language VARCHAR(255), nullable, default NULL
  • satisfies check-migrations.yml20260827-01 sorts last in the directory

Manual verification, browser locale zh-TW, against a database carrying real data (5 users, a team, a running Node-RED instance):

  • login, sign-up and the first-run setup flow render in Traditional Chinese with no configuration, from navigator.language alone; <html lang> is set correctly
  • forcing en renders English — both directions work
  • sidebar, user menu, account settings, team pages, admin pages, audit log and dialogs all render translated; browser tab titles too
  • <i18n-t> renders sentences with a link inside them in the right word order
  • validation errors and toasts set from component code render translated
  • PUT /api/v1/user with zh-TW → 200 and persists; fr-FR and nonsense400 from the schema enum; null → 200 and clears the preference
  • with localStorage deliberately set to en, a reload applies the stored zh-TW preference instead — precedence is correct

Checklist

  • I have read the contribution guidelines
  • Suitable unit/system level tests have been added and they pass
  • Documentation has been updated — docs/contribute/i18n.md
    • Upgrade instructions — n/a, the migration is additive and nullable
    • Configuration details — n/a, nothing in flowforge.yml changes
    • Concepts — locale resolution, key naming, and what is out of scope
  • Changes flowforge.yml? — no
    • Issue/PR raised on FlowFuse/helm to update ConfigMap Template — n/a
    • Issue/PR raised on FlowFuse/CloudProject to update values for Staging/Production — n/a

Labels

  • Includes a DB migration -> needs the area:migration label (I cannot add labels as an outside contributor)

Notes for reviewers

On the size. 422 files is a lot to review at once, and most of it is mechanical $t() substitution plus two locale files. If it helps, commit 1 stands alone as the infrastructure and is the part worth real scrutiny; commits 2–5 are the extraction. I can split this into separate PRs, or drop the zh-TW values and land only the plumbing plus the en extraction, whichever you prefer — say the word.

On which pages. The issue proposed starting with login and sign-up. I went further because the preference does not visibly do anything until the pages a signed-in user actually reads are covered. If you would rather review a smaller surface first, I can trim.

I run FlowFuse self-hosted for a Traditional Chinese speaking team, so I have a reason to keep the zh-TW locale current rather than land it and disappear.

@hmjvalineY

Copy link
Copy Markdown
Author

Pushed four more commits and rewrote the description to match.

What changed since the first push: the extraction now covers the whole UI rather than just login and sign-up. The infrastructure on its own left a signed-in user reading English on every page, so setting the language preference did not visibly do anything — that felt like half a feature.

422 files, 2,879 locale keys, en and zh-TW in step.

Getting there turned up a few things worth flagging, all handled:

  • pluralize() appends s to make a plural, which cannot work for a locale with no plural forms. The 15 call sites now use vue-i18n's own plural syntax.
  • RoleNames looked like display text but is compared against in code and feeds table search. The API keeps roleName and gains a separate translated roleLabel. A unit test caught this when I first changed roleName itself.
  • utils/pipelineValidation.js is a declared 1:1 copy of forge/lib/pipelineValidation.js, so it is left untouched — the backend copy cannot import the frontend locale.
  • fastify-i18n does not widen zh-Hant-TW onto zh-TW, which Chrome reports on some platforms. LOCALE_ALIASES handles it.

Full build and test run on this branch: lint 0 errors (11 pre-existing warnings, unchanged), 3,346 forge tests, 886 frontend tests, 61 system tests, build clean, migration verified on SQLite. Manually verified against a database with real data. Details in the description.

On the size: 422 files is a lot, and most of it is mechanical $t() substitution plus two locale files. Commit 1 stands alone as the infrastructure and is the part worth real scrutiny. If it is easier, I can split this into separate PRs, or drop the zh-TW values and land only the plumbing plus the en extraction. Happy to do whichever suits your review process — just say.

The platform UI was English-only with no i18n infrastructure, while the
Node-RED editor embedded inside it already ships 10 locales. A user with a
Chinese browser locale got a localised editor inside an English shell.

Adds the plumbing, an `en` baseline for the login and sign-up pages, a
`zh-TW` locale, and a per-user language preference.

Frontend uses vue-i18n; the backend uses fastify-i18n over node-polyglot.
Locale files live in `frontend/src/locales/<locale>.json` and
`locales/<locale>/common.json`. Both webpack entrypoints register the
plugin, so the first-run setup flow can be translated too.

Locale resolution is: the user's stored `language`, then the request or
browser locale, then `en`. The login and sign-up pages render before there
is a session, so browser detection is what makes them translatable at all;
signing in must not reset a user with no stored preference to English.

`fastify-i18n` narrows a regional tag onto its base language but does not
widen a script-qualified one, so LOCALE_ALIASES maps `zh-Hant-TW` onto
`zh-TW` rather than letting it fall back to English.

Existing API error strings are deliberately left untranslated. Several are
part of the de-facto contract — the sign-up page and a unit test both match
`'user registration not enabled'` literally — so translating them would be
a breaking change. Those responses already carry a stable `code` field,
which is what callers should branch on.

Tests assert every locale defines the same keys as `en`, and that the
frontend and backend agree on the supported set, so a missing translation
fails CI rather than silently rendering English.

Refs FlowFuse#8311

Signed-off-by: hmjvalineY <hmjvaline@ymhs.tyc.edu.tw>
The i18n plumbing landed with only the login and sign-up pages extracted,
which meant setting the language preference changed almost nothing a
signed-in user actually looks at.

Extends the extraction to the surfaces that appear on every page:

- frontend/src/stores/ux-navigation.js - all 33 sidebar entries and the
  section headings above them. Read through the active locale inside the
  getters, so switching language re-renders the sidebar without a reload.
- frontend/src/components/PageHeader.vue - the user dropdown
- frontend/src/pages/account/Settings.vue - the remaining form fields,
  buttons and alerts, plus theme options moved from data() to computed so
  they follow the locale

Locale files grow from 79 to 112 keys, still in step across en and zh-TW.

Signed-off-by: hmjvalineY <hmjvaline@ymhs.tyc.edu.tw>
The earlier passes covered the login and sign-up pages, the sidebar and
the account settings page. This extends the extraction to the rest of the
UI, so setting the language preference changes what a signed-in user
actually reads on every page.

Covered in this pass:

- template text nodes and display attributes across 327 .vue files,
  including text that wraps across lines and strings inside directive
  expressions
- display strings defined in plain JS — table column headers, dialog
  copy, route meta titles, prop defaults — read through a new bare `t()`
  export from frontend/src/i18n.js, since there is no component instance
  to hang `$t` off in those places
- validation errors and toast messages assigned to `errors.*` or passed
  to `alerts.emit`
- pluralised counts, moved from the ad-hoc `pluralize()` helper onto
  vue-i18n's own plural syntax, so a locale without plural forms reads
  correctly
- role names, via a new utils/roleLabels.js. `RoleNames` itself is left
  alone — it maps roles to identifiers that code compares against, and is
  not display text — so the API keeps returning `roleName` and gains a
  separate translated `roleLabel` for the tables that show it

Deliberately left in English: `alt="FlowFuse"` and other brand names,
enum values such as `popup-type`, and Vue component and route `name:`
properties. utils/pipelineValidation.js is also untouched — it is
maintained as a 1:1 copy of forge/lib/pipelineValidation.js, which cannot
import the frontend locale.

Locale files grow from 112 to 2,521 keys, still in step across en and
zh-TW, enforced by the existing parity test.

Signed-off-by: hmjvalineY <hmjvaline@ymhs.tyc.edu.tw>
Three surfaces the earlier passes could not reach, each for a different
reason:

- Audit log descriptions in AuditEntryVerbose.vue mix literal text with
  interpolation, so a plain text-node match skipped them. They now use
  vue-i18n named interpolation, with the original expressions passed
  through as `{p0}`, `{p1}` and so on.
- Audit event labels live in data/audit-events.json, which stays the
  source of truth for which events exist and how they group. A new
  `auditEvents` namespace holds the labels, keyed by the camel-cased
  event id, and services/audit-events.js reads them through `t()`.
- Welcome tour bodies are HTML strings in tour-welcome.js, so the value
  never looked like a plain display string.

Also picks up the instance state tiles (Running / Error / Not Running),
the audit log entry count, and the account and teams side-nav tabs.

The event-id key shape has to match between the locale files and the
runtime, and it is not the obvious slug: ids such as
`application.deviceGroup.created` already contain camel case, so the
segment after the separator keeps its own casing rather than being
lower-cased. All 165 event ids resolve.

Locale files grow to 2,803 keys across `ui` and the new `auditEvents`
namespace, still in step across en and zh-TW.

Signed-off-by: hmjvalineY <hmjvaline@ymhs.tyc.edu.tw>
A text node such as `There are no dashboards {{ scope === 'application'
? 'in this application' : 'in this team' }}.` mixes literal words with an
expression, so the earlier passes - which matched whole text nodes -
skipped it. These now use vue-i18n named interpolation, with the original
expressions passed through as {p0}, {p1} and so on.

Also translates the English string literals that some of those
expressions produced, and adds keys for the surrounding words. Two
literals are deliberately left: 'application' is a scope comparison and
'main' is a git branch default, neither is display text.

Signed-off-by: hmjvalineY <hmjvaline@ymhs.tyc.edu.tw>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant