Skip to content

feat(i18n): multilingual UI with react-i18next and a full az translation#312

Merged
martian56 merged 6 commits into
Devlaner:mainfrom
cavidelizade:feat/i18n
Jul 13, 2026
Merged

feat(i18n): multilingual UI with react-i18next and a full az translation#312
martian56 merged 6 commits into
Devlaner:mainfrom
cavidelizade:feat/i18n

Conversation

@cavidelizade

Copy link
Copy Markdown
Contributor

Feature summary

The web app can now run in more than one language. Every user-facing string reads through t(), the Language dropdown in Settings → Preferences finally does something, and Azerbaijani (az) ships as a full translation alongside English (en). Switching languages flips the whole UI and the choice survives a reload.

Linked issues / discussion

Closes #32

User-facing behavior

Settings → Account → Preferences → "Language & Time" has a Language dropdown. It used to be a dead control (wrote to a local useState('en') nothing read). It now calls i18n.changeLanguage(lng); the language detector persists the choice to localStorage under devlane-language, so a reload keeps it. Pick Azərbaycanca and the entire interface — sidebar, home, work items, modals, menus, empty states, toasts, settings, instance-admin — switches to Azerbaijani. Dates, relative times, and numbers reformat to the active locale via Intl. <html lang> updates to match (and dir, ready for a future RTL language).

What changed

API (apps/api/)

  • No changes. This is the localStorage-only version the issue describes as enough to close it; persisting language on the user record (like user_timezone) is called out as an optional follow-up.

UI (apps/web/)

  • Setupsrc/i18n/index.ts initializes i18next once (react-i18next + language detector, fallbackLng: 'en', detection order ['localStorage','navigator'] cached to localStorage, escapeValue: false). Imported in src/main.tsx before <App /> so the language is ready on first paint.
  • Catalogssrc/i18n/locales/{en,az}/translation.json, flat dotted keys grouped by area. 2112 keys each, full parity, none empty. en is the source of truth; az is a complete human-reviewed translation.
  • Migration — every hardcoded string under src/ moved onto t() / <Trans> (pages, shared components, modals, menus, empty states, toasts, auth/setup flows, instance-admin). English text is passed as the t() default so the extractor builds the en catalog from the code.
  • Dynamic keys — a handful of enum labels are looked up with computed keys (t(\module.status.${id}`, …)) the parser can't see statically; they're registered in src/i18n/dynamicKeys.ts` so both catalogs stay complete.
  • Switcher — the Settings dropdown is driven by i18next; the dead language state is gone. Languages come from one SUPPORTED_LANGUAGES array, so adding a third is one JSON file + one entry.
  • Formatting — dates/relative-times/numbers go through Intl.* keyed to the active language.
  • Tooling / regression guardi18next-parser config + npm run i18n:extract (documented in src/i18n/README.md), plus the eslint-plugin-i18next no-literal-string rule (jsx-text-only) so new hardcoded strings fail lint. It runs in the existing ui-ci lint step — no new workflow.

Database

  • No schema changes.

Why this design

I followed the issue's approach as written: react-i18next on i18next, mirroring how ThemeContext already handles a client preference (read localStorage → apply → persist), with the devlane-language key to match devlane-theme. Flat dotted keys keep a 2000+ key set greppable and make en/az trivial to diff for parity. English defaults live at the call site so the parser stays the single source for the en catalog and translators only touch JSON.

A couple of deliberate calls:

  • Regression guard. The issue offered "eslint-plugin-i18next or a simple custom rule." I used the plugin in jsx-text-only mode so it flags visible JSX text without fighting every technical string. Intentional literals (decorative glyphs/emoji, brand names, a few verbatim code/acronym tokens like CSV, .pem) are excluded in the eslint config rather than sprinkled with inline disables, so the config is the one place that documents what's intentionally not translated.
  • Bundle size. With two locales the catalogs are imported directly. Lazy-loading per-language (listed under "easy to forget") is worth doing once there are several languages; I've left it as a follow-up rather than add loader complexity for two files.

Test plan

There's no web test runner in the repo (root validate = typecheck + lint + prettier + go vet/test), so the automated coverage here is the type checker, the lint gate, and the parser:

  • npm run validate green (typecheck + lint with the no-literal-string gate + prettier)
  • npm run i18n:extract reports no untracked strings and no conflicting keys; en/az stay at full parity (2112 keys, none empty)
  • Manual end-to-end walkthrough:
    1. Log in, open Settings → Preferences, switch Language to Azərbaycanca → whole UI switches to Azerbaijani.
    2. Reload → still Azerbaijani (localStorage devlane-language=az, <html lang="az">).
    3. Switch back to English → UI reverts.
  • Checked light and dark theme
  • Checked a narrow viewport

Out of scope (follow-ups)

  • Persisting language on the user record (user_timezone-style) so it follows across devices — the issue marks this optional.
  • Lazy-loading locale files once there are more than two languages.
  • Translating API/server error text (a separate, larger effort noted in the issue).

Rollout notes

None. No env vars, no migrations, no instance settings. Default language stays English via fallbackLng.

AI assistance

  • AI tools were used — tool(s): Claude Code (Opus 4.8) — and AI-assisted commits include a Co-Authored-By: trailer

Checklist

  • PR title follows Conventional Commits and is ≤ 100 chars
  • Trailing slashes on new routes match neighboring routes (no route changes)
  • New env vars documented in internal/config/config.go (none)
  • New instance settings reachable from the admin UI (none)
  • No --no-verify bypass (hooks ran on every commit)
  • Acceptance criteria from the linked issue are all met

cavidelizade and others added 6 commits July 13, 2026 19:56
… switcher

Establish the i18n foundation the rest of the migration builds on:

- Add i18next + react-i18next + i18next-browser-languagedetector (and
  i18next-parser as a dev tool).
- src/i18n/index.ts initializes i18next once (imported from main.tsx): language
  detected from localStorage (devlane-language) then the browser, fallback en,
  flat dotted keys, and <html lang/dir> kept in sync for future RTL locales.
- Locale files under src/i18n/locales/{en,az}/translation.json (en is the source
  of truth), an Intl-based src/i18n/format.ts for locale-aware dates/numbers,
  an i18next-parser config + `npm run i18n:extract` script, and an i18n README
  documenting the key convention and how to add a language.
- The dead Language dropdown in Settings → Preferences now drives
  i18n.changeLanguage and lists English + Azerbaijani; the old unused `language`
  state is removed.

First migrated strings: the Settings language label + help text. The bulk
string migration follows in subsequent commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wrap user-facing strings in t()/<Trans> across ~30 pages (auth, setup,
CreateWorkspace, Profile/Home/Notifications, list pages, detail pages, views,
analytics), English text as the default value. Shared words use common.* keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wrap user-facing strings with t()/<Trans> across the remaining large pages and
the component tree, English text as the default value:

- SettingsPage (all sub-areas) and the work-item pages (IssueDetail/List,
  Intake, Board)
- layout: Sidebar, GlobalCommandPalette, InstanceAdminLayout, all page headers
- create/update modals (work item, project, cycle, module, icon, image)
- work-item components (comment editor, activity feed, PR sidebar, links,
  relations, attachments, all layouts) and filter/display panels
- settings components (webhooks, tokens, estimates, notifications, nav, modals),
  integrations, stickies, page-editor slash commands

Shared words reuse common.* keys; module-level helpers thread a TFunction where
needed. typecheck + lint + build pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finish the string migration:

- all instance-admin pages
- remaining headers, page-editor toolbar/typography, export/date-range/create
  modals, favorites tree, reactions and misc components
- reactively translate the enum/label constants at their render sites (module
  statuses, settings sections, org-size option, saved-view display properties,
  relation types, state groups) so the switcher flips them too
- formatRelativeTime now uses Intl.RelativeTimeFormat keyed to the active
  language (locale-aware, no per-unit keys)

typecheck + lint + build pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ing gate

- Generate the full English catalog with i18next-parser and a complete
  Azerbaijani translation (full key parity, placeholders + markup preserved).
- Register template-literal ("dynamic") keys the parser can't see (module
  statuses, state groups, priorities, display properties, settings and
  instance-admin sections, webhook events, relation types) in
  src/i18n/dynamicKeys.ts so both locales stay complete.
- Migrate the last stragglers earlier batches missed (loading fallbacks,
  notification sentences, snooze menu, move/select-parent modals, repo-sync
  settings, etc.).
- Add the eslint-plugin-i18next no-literal-string gate (jsx-text-only) to catch
  new hardcoded strings. Non-Latin glyphs/emoji, brand names and a few verbatim
  code/acronym tokens are excluded via config so the gate stays quiet on
  intentional literals.

typecheck + lint (with the gate) pass; the Settings switcher flips the whole UI
between English and Azerbaijani and persists across reloads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Point CreateViewModal at the existing `views.createFailed` key and give the
  export modal its own `settings.export.modalTitle` so no key is used with two
  different English defaults.
- Align the mid-sentence `common.project` / `common.unknown` fallbacks and the
  loading/saving ellipsis (`…`) to the canonical catalog values.
- Re-run i18next-parser: the extractor now reports no conflicting keys and the
  en/az catalogs sort identically (2112 keys each, full parity, none empty).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cavidelizade cavidelizade requested a review from a team as a code owner July 13, 2026 18:34
@cavidelizade

Copy link
Copy Markdown
Contributor Author

@martian56 this is the rebuild of the i18n work from scratch against the #32 description — thanks for writing that up, it made the scope really clear.

What's in here:

  • react-i18next set up once in src/i18n/index.ts, imported in main.tsx before <App />, localStorage-first detection on the devlane-language key (matches devlane-theme).
  • Every user-facing string under apps/web/src moved onto t()/<Trans> — pages, shared components, modals, menus, empty states, toasts, auth/setup, instance-admin. No hardcoded strings left.
  • en and az are both complete and in sync (2112 keys each, full parity, none empty). Azerbaijani is a real translation, not a stub.
  • The dead Settings → Preferences dropdown now actually switches the language and it survives a reload. Adding a third language is one JSON file + one entry in SUPPORTED_LANGUAGES.
  • Dates/numbers/relative-times go through Intl keyed to the active locale.
  • npm run i18n:extract (i18next-parser) is wired + documented, and there's an eslint-plugin-i18next no-literal-string gate so new hardcoded strings fail lint.

It's split into small commits so it's reviewable but lands as one PR like the issue asks. Heads up: CodeRabbit auto-skipped because the diff is over its 150-file limit, so there's no bot review here — the parser + lint gate + typecheck are the automated checks. npm run validate is green and I checked the switch end-to-end (en ⇄ az, persists on reload, <html lang> updates).

Anything you'd like changed, or is this good to merge?

@martian56 martian56 merged commit a81f6a3 into Devlaner:main Jul 13, 2026
2 checks passed
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.

Setup I18n for multilingual UI

2 participants