mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-18 22:59:57 +00:00
[client/ui] Introduce localisation (i18n + preferences) feature packages
Adds a tray + React translation pipeline driven by a single JSON locale tree (frontend/src/i18n/locales) embedded into the Go binary. The tray re-renders on language switch via a Localizer that subscribes to the preferences store. Layout: - client/ui/i18n: Bundle, LanguageCode, Language, errors, embedded-FS loader. Pure domain, no Wails/daemon deps. - client/ui/preferences: Store + UIPreferences for user-scope UI state, persisted under os.UserConfigDir()/netbird/ui-preferences.json with atomic writes and a subscribe/broadcast channel. - client/ui/services: thin Wails-binding facades (services.I18n, services.Preferences) so React sees ctx-first signatures. - client/ui/localizer.go: tray bridge that owns the active language, exposes T()/StatusLabel() and re-paints the menu on prefs change. - tray.go: every user-facing const replaced by translation keys via t.loc.T(...); menu rebuild + state replay on language switch. - main.go: //go:embed all:frontend/src/i18n/locales, wires Bundle -> Store -> Localizer -> Wails facades in order. Frontend API exposed via Wails bindings: I18n.Languages, I18n.Bundle, Preferences.Get, Preferences.SetLanguage, plus the netbird:preferences:changed event. Includes regenerated Wails TS bindings (peers/profileswitcher/etc. re-emitted as part of the build) and en/hu seed bundles.
This commit is contained in:
156
client/ui/i18n/bundle_test.go
Normal file
156
client/ui/i18n/bundle_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeLocales returns an in-memory FS that mirrors the real
|
||||
// frontend/src/i18n/locales layout (root-level _index.json plus
|
||||
// <code>/common.json bundles). Used by every Bundle test so we don't
|
||||
// depend on the embedded production bundles staying stable.
|
||||
func fakeLocales() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"_index.json": {Data: []byte(`{
|
||||
"languages": [
|
||||
{"code": "en", "displayName": "English", "englishName": "English"},
|
||||
{"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}
|
||||
]
|
||||
}`)},
|
||||
"en/common.json": {Data: []byte(`{
|
||||
"tray.menu.connect": "Connect",
|
||||
"tray.menu.installVersion": "Install version {version}",
|
||||
"notify.update.body": "NetBird {version} is available."
|
||||
}`)},
|
||||
"hu/common.json": {Data: []byte(`{
|
||||
"tray.menu.connect": "Csatlakozás",
|
||||
"tray.menu.installVersion": "{version} telepítése"
|
||||
}`)},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundle_LoadsAllLanguages(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
langs := b.Languages()
|
||||
require.Len(t, langs, 2)
|
||||
codes := []LanguageCode{langs[0].Code, langs[1].Code}
|
||||
assert.ElementsMatch(t, []LanguageCode{"en", "hu"}, codes, "Languages should list every bundle that loaded")
|
||||
}
|
||||
|
||||
func TestBundle_TranslateLooksUpKey(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Csatlakozás", b.Translate("hu", "tray.menu.connect"))
|
||||
assert.Equal(t, "Connect", b.Translate("en", "tray.menu.connect"))
|
||||
}
|
||||
|
||||
func TestBundle_TranslateSubstitutesPlaceholders(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Install version 1.2.3",
|
||||
b.Translate("en", "tray.menu.installVersion", "version", "1.2.3"),
|
||||
"placeholders should substitute by name")
|
||||
assert.Equal(t, "1.2.3 telepítése",
|
||||
b.Translate("hu", "tray.menu.installVersion", "version", "1.2.3"))
|
||||
}
|
||||
|
||||
func TestBundle_TranslateFallsBackToEnglish(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
// notify.update.body is missing from the hu bundle; English fallback
|
||||
// applies so the user always sees a populated label rather than the
|
||||
// raw key.
|
||||
got := b.Translate("hu", "notify.update.body", "version", "9.9.9")
|
||||
assert.Equal(t, "NetBird 9.9.9 is available.", got, "missing hu key should fall back to en bundle")
|
||||
}
|
||||
|
||||
func TestBundle_TranslateUnknownKeyReturnsKey(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "tray.missing", b.Translate("en", "tray.missing"),
|
||||
"unknown key should return the key itself for debugability")
|
||||
}
|
||||
|
||||
func TestBundle_BundleForReturnsCopy(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
m, err := b.BundleFor("en")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, m, "BundleFor should return populated map for known language")
|
||||
|
||||
m["tray.menu.connect"] = "Mutated"
|
||||
assert.Equal(t, "Connect", b.Translate("en", "tray.menu.connect"),
|
||||
"BundleFor must return a copy, not the live map")
|
||||
}
|
||||
|
||||
func TestBundle_BundleForUnknownLanguage(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = b.BundleFor("xx")
|
||||
assert.ErrorIs(t, err, ErrUnsupportedLanguage)
|
||||
}
|
||||
|
||||
func TestBundle_HasLanguage(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, b.HasLanguage("en"))
|
||||
assert.True(t, b.HasLanguage("hu"))
|
||||
assert.False(t, b.HasLanguage("de"))
|
||||
}
|
||||
|
||||
func TestBundle_MissingDefaultBundleFails(t *testing.T) {
|
||||
// Without an en bundle we have nothing to fall back to, so construction
|
||||
// must hard-fail. Catches packaging accidents where someone drops the
|
||||
// English locale.
|
||||
fs := fstest.MapFS{
|
||||
"_index.json": {Data: []byte(`{"languages":[{"code":"hu","displayName":"Magyar","englishName":"Hungarian"}]}`)},
|
||||
"hu/common.json": {Data: []byte(`{"k":"v"}`)},
|
||||
}
|
||||
_, err := NewBundle(fs)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "default language")
|
||||
}
|
||||
|
||||
func TestBundle_MissingBundleSkipsLanguage(t *testing.T) {
|
||||
// A language declared in the index but missing its bundle file is
|
||||
// dropped from Languages with a warning — adding a new language must
|
||||
// be a two-step process (declare + ship), not declare-only.
|
||||
fs := fstest.MapFS{
|
||||
"_index.json": {Data: []byte(`{"languages":[
|
||||
{"code":"en","displayName":"English","englishName":"English"},
|
||||
{"code":"de","displayName":"Deutsch","englishName":"German"}
|
||||
]}`)},
|
||||
"en/common.json": {Data: []byte(`{"k":"v"}`)},
|
||||
}
|
||||
b, err := NewBundle(fs)
|
||||
require.NoError(t, err)
|
||||
|
||||
langs := b.Languages()
|
||||
require.Len(t, langs, 1)
|
||||
assert.Equal(t, LanguageCode("en"), langs[0].Code, "language without a bundle file must be dropped")
|
||||
assert.False(t, b.HasLanguage("de"))
|
||||
}
|
||||
|
||||
func TestBundle_OddPlaceholderArgsDoNotPanic(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Trailing dangling arg should be dropped, not panic — preserves UI
|
||||
// stability when a caller passes an unpaired placeholder by mistake.
|
||||
got := b.Translate("en", "tray.menu.installVersion", "version", "1.2.3", "extra")
|
||||
assert.Equal(t, "Install version 1.2.3", got)
|
||||
}
|
||||
Reference in New Issue
Block a user