From 0697cfd6e2d525254767bbdfb249d520209160b8 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 5 Nov 2026 17:50:54 -0800 Subject: [PATCH] feat(ui): add a beta language setting to the account menu (#50309) * feat(ui): add a beta language setting to the account menu The account menu gets a Language field, marked Beta, that lists each language by its own name with the English name beside it. The choice is saved per browser in localStorage and applied with a page reload, so memoized and imperative translations are read fresh. English stays the default. An unset value, English, an unknown or unsupported code, a locale that fails to load, and any string a locale has not translated all show English. The field stays hidden until a second locale is registered in LOCALES, so this change is invisible until the first translation lands. * fix(ui): keep the help translate link text inside the link is a void HTML tag, so the Trans parser dropped its children and rendered an empty anchor. The note now uses , and the language list is at least as wide as its trigger. --- .../SidebarAccountMenu/LanguageField.test.tsx | 68 +++++++++++++++ .../SidebarAccountMenu/LanguageField.tsx | 86 +++++++++++++++++++ .../SidebarAccountMenu/SidebarAccountMenu.tsx | 2 + .../src/i18n/I18nProvider.test.tsx | 82 ++++++++++++++++++ .../src/i18n/I18nProvider.tsx | 48 ++++++++++- .../src/i18n/languages.test.ts | 42 +++++++++ ui/litellm-dashboard/src/i18n/languages.ts | 30 +++++++ ui/litellm-dashboard/src/i18n/locales/en.json | 4 + 8 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 ui/litellm-dashboard/src/components/SidebarAccountMenu/LanguageField.test.tsx create mode 100644 ui/litellm-dashboard/src/components/SidebarAccountMenu/LanguageField.tsx create mode 100644 ui/litellm-dashboard/src/i18n/I18nProvider.test.tsx create mode 100644 ui/litellm-dashboard/src/i18n/languages.test.ts create mode 100644 ui/litellm-dashboard/src/i18n/languages.ts diff --git a/ui/litellm-dashboard/src/components/SidebarAccountMenu/LanguageField.test.tsx b/ui/litellm-dashboard/src/components/SidebarAccountMenu/LanguageField.test.tsx new file mode 100644 index 00000000000..3c037d2a44b --- /dev/null +++ b/ui/litellm-dashboard/src/components/SidebarAccountMenu/LanguageField.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { englishName, Locales, nativeName } from "@/i18n/languages"; + +import LanguageField from "./LanguageField"; + +const locales: Locales = new Map([ + ["ja", async () => ({})], + ["es", async () => ({})], +]); + +const renderField = (fieldLocales: Locales) => { + const store = { read: () => null, write: vi.fn() }; + const reload = vi.fn(); + const view = render(); + return { ...view, store, reload }; +}; + +describe("LanguageField", () => { + it("stays hidden while English is the only language", () => { + const { container } = renderField(new Map()); + + expect(container).toBeEmptyDOMElement(); + }); + + it("lists each language by its own name with the English name beside it", async () => { + renderField(locales); + expect(screen.getByRole("combobox", { name: /Language/ })).toHaveTextContent(nativeName("en")); + + await userEvent.click(screen.getByRole("combobox", { name: /Language/ })); + + const japanese = await screen.findByRole("option", { name: new RegExp(nativeName("ja")) }); + expect(japanese).toHaveTextContent(englishName("ja")); + expect(screen.getByRole("option", { name: new RegExp(nativeName("es")) })).toHaveTextContent(englishName("es")); + expect(screen.getByRole("option", { name: nativeName("en") })).toBeInTheDocument(); + }); + + it("links the fallback note to where translations live", () => { + renderField(locales); + + expect(screen.getByRole("link", { name: "Help translate" })).toHaveAttribute( + "href", + expect.stringContaining("ui/litellm-dashboard/src/i18n/locales"), + ); + }); + + it("saves the chosen language and reloads the page", async () => { + const { store, reload } = renderField(locales); + + await userEvent.click(screen.getByRole("combobox", { name: /Language/ })); + await userEvent.click(await screen.findByRole("option", { name: new RegExp(nativeName("ja")) })); + + expect(store.write).toHaveBeenCalledWith("ja"); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("does nothing when the current language is picked again", async () => { + const { store, reload } = renderField(locales); + + await userEvent.click(screen.getByRole("combobox", { name: /Language/ })); + await userEvent.click(await screen.findByRole("option", { name: nativeName("en") })); + + expect(store.write).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/SidebarAccountMenu/LanguageField.tsx b/ui/litellm-dashboard/src/components/SidebarAccountMenu/LanguageField.tsx new file mode 100644 index 00000000000..b3acb97a466 --- /dev/null +++ b/ui/litellm-dashboard/src/components/SidebarAccountMenu/LanguageField.tsx @@ -0,0 +1,86 @@ +import { Select as SelectPrimitive } from "@base-ui/react/select"; +import { CheckIcon } from "lucide-react"; +import { Trans, useTranslation } from "react-i18next"; + +import BetaBadge from "@/components/BetaBadge"; +import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; +import { Select, SelectContent, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + browserLanguageStore, + DEFAULT_LANGUAGE, + englishName, + LanguageStore, + Locales, + LOCALES, + nativeName, + supportedLanguages, +} from "@/i18n/languages"; + +const TRANSLATIONS_URL = "https://github.com/BerriAI/litellm/tree/main/ui/litellm-dashboard/src/i18n/locales"; +const CONTROL_ID = "account-menu-language"; + +type LanguageFieldProps = { + locales?: Locales; + store?: LanguageStore; + reload?: () => void; +}; + +export default function LanguageField({ + locales = LOCALES, + store = browserLanguageStore, + reload = () => window.location.reload(), +}: LanguageFieldProps) { + const { t, i18n } = useTranslation(); + const languages = supportedLanguages(locales); + if (languages.length < 2) return null; + + const choose = (language: string | null) => { + if (language === null || language === i18n.language) return; + store.write(language); + reload(); + }; + + return ( + + + {t("shell.sidebarAccountMenu.language.label")} + + + + + ), + }} + /> + + + ); +} diff --git a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx index 5d419274bc7..6066c9404c6 100644 --- a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx +++ b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx @@ -6,6 +6,7 @@ import { useDisableLiteAdmin } from "@/app/(dashboard)/hooks/useDisableLiteAdmin import { ARIA_AGENT_LABEL } from "@/components/liteadmin/aria-brand"; import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge"; import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; +import LanguageField from "./LanguageField"; import { emitLocalStorageChange, removeLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils"; import { navAccountDisplayName } from "@/components/Navbar/navDisplayName"; import CopyButton from "@/components/shared/CopyButton"; @@ -267,6 +268,7 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla /> )} + diff --git a/ui/litellm-dashboard/src/i18n/I18nProvider.test.tsx b/ui/litellm-dashboard/src/i18n/I18nProvider.test.tsx new file mode 100644 index 00000000000..1cf0c8971a0 --- /dev/null +++ b/ui/litellm-dashboard/src/i18n/I18nProvider.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from "@testing-library/react"; +import i18next, { type InitOptions } from "i18next"; +import { useState } from "react"; +import { I18nextProvider, initReactI18next, useTranslation } from "react-i18next"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { LanguageGate } from "./I18nProvider"; +import { Locales, Messages } from "./languages"; +import en from "./locales/en.json"; + +const JAPANESE_LOGOUT = "ログアウト"; +const japanese: Messages = { shell: { sidebarAccountMenu: { logout: JAPANESE_LOGOUT } } }; + +const options: InitOptions = { + lng: "en", + fallbackLng: "en", + interpolation: { escapeValue: false }, + resources: { en: { translation: en } }, + initAsync: false, +}; + +const createI18n = () => { + const instance = i18next.createInstance(); + void instance.use(initReactI18next).init(options); + return instance; +}; + +function AccountMenuLabels() { + const { t, i18n } = useTranslation(); + const [labelReadOnce] = useState(() => i18n.t("shell.sidebarAccountMenu.logout")); + return ( + <> +

{t("shell.sidebarAccountMenu.logout")}

+

{labelReadOnce}

+

{t("shell.sidebarAccountMenu.changePassword")}

+ + ); +} + +const renderGate = (stored: string | null, locales: Locales) => { + const instance = createI18n(); + render( + + stored, write: vi.fn() }}> + + + , + ); +}; + +afterEach(() => { + document.documentElement.lang = "en"; + vi.restoreAllMocks(); +}); + +describe("LanguageGate", () => { + it("shows the stored language, with English for strings it hasn't translated", async () => { + renderGate("ja", new Map([["ja", async () => japanese]])); + + expect(await screen.findByTestId("logout")).toHaveTextContent(JAPANESE_LOGOUT); + expect(screen.getByTestId("logout-read-once")).toHaveTextContent(JAPANESE_LOGOUT); + expect(screen.getByTestId("change-password")).toHaveTextContent(en.shell.sidebarAccountMenu.changePassword); + expect(document.documentElement.lang).toBe("ja"); + }); + + it.each([null, "en", "fr"])("shows English without loading anything when the stored value is %j", (stored) => { + const load = vi.fn(async () => japanese); + renderGate(stored, new Map([["ja", load]])); + + expect(screen.getByTestId("logout")).toHaveTextContent(en.shell.sidebarAccountMenu.logout); + expect(load).not.toHaveBeenCalled(); + expect(document.documentElement.lang).toBe("en"); + }); + + it("falls back to English when the translations fail to load", async () => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + renderGate("ja", new Map([["ja", () => Promise.reject(new Error("offline"))]])); + + expect(await screen.findByTestId("logout")).toHaveTextContent(en.shell.sidebarAccountMenu.logout); + expect(document.documentElement.lang).toBe("en"); + }); +}); diff --git a/ui/litellm-dashboard/src/i18n/I18nProvider.tsx b/ui/litellm-dashboard/src/i18n/I18nProvider.tsx index f0ecdfe2568..f96658fbbb3 100644 --- a/ui/litellm-dashboard/src/i18n/I18nProvider.tsx +++ b/ui/litellm-dashboard/src/i18n/I18nProvider.tsx @@ -1,9 +1,55 @@ "use client"; +import type { i18n as I18n } from "i18next"; +import { useEffect, useState, useSyncExternalStore } from "react"; import { I18nextProvider } from "react-i18next"; +import LoadingScreen from "@/components/common_components/LoadingScreen"; + import i18n from "./i18n"; +import { browserLanguageStore, DEFAULT_LANGUAGE, LanguageStore, Locales, LOCALES, resolveLanguage } from "./languages"; + +type LanguageGateProps = { + i18n: I18n; + locales: Locales; + store: LanguageStore; + children: React.ReactNode; +}; + +const neverChanges = () => () => undefined; + +export function LanguageGate({ i18n, locales, store, children }: LanguageGateProps) { + const language = useSyncExternalStore( + neverChanges, + () => resolveLanguage(store.read(), locales), + () => DEFAULT_LANGUAGE, + ); + const [settled, setSettled] = useState(DEFAULT_LANGUAGE); + + useEffect(() => { + const load = locales.get(language); + if (!load) return; + load() + .then((messages) => { + i18n.addResourceBundle(language, "translation", messages); + return i18n.changeLanguage(language); + }) + .then(() => { + document.documentElement.lang = language; + }) + .catch((error: unknown) => console.warn(`Couldn't load the "${language}" translations`, error)) + .finally(() => setSettled(language)); + }, [i18n, language, locales]); + + return language === settled ? children : ; +} export default function I18nProvider({ children }: { children: React.ReactNode }) { - return {children}; + return ( + + + {children} + + + ); } diff --git a/ui/litellm-dashboard/src/i18n/languages.test.ts b/ui/litellm-dashboard/src/i18n/languages.test.ts new file mode 100644 index 00000000000..5a462d7a52d --- /dev/null +++ b/ui/litellm-dashboard/src/i18n/languages.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { englishName, Locales, nativeName, resolveLanguage, supportedLanguages } from "./languages"; + +const locales: Locales = new Map([["ja", async () => ({})]]); + +describe("resolveLanguage", () => { + it("uses the stored language when it has translations", () => { + expect(resolveLanguage("ja", locales)).toBe("ja"); + }); + + it.each([null, "", "en", "fr", "JA", "ja-JP", "__proto__"])("shows English when the stored value is %j", (stored) => { + expect(resolveLanguage(stored, locales)).toBe("en"); + }); + + it("shows English when no language besides English has translations", () => { + expect(resolveLanguage("ja", new Map())).toBe("en"); + }); +}); + +describe("supportedLanguages", () => { + it("puts English first, then every language with translations", () => { + expect(supportedLanguages(locales)).toEqual(["en", "ja"]); + expect(supportedLanguages(new Map())).toEqual(["en"]); + }); +}); + +describe("language names", () => { + it("names a language in itself, starting with a capital letter", () => { + const spanish = nativeName("es"); + + expect(spanish.toLocaleLowerCase("es")).toBe( + new Intl.DisplayNames(["es"], { type: "language" }).of("es")?.toLocaleLowerCase("es"), + ); + expect(spanish.charAt(0)).toBe(spanish.charAt(0).toLocaleUpperCase("es")); + }); + + it("names a language in English for the secondary label", () => { + expect(englishName("ja")).toBe(new Intl.DisplayNames(["en"], { type: "language" }).of("ja")); + expect(englishName("ja")).not.toBe(nativeName("ja")); + }); +}); diff --git a/ui/litellm-dashboard/src/i18n/languages.ts b/ui/litellm-dashboard/src/i18n/languages.ts new file mode 100644 index 00000000000..616523a7e54 --- /dev/null +++ b/ui/litellm-dashboard/src/i18n/languages.ts @@ -0,0 +1,30 @@ +import { getLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils"; + +export type Messages = { readonly [key: string]: string | Messages }; +export type Locales = ReadonlyMap Promise>; +export type LanguageStore = { readonly read: () => string | null; readonly write: (language: string) => void }; + +export const DEFAULT_LANGUAGE = "en"; +export const LANGUAGE_STORAGE_KEY = "litellm_language"; + +export const LOCALES: Locales = new Map(); + +export const browserLanguageStore: LanguageStore = { + read: () => getLocalStorageItem(LANGUAGE_STORAGE_KEY), + write: (language) => setLocalStorageItem(LANGUAGE_STORAGE_KEY, language), +}; + +export const resolveLanguage = (stored: string | null, locales: Locales): string => + stored !== null && locales.has(stored) ? stored : DEFAULT_LANGUAGE; + +export const supportedLanguages = (locales: Locales): readonly string[] => [DEFAULT_LANGUAGE, ...locales.keys()]; + +const displayName = (language: string, displayLanguage: string): string => + new Intl.DisplayNames([displayLanguage], { type: "language" }).of(language) ?? language; + +export const nativeName = (language: string): string => { + const name = displayName(language, language); + return name.charAt(0).toLocaleUpperCase(language) + name.slice(1); +}; + +export const englishName = (language: string): string => displayName(language, DEFAULT_LANGUAGE); diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index f8886610e01..c72b9b91ca7 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -8085,6 +8085,10 @@ "copyUserId": "Copy user ID", "hideAria": "Hide ", "toggleHideAria": "Toggle hide {{name}}", + "language": { + "label": "Language", + "fallbackNote": "Missing translations show in English. Help translate" + }, "changePassword": "Change Password", "logout": "Logout" },