mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-07 07:58:09 +00:00
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 <link> is a void HTML tag, so the Trans parser dropped its children and rendered an empty anchor. The note now uses <githubLink>, and the language list is at least as wide as its trigger.
This commit is contained in:
parent
9f489430a6
commit
0697cfd6e2
8 changed files with 361 additions and 1 deletions
|
|
@ -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(<LanguageField locales={fieldLocales} store={store} reload={reload} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<Field className="gap-1.5 px-3 pt-2 pb-2.5">
|
||||
<FieldLabel htmlFor={CONTROL_ID} className="text-[13px] font-normal">
|
||||
<BetaBadge>{t("shell.sidebarAccountMenu.language.label")}</BetaBadge>
|
||||
</FieldLabel>
|
||||
<Select
|
||||
items={languages.map((language) => ({ value: language, label: nativeName(language) }))}
|
||||
value={i18n.language}
|
||||
onValueChange={choose}
|
||||
>
|
||||
<SelectTrigger id={CONTROL_ID} size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" className="w-auto min-w-(--anchor-width)">
|
||||
{languages.map((language) => (
|
||||
<SelectPrimitive.Item
|
||||
key={language}
|
||||
value={language}
|
||||
className="flex w-full cursor-default items-center gap-1.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground"
|
||||
>
|
||||
<SelectPrimitive.ItemText className="whitespace-nowrap">{nativeName(language)}</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator className="flex">
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
{language !== DEFAULT_LANGUAGE && (
|
||||
<span className="ml-auto pl-6 whitespace-nowrap text-muted-foreground">{englishName(language)}</span>
|
||||
)}
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription className="text-xs">
|
||||
<Trans
|
||||
i18nKey="shell.sidebarAccountMenu.language.fallbackNote"
|
||||
components={{
|
||||
githubLink: (
|
||||
<a href={TRANSLATIONS_URL} target="_blank" rel="noopener noreferrer" className="whitespace-nowrap" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
<LanguageField />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
|
|
|||
82
ui/litellm-dashboard/src/i18n/I18nProvider.test.tsx
Normal file
82
ui/litellm-dashboard/src/i18n/I18nProvider.test.tsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<p data-testid="logout">{t("shell.sidebarAccountMenu.logout")}</p>
|
||||
<p data-testid="logout-read-once">{labelReadOnce}</p>
|
||||
<p data-testid="change-password">{t("shell.sidebarAccountMenu.changePassword")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderGate = (stored: string | null, locales: Locales) => {
|
||||
const instance = createI18n();
|
||||
render(
|
||||
<I18nextProvider i18n={instance}>
|
||||
<LanguageGate i18n={instance} locales={locales} store={{ read: () => stored, write: vi.fn() }}>
|
||||
<AccountMenuLabels />
|
||||
</LanguageGate>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 : <LoadingScreen />;
|
||||
}
|
||||
|
||||
export default function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<LanguageGate i18n={i18n} locales={LOCALES} store={browserLanguageStore}>
|
||||
{children}
|
||||
</LanguageGate>
|
||||
</I18nextProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
42
ui/litellm-dashboard/src/i18n/languages.test.ts
Normal file
42
ui/litellm-dashboard/src/i18n/languages.test.ts
Normal file
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
30
ui/litellm-dashboard/src/i18n/languages.ts
Normal file
30
ui/litellm-dashboard/src/i18n/languages.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { getLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils";
|
||||
|
||||
export type Messages = { readonly [key: string]: string | Messages };
|
||||
export type Locales = ReadonlyMap<string, () => Promise<Messages>>;
|
||||
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);
|
||||
|
|
@ -8085,6 +8085,10 @@
|
|||
"copyUserId": "Copy user ID",
|
||||
"hideAria": "Hide <name/>",
|
||||
"toggleHideAria": "Toggle hide {{name}}",
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"fallbackNote": "Missing translations show in English. <githubLink>Help translate</githubLink>"
|
||||
},
|
||||
"changePassword": "Change Password",
|
||||
"logout": "Logout"
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue