From f0ebf3eba8b9e5f5986c5bd7f049b9eac8af239a Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Tue, 14 Jan 2025 08:46:22 -0500 Subject: [PATCH] Improve i18n support and add missing translations (#6070) Co-authored-by: openhands --- .../__tests__/components/browser.test.tsx | 2 +- .../components/chat/chat-input.test.tsx | 8 +- .../conversation-panel.test.tsx | 2 +- .../github/github-repo-selector.test.tsx | 2 +- .../features/sidebar/sidebar.test.tsx | 10 +- .../components/feedback-form.test.tsx | 17 +- .../components/interactive-chat-box.test.tsx | 2 +- .../components/landing-translations.test.tsx | 190 + .../modals/settings/model-selector.test.tsx | 18 +- .../components/suggestion-item.test.tsx | 27 + .../__tests__/components/user-avatar.test.tsx | 8 +- .../__tests__/i18n/duplicate-keys.test.ts | 76 + frontend/__tests__/i18n/translations.test.tsx | 20 + .../utils/check-hardcoded-strings.test.tsx | 40 + frontend/__tests__/utils/i18n-test-utils.tsx | 29 + .../browser/empty-browser-message.tsx | 2 +- .../components/features/chat/chat-input.tsx | 7 +- .../features/chat/chat-suggestions.tsx | 6 +- .../features/chat/interactive-chat-box.tsx | 1 - .../features/controls/agent-control-bar.tsx | 7 +- .../conversation-panel/conversation-panel.tsx | 7 +- .../new-conversation-button.tsx | 6 +- .../features/feedback/feedback-form.tsx | 26 +- .../features/github/github-repo-selector.tsx | 13 +- .../github-repositories-suggestion-box.tsx | 7 +- .../features/images/attach-image-label.tsx | 5 +- .../features/jupyter/jupyter-cell-output.tsx | 7 +- .../features/sidebar/user-avatar.tsx | 9 +- .../import-project-suggestion-box.tsx | 7 +- .../suggestions/suggestion-bubble.tsx | 7 +- .../features/suggestions/suggestion-item.tsx | 8 +- .../terminal/terminal-status-label.tsx | 5 +- .../features/waitlist/tos-checkbox.tsx | 8 +- frontend/src/components/layout/beta-badge.tsx | 6 +- .../components/layout/served-app-label.tsx | 5 +- .../src/components/shared/action-tooltip.tsx | 6 +- .../components/shared/buttons/docs-button.tsx | 7 +- .../shared/buttons/exit-project-button.tsx | 8 +- .../shared/buttons/open-vscode-button.tsx | 9 +- .../shared/buttons/settings-button.tsx | 7 +- .../components/shared/buttons/stop-button.tsx | 6 +- .../shared/buttons/submit-button.tsx | 5 +- .../components/shared/download-progress.tsx | 19 +- .../src/components/shared/hero-heading.tsx | 12 +- .../shared/inputs/api-key-input.tsx | 8 +- .../account-settings-form.tsx | 22 +- .../shared/modals/settings/model-selector.tsx | 16 +- .../modals/settings/runtime-size-selector.tsx | 5 +- .../shared/modals/settings/settings-form.tsx | 28 +- .../shared/modals/settings/settings-modal.tsx | 11 +- frontend/src/components/shared/task-form.tsx | 28 +- frontend/src/i18n/index.ts | 1 + frontend/src/i18n/translation.json | 6605 +++++++++++------ frontend/src/routes/_oh._index/route.tsx | 7 +- frontend/src/routes/_oh.app/route.tsx | 11 +- .../utils/suggestions/non-repo-suggestions.ts | 8 +- .../src/utils/suggestions/repo-suggestions.ts | 12 +- package-lock.json | 28 + package.json | 5 + 59 files changed, 5189 insertions(+), 2285 deletions(-) create mode 100644 frontend/__tests__/components/landing-translations.test.tsx create mode 100644 frontend/__tests__/i18n/duplicate-keys.test.ts create mode 100644 frontend/__tests__/i18n/translations.test.tsx create mode 100644 frontend/__tests__/utils/check-hardcoded-strings.test.tsx create mode 100644 frontend/__tests__/utils/i18n-test-utils.tsx create mode 100644 package-lock.json create mode 100644 package.json diff --git a/frontend/__tests__/components/browser.test.tsx b/frontend/__tests__/components/browser.test.tsx index 6b4bfba73d4d..c51519bf0ff4 100644 --- a/frontend/__tests__/components/browser.test.tsx +++ b/frontend/__tests__/components/browser.test.tsx @@ -45,7 +45,7 @@ describe("Browser", () => { }); // i18n empty message key - expect(screen.getByText("BROWSER$EMPTY_MESSAGE")).toBeInTheDocument(); + expect(screen.getByText("BROWSER$NO_PAGE_LOADED")).toBeInTheDocument(); }); it("renders the url and a screenshot", () => { diff --git a/frontend/__tests__/components/chat/chat-input.test.tsx b/frontend/__tests__/components/chat/chat-input.test.tsx index 3cf83a1e7b8f..f3248371e37b 100644 --- a/frontend/__tests__/components/chat/chat-input.test.tsx +++ b/frontend/__tests__/components/chat/chat-input.test.tsx @@ -84,12 +84,10 @@ describe("ChatInput", () => { expect(onSubmitMock).not.toHaveBeenCalled(); }); - it("should render a placeholder", () => { - render( - , - ); + it("should render a placeholder with translation key", () => { + render(); - const textarea = screen.getByPlaceholderText("Enter your message"); + const textarea = screen.getByPlaceholderText("SUGGESTIONS$WHAT_TO_BUILD"); expect(textarea).toBeInTheDocument(); }); diff --git a/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx b/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx index 4ec5212bb514..beb89a95625a 100644 --- a/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx +++ b/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx @@ -71,7 +71,7 @@ describe("ConversationPanel", () => { renderConversationPanel(); - const emptyState = await screen.findByText("No conversations found"); + const emptyState = await screen.findByText("CONVERSATION$NO_CONVERSATIONS"); expect(emptyState).toBeInTheDocument(); }); diff --git a/frontend/__tests__/components/features/github/github-repo-selector.test.tsx b/frontend/__tests__/components/features/github/github-repo-selector.test.tsx index 5ef98b251af9..783bc82020f5 100644 --- a/frontend/__tests__/components/features/github/github-repo-selector.test.tsx +++ b/frontend/__tests__/components/features/github/github-repo-selector.test.tsx @@ -19,7 +19,7 @@ describe("GitHubRepositorySelector", () => { ); expect( - screen.getByPlaceholderText("Select a GitHub project"), + screen.getByPlaceholderText("LANDING$SELECT_REPO"), ).toBeInTheDocument(); }); diff --git a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx index 3b7a2a275ecc..43a0ddc660a6 100644 --- a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx +++ b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx @@ -128,7 +128,7 @@ describe("Sidebar", () => { await user.click(norskOption); const tokenInput = - within(accountSettingsModal).getByLabelText(/github token/i); + within(accountSettingsModal).getByLabelText(/GITHUB\$TOKEN_OPTIONAL/i); await user.type(tokenInput, "new-token"); const saveButton = @@ -151,7 +151,11 @@ describe("Sidebar", () => { const settingsModal = screen.getByTestId("ai-config-modal"); - const apiKeyInput = within(settingsModal).getByLabelText(/api key/i); + // Click the advanced options switch to show the API key input + const advancedOptionsSwitch = within(settingsModal).getByTestId("advanced-option-switch"); + await user.click(advancedOptionsSwitch); + + const apiKeyInput = within(settingsModal).getByLabelText(/API\$KEY/i); await user.type(apiKeyInput, "SET"); const saveButton = within(settingsModal).getByTestId( @@ -162,7 +166,7 @@ describe("Sidebar", () => { expect(saveSettingsSpy).toHaveBeenCalledWith({ ...MOCK_USER_PREFERENCES.settings, llm_api_key: undefined, - llm_base_url: undefined, + llm_base_url: "", security_analyzer: undefined, }); }); diff --git a/frontend/__tests__/components/feedback-form.test.tsx b/frontend/__tests__/components/feedback-form.test.tsx index c9234e737475..e3cad75d45c1 100644 --- a/frontend/__tests__/components/feedback-form.test.tsx +++ b/frontend/__tests__/components/feedback-form.test.tsx @@ -14,6 +14,7 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "test-utils"; import { FeedbackForm } from "#/components/features/feedback/feedback-form"; +import { I18nKey } from "#/i18n/declaration"; describe("FeedbackForm", () => { const user = userEvent.setup(); @@ -28,20 +29,20 @@ describe("FeedbackForm", () => { , ); - screen.getByLabelText("Email"); - screen.getByLabelText("Private"); - screen.getByLabelText("Public"); + screen.getByLabelText(I18nKey.FEEDBACK$EMAIL_LABEL); + screen.getByLabelText(I18nKey.FEEDBACK$PRIVATE_LABEL); + screen.getByLabelText(I18nKey.FEEDBACK$PUBLIC_LABEL); - screen.getByRole("button", { name: "Submit" }); - screen.getByRole("button", { name: "Cancel" }); + screen.getByRole("button", { name: I18nKey.FEEDBACK$CONTRIBUTE_LABEL }); + screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL }); }); it("should switch between private and public permissions", async () => { renderWithProviders( , ); - const privateRadio = screen.getByLabelText("Private"); - const publicRadio = screen.getByLabelText("Public"); + const privateRadio = screen.getByLabelText(I18nKey.FEEDBACK$PRIVATE_LABEL); + const publicRadio = screen.getByLabelText(I18nKey.FEEDBACK$PUBLIC_LABEL); expect(privateRadio).toBeChecked(); // private is the default value expect(publicRadio).not.toBeChecked(); @@ -59,7 +60,7 @@ describe("FeedbackForm", () => { renderWithProviders( , ); - await user.click(screen.getByRole("button", { name: "Cancel" })); + await user.click(screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL })); expect(onCloseMock).toHaveBeenCalled(); }); diff --git a/frontend/__tests__/components/interactive-chat-box.test.tsx b/frontend/__tests__/components/interactive-chat-box.test.tsx index fe6ba329763b..6ec74ddc88a6 100644 --- a/frontend/__tests__/components/interactive-chat-box.test.tsx +++ b/frontend/__tests__/components/interactive-chat-box.test.tsx @@ -157,7 +157,7 @@ describe("InteractiveChatBox", () => { expect(onChange).not.toHaveBeenCalledWith(""); // Submit the message with image - const submitButton = screen.getByRole("button", { name: "Send" }); + const submitButton = screen.getByRole("button", { name: "BUTTON$SEND" }); await user.click(submitButton); // Verify onSubmit was called with the message and image diff --git a/frontend/__tests__/components/landing-translations.test.tsx b/frontend/__tests__/components/landing-translations.test.tsx new file mode 100644 index 000000000000..9cfb9a07f160 --- /dev/null +++ b/frontend/__tests__/components/landing-translations.test.tsx @@ -0,0 +1,190 @@ +import { render, screen } from "@testing-library/react"; +import { test, expect, describe, vi } from "vitest"; +import { useTranslation } from "react-i18next"; +import translations from "../../src/i18n/translation.json"; +import { UserAvatar } from "../../src/components/features/sidebar/user-avatar"; + +vi.mock("@nextui-org/react", () => ({ + Tooltip: ({ content, children }: { content: string; children: React.ReactNode }) => ( +
+ {children} +
{content}
+
+ ), +})); + +const supportedLanguages = ['en', 'ja', 'zh-CN', 'zh-TW', 'ko-KR', 'de', 'no', 'it', 'pt', 'es', 'ar', 'fr', 'tr']; + +// Helper function to check if a translation exists for all supported languages +function checkTranslationExists(key: string) { + const missingTranslations: string[] = []; + + const translationEntry = (translations as Record>)[key]; + if (!translationEntry) { + throw new Error(`Translation key "${key}" does not exist in translation.json`); + } + + for (const lang of supportedLanguages) { + if (!translationEntry[lang]) { + missingTranslations.push(lang); + } + } + + return missingTranslations; +} + +// Helper function to find duplicate translation keys +function findDuplicateKeys(obj: Record) { + const seen = new Set(); + const duplicates = new Set(); + + // Only check top-level keys as these are our translation keys + for (const key in obj) { + if (seen.has(key)) { + duplicates.add(key); + } else { + seen.add(key); + } + } + + return Array.from(duplicates); +} + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translationEntry = (translations as Record>)[key]; + return translationEntry?.ja || key; + }, + }), +})); + +describe("Landing page translations", () => { + test("should render Japanese translations correctly", () => { + // Mock a simple component that uses the translations + const TestComponent = () => { + const { t } = useTranslation(); + return ( +
+ {}} /> +
+

{t("LANDING$TITLE")}

+ + + + + +
+
+ {t("WORKSPACE$TERMINAL_TAB_LABEL")} + {t("WORKSPACE$BROWSER_TAB_LABEL")} + {t("WORKSPACE$JUPYTER_TAB_LABEL")} + {t("WORKSPACE$CODE_EDITOR_TAB_LABEL")} +
+
{t("WORKSPACE$TITLE")}
+ +
+ {t("TERMINAL$WAITING_FOR_CLIENT")} + {t("STATUS$CONNECTED")} + {t("STATUS$CONNECTED_TO_SERVER")} +
+
+ {`5 ${t("TIME$MINUTES_AGO")}`} + {`2 ${t("TIME$HOURS_AGO")}`} + {`3 ${t("TIME$DAYS_AGO")}`} +
+
+ ); + }; + + render(); + + // Check main content translations + expect(screen.getByText("開発を始めましょう!")).toBeInTheDocument(); + expect(screen.getByText("VS Codeで開く")).toBeInTheDocument(); + expect(screen.getByText("テストカバレッジを向上させる")).toBeInTheDocument(); + expect(screen.getByText("Dependabot PRを自動マージ")).toBeInTheDocument(); + expect(screen.getByText("READMEを改善")).toBeInTheDocument(); + expect(screen.getByText("依存関係を整理")).toBeInTheDocument(); + + // Check user avatar tooltip + const userAvatar = screen.getByTestId("user-avatar"); + userAvatar.focus(); + expect(screen.getByText("アカウント設定")).toBeInTheDocument(); + + // Check tab labels + const tabs = screen.getByTestId("tabs"); + expect(tabs).toHaveTextContent("ターミナル"); + expect(tabs).toHaveTextContent("ブラウザ"); + expect(tabs).toHaveTextContent("Jupyter"); + expect(tabs).toHaveTextContent("コードエディタ"); + + // Check workspace label and new project button + expect(screen.getByTestId("workspace-label")).toHaveTextContent("ワークスペース"); + expect(screen.getByTestId("new-project")).toHaveTextContent("新規プロジェクト"); + + // Check status messages + const status = screen.getByTestId("status"); + expect(status).toHaveTextContent("クライアントの準備を待機中"); + expect(status).toHaveTextContent("接続済み"); + expect(status).toHaveTextContent("サーバーに接続済み"); + + // Check account settings menu + expect(screen.getByText("アカウント設定")).toBeInTheDocument(); + + // Check time-related translations + const time = screen.getByTestId("time"); + expect(time).toHaveTextContent("5 分前"); + expect(time).toHaveTextContent("2 時間前"); + expect(time).toHaveTextContent("3 日前"); + }); + + test("all translation keys should have translations for all supported languages", () => { + // Test all translation keys used in the component + const translationKeys = [ + "LANDING$TITLE", + "VSCODE$OPEN", + "SUGGESTIONS$INCREASE_TEST_COVERAGE", + "SUGGESTIONS$AUTO_MERGE_PRS", + "SUGGESTIONS$FIX_README", + "SUGGESTIONS$CLEAN_DEPENDENCIES", + "WORKSPACE$TERMINAL_TAB_LABEL", + "WORKSPACE$BROWSER_TAB_LABEL", + "WORKSPACE$JUPYTER_TAB_LABEL", + "WORKSPACE$CODE_EDITOR_TAB_LABEL", + "WORKSPACE$TITLE", + "PROJECT$NEW_PROJECT", + "TERMINAL$WAITING_FOR_CLIENT", + "STATUS$CONNECTED", + "STATUS$CONNECTED_TO_SERVER", + "TIME$MINUTES_AGO", + "TIME$HOURS_AGO", + "TIME$DAYS_AGO" + ]; + + // Check all keys and collect missing translations + const missingTranslationsMap = new Map(); + translationKeys.forEach(key => { + const missing = checkTranslationExists(key); + if (missing.length > 0) { + missingTranslationsMap.set(key, missing); + } + }); + + // If any translations are missing, throw an error with all missing translations + if (missingTranslationsMap.size > 0) { + const errorMessage = Array.from(missingTranslationsMap.entries()) + .map(([key, langs]) => `\n- "${key}" is missing translations for: ${langs.join(', ')}`) + .join(''); + throw new Error(`Missing translations:${errorMessage}`); + } + }); + + test("translation file should not have duplicate keys", () => { + const duplicates = findDuplicateKeys(translations); + + if (duplicates.length > 0) { + throw new Error(`Found duplicate translation keys: ${duplicates.join(', ')}`); + } + }); +}); diff --git a/frontend/__tests__/components/modals/settings/model-selector.test.tsx b/frontend/__tests__/components/modals/settings/model-selector.test.tsx index d2f951050945..757f5dcd45ce 100644 --- a/frontend/__tests__/components/modals/settings/model-selector.test.tsx +++ b/frontend/__tests__/components/modals/settings/model-selector.test.tsx @@ -1,7 +1,23 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ModelSelector } from "#/components/shared/modals/settings/model-selector"; +import { I18nKey } from "#/i18n/declaration"; + +// Mock react-i18next +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: { [key: string]: string } = { + LLM$PROVIDER: "LLM Provider", + LLM$MODEL: "LLM Model", + LLM$SELECT_PROVIDER_PLACEHOLDER: "Select a provider", + LLM$SELECT_MODEL_PLACEHOLDER: "Select a model", + }; + return translations[key] || key; + }, + }), +})); describe("ModelSelector", () => { const models = { diff --git a/frontend/__tests__/components/suggestion-item.test.tsx b/frontend/__tests__/components/suggestion-item.test.tsx index 23d2aaa41e5b..dcdd532e7f6d 100644 --- a/frontend/__tests__/components/suggestion-item.test.tsx +++ b/frontend/__tests__/components/suggestion-item.test.tsx @@ -2,6 +2,20 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { SuggestionItem } from "#/components/features/suggestions/suggestion-item"; +import { I18nKey } from "#/i18n/declaration"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "SUGGESTIONS$TODO_APP": "ToDoリストアプリを開発する", + "LANDING$BUILD_APP_BUTTON": "プルリクエストを表示するアプリを開発する", + "SUGGESTIONS$HACKER_NEWS": "Hacker Newsのトップ記事を表示するbashスクリプトを作成する", + }; + return translations[key] || key; + }, + }), +})); describe("SuggestionItem", () => { const suggestionItem = { label: "suggestion1", value: "a long text value" }; @@ -18,6 +32,19 @@ describe("SuggestionItem", () => { expect(screen.getByText(/suggestion1/i)).toBeInTheDocument(); }); + it("should render a translated suggestion when using I18nKey", async () => { + const translatedSuggestion = { + label: I18nKey.SUGGESTIONS$TODO_APP, + value: "todo app value", + }; + + const { container } = render(); + console.log('Rendered HTML:', container.innerHTML); + + + expect(screen.getByText("ToDoリストアプリを開発する")).toBeInTheDocument(); + }); + it("should call onClick when clicking a suggestion", async () => { const user = userEvent.setup(); render(); diff --git a/frontend/__tests__/components/user-avatar.test.tsx b/frontend/__tests__/components/user-avatar.test.tsx index 076eb75b49bf..59bc90cecce3 100644 --- a/frontend/__tests__/components/user-avatar.test.tsx +++ b/frontend/__tests__/components/user-avatar.test.tsx @@ -14,7 +14,7 @@ describe("UserAvatar", () => { render(); expect(screen.getByTestId("user-avatar")).toBeInTheDocument(); expect( - screen.getByLabelText("user avatar placeholder"), + screen.getByLabelText("USER$AVATAR_PLACEHOLDER"), ).toBeInTheDocument(); }); @@ -38,7 +38,7 @@ describe("UserAvatar", () => { expect(screen.getByAltText("user avatar")).toBeInTheDocument(); expect( - screen.queryByLabelText("user avatar placeholder"), + screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"), ).not.toBeInTheDocument(); }); @@ -46,13 +46,13 @@ describe("UserAvatar", () => { const { rerender } = render(); expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument(); expect( - screen.getByLabelText("user avatar placeholder"), + screen.getByLabelText("USER$AVATAR_PLACEHOLDER"), ).toBeInTheDocument(); rerender(); expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); expect( - screen.queryByLabelText("user avatar placeholder"), + screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"), ).not.toBeInTheDocument(); rerender( diff --git a/frontend/__tests__/i18n/duplicate-keys.test.ts b/frontend/__tests__/i18n/duplicate-keys.test.ts new file mode 100644 index 000000000000..35ab9b89c971 --- /dev/null +++ b/frontend/__tests__/i18n/duplicate-keys.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +describe('translation.json', () => { + it('should not have duplicate translation keys', () => { + // Read the translation.json file + const translationPath = path.join(__dirname, '../../src/i18n/translation.json'); + const translationContent = fs.readFileSync(translationPath, 'utf-8'); + + // First, let's check for exact string matches of key definitions + const keyRegex = /"([^"]+)": {/g; + const matches = translationContent.matchAll(keyRegex); + const keyOccurrences = new Map(); + const duplicateKeys: string[] = []; + + for (const match of matches) { + const key = match[1]; + const count = (keyOccurrences.get(key) || 0) + 1; + keyOccurrences.set(key, count); + if (count > 1) { + duplicateKeys.push(key); + } + } + + // Remove duplicates from duplicateKeys array + const uniqueDuplicates = [...new Set(duplicateKeys)]; + + // If there are duplicates, create a helpful error message + if (uniqueDuplicates.length > 0) { + const errorMessage = `Found duplicate translation keys:\n${uniqueDuplicates + .map((key) => ` - "${key}" appears ${keyOccurrences.get(key)} times`) + .join('\n')}`; + throw new Error(errorMessage); + } + + // Expect no duplicates (this will pass if we reach here) + expect(uniqueDuplicates).toHaveLength(0); + }); + + it('should have consistent translations for each key', () => { + // Read the translation.json file + const translationPath = path.join(__dirname, '../../src/i18n/translation.json'); + const translationContent = fs.readFileSync(translationPath, 'utf-8'); + const translations = JSON.parse(translationContent); + + // Create a map to store English translations for each key + const englishTranslations = new Map(); + const inconsistentKeys: string[] = []; + + // Check each key's English translation + Object.entries(translations).forEach(([key, value]: [string, any]) => { + if (typeof value === 'object' && value.en !== undefined) { + const currentEn = value.en.toLowerCase(); + const existingEn = englishTranslations.get(key)?.toLowerCase(); + + if (existingEn !== undefined && existingEn !== currentEn) { + inconsistentKeys.push(key); + } else { + englishTranslations.set(key, value.en); + } + } + }); + + // If there are inconsistencies, create a helpful error message + if (inconsistentKeys.length > 0) { + const errorMessage = `Found inconsistent translations for keys:\n${inconsistentKeys + .map((key) => ` - "${key}" has multiple different English translations`) + .join('\n')}`; + throw new Error(errorMessage); + } + + // Expect no inconsistencies + expect(inconsistentKeys).toHaveLength(0); + }); +}); diff --git a/frontend/__tests__/i18n/translations.test.tsx b/frontend/__tests__/i18n/translations.test.tsx new file mode 100644 index 000000000000..3833b4d306d1 --- /dev/null +++ b/frontend/__tests__/i18n/translations.test.tsx @@ -0,0 +1,20 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import i18n from '../../src/i18n'; +import { AccountSettingsContextMenu } from '../../src/components/features/context-menu/account-settings-context-menu'; +import { renderWithProviders } from '../../test-utils'; + +describe('Translations', () => { + it('should render translated text', () => { + i18n.changeLanguage('en'); + renderWithProviders( + {}} + onLogout={() => {}} + onClose={() => {}} + isLoggedIn={true} + /> + ); + expect(screen.getByTestId('account-settings-context-menu')).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/utils/check-hardcoded-strings.test.tsx b/frontend/__tests__/utils/check-hardcoded-strings.test.tsx new file mode 100644 index 000000000000..74c288ad3929 --- /dev/null +++ b/frontend/__tests__/utils/check-hardcoded-strings.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { test, expect, describe, vi } from "vitest"; +import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box"; +import { ChatInput } from "#/components/features/chat/chat-input"; + +// Mock react-i18next +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe("Check for hardcoded English strings", () => { + test("InteractiveChatBox should not have hardcoded English strings", () => { + const { container } = render( + {}} + onStop={() => {}} + /> + ); + + // Get all text content + const text = container.textContent; + + // List of English strings that should be translated + const hardcodedStrings = [ + "What do you want to build?", + ]; + + // Check each string + hardcodedStrings.forEach(str => { + expect(text).not.toContain(str); + }); + }); + + test("ChatInput should use translation key for placeholder", () => { + render( {}} />); + screen.getByPlaceholderText("SUGGESTIONS$WHAT_TO_BUILD"); + }); +}); diff --git a/frontend/__tests__/utils/i18n-test-utils.tsx b/frontend/__tests__/utils/i18n-test-utils.tsx new file mode 100644 index 000000000000..7153540a1953 --- /dev/null +++ b/frontend/__tests__/utils/i18n-test-utils.tsx @@ -0,0 +1,29 @@ +import { ReactNode } from "react"; +import { I18nextProvider } from "react-i18next"; + +const mockI18n = { + language: "ja", + t: (key: string) => { + const translations: Record = { + "SUGGESTIONS$TODO_APP": "ToDoリストアプリを開発する", + "LANDING$BUILD_APP_BUTTON": "プルリクエストを表示するアプリを開発する", + "SUGGESTIONS$HACKER_NEWS": "Hacker Newsのトップ記事を表示するbashスクリプトを作成する", + "LANDING$TITLE": "一緒に開発を始めましょう!", + "OPEN_IN_VSCODE": "VS Codeで開く", + "INCREASE_TEST_COVERAGE": "テストカバレッジを向上", + "AUTO_MERGE_PRS": "PRを自動マージ", + "FIX_README": "READMEを修正", + "CLEAN_DEPENDENCIES": "依存関係を整理" + }; + return translations[key] || key; + }, + exists: () => true, + changeLanguage: () => new Promise(() => {}), + use: () => mockI18n, +}; + +export function I18nTestProvider({ children }: { children: ReactNode }) { + return ( + {children} + ); +} diff --git a/frontend/src/components/features/browser/empty-browser-message.tsx b/frontend/src/components/features/browser/empty-browser-message.tsx index bf034bf2c171..a4adc9529245 100644 --- a/frontend/src/components/features/browser/empty-browser-message.tsx +++ b/frontend/src/components/features/browser/empty-browser-message.tsx @@ -8,7 +8,7 @@ export function EmptyBrowserMessage() { return (
- {t(I18nKey.BROWSER$EMPTY_MESSAGE)} + {t(I18nKey.BROWSER$NO_PAGE_LOADED)}
); } diff --git a/frontend/src/components/features/chat/chat-input.tsx b/frontend/src/components/features/chat/chat-input.tsx index 02e346ca27a8..fbf3aff7aa1e 100644 --- a/frontend/src/components/features/chat/chat-input.tsx +++ b/frontend/src/components/features/chat/chat-input.tsx @@ -1,5 +1,7 @@ import React from "react"; import TextareaAutosize from "react-textarea-autosize"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; import { cn } from "#/utils/utils"; import { SubmitButton } from "#/components/shared/buttons/submit-button"; import { StopButton } from "#/components/shared/buttons/stop-button"; @@ -8,7 +10,6 @@ interface ChatInputProps { name?: string; button?: "submit" | "stop"; disabled?: boolean; - placeholder?: string; showButton?: boolean; value?: string; maxRows?: number; @@ -26,7 +27,6 @@ export function ChatInput({ name, button = "submit", disabled, - placeholder, showButton = true, value, maxRows = 4, @@ -39,6 +39,7 @@ export function ChatInput({ className, buttonClassName, }: ChatInputProps) { + const { t } = useTranslation(); const textareaRef = React.useRef(null); const [isDraggingOver, setIsDraggingOver] = React.useState(false); @@ -117,7 +118,7 @@ export function ChatInput({
- Let's start building! + {t(I18nKey.LANDING$TITLE)}
state.agent); @@ -27,8 +30,8 @@ export function AgentControlBar() { } content={ curAgentState === AgentState.PAUSED - ? "Resume the agent task" - : "Pause the current task" + ? t(I18nKey.AGENT$RESUME_TASK) + : t(I18nKey.AGENT$PAUSE_TASK) } action={ curAgentState === AgentState.PAUSED diff --git a/frontend/src/components/features/conversation-panel/conversation-panel.tsx b/frontend/src/components/features/conversation-panel/conversation-panel.tsx index 8594143c3e2d..a96c649a54d8 100644 --- a/frontend/src/components/features/conversation-panel/conversation-panel.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-panel.tsx @@ -1,5 +1,7 @@ import React from "react"; import { NavLink, useParams } from "react-router"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; import { ConversationCard } from "./conversation-card"; import { useUserConversations } from "#/hooks/query/use-user-conversations"; import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation"; @@ -15,6 +17,7 @@ interface ConversationPanelProps { } export function ConversationPanel({ onClose }: ConversationPanelProps) { + const { t } = useTranslation(); const { conversationId: cid } = useParams(); const endSession = useEndSession(); const ref = useClickOutsideElement(onClose); @@ -78,7 +81,9 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) { )} {conversations?.length === 0 && (
-

No conversations found

+

+ {t(I18nKey.CONVERSATION$NO_CONVERSATIONS)} +

)} {conversations?.map((project) => ( diff --git a/frontend/src/components/features/conversation-panel/new-conversation-button.tsx b/frontend/src/components/features/conversation-panel/new-conversation-button.tsx index b7563952cfce..6b391cca906a 100644 --- a/frontend/src/components/features/conversation-panel/new-conversation-button.tsx +++ b/frontend/src/components/features/conversation-panel/new-conversation-button.tsx @@ -1,8 +1,12 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; + interface NewConversationButtonProps { onClick: () => void; } export function NewConversationButton({ onClick }: NewConversationButtonProps) { + const { t } = useTranslation(); return ( ); } diff --git a/frontend/src/components/features/feedback/feedback-form.tsx b/frontend/src/components/features/feedback/feedback-form.tsx index 31705a101493..9c6099024915 100644 --- a/frontend/src/components/features/feedback/feedback-form.tsx +++ b/frontend/src/components/features/feedback/feedback-form.tsx @@ -1,5 +1,7 @@ import React from "react"; import hotToast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; import { Feedback } from "#/api/open-hands.types"; import { useSubmitFeedback } from "#/hooks/mutation/use-submit-feedback"; import { ModalButton } from "#/components/shared/buttons/modal-button"; @@ -13,8 +15,9 @@ interface FeedbackFormProps { } export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) { + const { t } = useTranslation(); const copiedToClipboardToast = () => { - hotToast("Password copied to clipboard", { + hotToast(t(I18nKey.FEEDBACK$PASSWORD_COPIED_MESSAGE), { icon: "📋", position: "bottom-right", }); @@ -41,10 +44,13 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) { target="_blank" rel="noreferrer" > - Go to shared feedback + {t(I18nKey.FEEDBACK$GO_TO_FEEDBACK)} onPressToast(password)} className="cursor-pointer"> - Password: {password} (copy) + {t(I18nKey.FEEDBACK$PASSWORD)}: {password}{" "} + + ({t(I18nKey.FEEDBACK$COPY_LABEL)}) + , { duration: 10000 }, @@ -86,12 +92,14 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) { return (
@@ -104,11 +112,11 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) { type="radio" defaultChecked /> - Private + {t(I18nKey.FEEDBACK$PRIVATE_LABEL)} @@ -116,12 +124,12 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) { diff --git a/frontend/src/components/features/github/github-repo-selector.tsx b/frontend/src/components/features/github/github-repo-selector.tsx index b9f070f213e0..22ef9972ce75 100644 --- a/frontend/src/components/features/github/github-repo-selector.tsx +++ b/frontend/src/components/features/github/github-repo-selector.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { Autocomplete, AutocompleteItem, @@ -6,6 +7,7 @@ import { } from "@nextui-org/react"; import { useDispatch } from "react-redux"; import posthog from "posthog-js"; +import { I18nKey } from "#/i18n/declaration"; import { setSelectedRepository } from "#/state/initial-query-slice"; import { useConfig } from "#/hooks/query/use-config"; import { sanitizeQuery } from "#/utils/sanitize-query"; @@ -23,6 +25,7 @@ export function GitHubRepositorySelector({ userRepositories, publicRepositories, }: GitHubRepositorySelectorProps) { + const { t } = useTranslation(); const { data: config } = useConfig(); const [selectedKey, setSelectedKey] = React.useState(null); @@ -49,14 +52,14 @@ export function GitHubRepositorySelector({ dispatch(setSelectedRepository(null)); }; - const emptyContent = "No results found."; + const emptyContent = t(I18nKey.GITHUB$NO_RESULTS); return ( e.stopPropagation()} > - Add more repositories... + {t(I18nKey.GITHUB$ADD_MORE_REPOS)} // eslint-disable-next-line @typescript-eslint/no-explicit-any ) as any)} {userRepositories.length > 0 && ( - + {userRepositories.map((repo) => ( )} {publicRepositories.length > 0 && ( - + {publicRepositories.map((repo) => ( (""); @@ -53,7 +56,7 @@ export function GitHubRepositoriesSuggestionBox({ return ( <> ) : ( } className="bg-[#791B80] w-full" onClick={handleConnectToGitHub} diff --git a/frontend/src/components/features/images/attach-image-label.tsx b/frontend/src/components/features/images/attach-image-label.tsx index 9b3f1f413cf1..e43f14d9dfdb 100644 --- a/frontend/src/components/features/images/attach-image-label.tsx +++ b/frontend/src/components/features/images/attach-image-label.tsx @@ -1,10 +1,13 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; import Clip from "#/icons/clip.svg?react"; export function AttachImageLabel() { + const { t } = useTranslation(); return (
- Attach images + {t(I18nKey.LANDING$ATTACH_IMAGES)}
); } diff --git a/frontend/src/components/features/jupyter/jupyter-cell-output.tsx b/frontend/src/components/features/jupyter/jupyter-cell-output.tsx index e1d80e68e94b..359c519588bc 100644 --- a/frontend/src/components/features/jupyter/jupyter-cell-output.tsx +++ b/frontend/src/components/features/jupyter/jupyter-cell-output.tsx @@ -1,6 +1,8 @@ import Markdown from "react-markdown"; import SyntaxHighlighter from "react-syntax-highlighter"; import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; import { JupyterLine } from "#/utils/parse-cell-content"; interface JupyterCellOutputProps { @@ -8,9 +10,12 @@ interface JupyterCellOutputProps { } export function JupyterCellOutput({ lines }: JupyterCellOutputProps) { + const { t } = useTranslation(); return (
-
STDOUT/STDERR
+
+ {t(I18nKey.JUPYTER$OUTPUT_LABEL)} +
}
       {!isLoading && !avatarUrl && (
         
diff --git a/frontend/src/components/features/suggestions/import-project-suggestion-box.tsx b/frontend/src/components/features/suggestions/import-project-suggestion-box.tsx
index e76317001a01..5953e0fc620e 100644
--- a/frontend/src/components/features/suggestions/import-project-suggestion-box.tsx
+++ b/frontend/src/components/features/suggestions/import-project-suggestion-box.tsx
@@ -1,3 +1,5 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
 import { SuggestionBox } from "./suggestion-box";
 
 interface ImportProjectSuggestionBoxProps {
@@ -7,13 +9,14 @@ interface ImportProjectSuggestionBoxProps {
 export function ImportProjectSuggestionBox({
   onChange,
 }: ImportProjectSuggestionBoxProps) {
+  const { t } = useTranslation();
   return (
     
           
-            Upload a .zip
+            {t(I18nKey.LANDING$UPLOAD_ZIP)}
           
            void;
   onRefresh: () => void;
 }
@@ -12,6 +14,7 @@ export function SuggestionBubble({
   onClick,
   onRefresh,
 }: SuggestionBubbleProps) {
+  const { t } = useTranslation();
   const handleRefresh = (e: React.MouseEvent) => {
     e.stopPropagation();
     onRefresh();
@@ -24,7 +27,7 @@ export function SuggestionBubble({
     >
       
- {suggestion} + {t(suggestion.key as I18nKey)}
diff --git a/frontend/src/components/features/suggestions/suggestion-item.tsx b/frontend/src/components/features/suggestions/suggestion-item.tsx index 1d685a069803..9b7705958bbb 100644 --- a/frontend/src/components/features/suggestions/suggestion-item.tsx +++ b/frontend/src/components/features/suggestions/suggestion-item.tsx @@ -1,4 +1,7 @@ -export type Suggestion = { label: string; value: string }; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; + +export type Suggestion = { label: I18nKey | string; value: string }; interface SuggestionItemProps { suggestion: Suggestion; @@ -6,6 +9,7 @@ interface SuggestionItemProps { } export function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) { + const { t } = useTranslation(); return (
  • ); diff --git a/frontend/src/components/features/terminal/terminal-status-label.tsx b/frontend/src/components/features/terminal/terminal-status-label.tsx index 24b55960ee9e..cd7197f6d929 100644 --- a/frontend/src/components/features/terminal/terminal-status-label.tsx +++ b/frontend/src/components/features/terminal/terminal-status-label.tsx @@ -1,9 +1,12 @@ import { useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; import { cn } from "#/utils/utils"; import { AgentState } from "#/types/agent-state"; import { RootState } from "#/store"; +import { I18nKey } from "#/i18n/declaration"; export function TerminalStatusLabel() { + const { t } = useTranslation(); const { curAgentState } = useSelector((state: RootState) => state.agent); return ( @@ -17,7 +20,7 @@ export function TerminalStatusLabel() { : "bg-green-500", )} /> - Terminal + {t(I18nKey.WORKSPACE$TERMINAL_TAB_LABEL)} ); } diff --git a/frontend/src/components/features/waitlist/tos-checkbox.tsx b/frontend/src/components/features/waitlist/tos-checkbox.tsx index 2a780776fb3d..b2fdafcbc725 100644 --- a/frontend/src/components/features/waitlist/tos-checkbox.tsx +++ b/frontend/src/components/features/waitlist/tos-checkbox.tsx @@ -1,20 +1,24 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; + interface TOSCheckboxProps { onChange: () => void; } export function TOSCheckbox({ onChange }: TOSCheckboxProps) { + const { t } = useTranslation(); return ( diff --git a/frontend/src/components/layout/beta-badge.tsx b/frontend/src/components/layout/beta-badge.tsx index e31903d138aa..3a7155bb9be4 100644 --- a/frontend/src/components/layout/beta-badge.tsx +++ b/frontend/src/components/layout/beta-badge.tsx @@ -1,7 +1,11 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; + export function BetaBadge() { + const { t } = useTranslation(); return ( - Beta + {t(I18nKey.BADGE$BETA)} ); } diff --git a/frontend/src/components/layout/served-app-label.tsx b/frontend/src/components/layout/served-app-label.tsx index 47687908905d..1334c4be0561 100644 --- a/frontend/src/components/layout/served-app-label.tsx +++ b/frontend/src/components/layout/served-app-label.tsx @@ -1,12 +1,15 @@ +import { useTranslation } from "react-i18next"; import { useActiveHost } from "#/hooks/query/use-active-host"; +import { I18nKey } from "#/i18n/declaration"; export function ServedAppLabel() { + const { t } = useTranslation(); const { activeHost } = useActiveHost(); return (
    -
    App
    +
    {t(I18nKey.APP$TITLE)}
    BETA
    {activeHost &&
    } diff --git a/frontend/src/components/shared/action-tooltip.tsx b/frontend/src/components/shared/action-tooltip.tsx index eb2053a7bdf4..9111269067a4 100644 --- a/frontend/src/components/shared/action-tooltip.tsx +++ b/frontend/src/components/shared/action-tooltip.tsx @@ -22,7 +22,11 @@ export function ActionTooltip({ type, onClick }: ActionTooltipProps) { ); } diff --git a/frontend/src/components/shared/buttons/settings-button.tsx b/frontend/src/components/shared/buttons/settings-button.tsx index 8abae964cd81..2b792e5ed4c4 100644 --- a/frontend/src/components/shared/buttons/settings-button.tsx +++ b/frontend/src/components/shared/buttons/settings-button.tsx @@ -1,16 +1,19 @@ import { FaCog } from "react-icons/fa"; +import { useTranslation } from "react-i18next"; import { TooltipButton } from "./tooltip-button"; +import { I18nKey } from "#/i18n/declaration"; interface SettingsButtonProps { onClick: () => void; } export function SettingsButton({ onClick }: SettingsButtonProps) { + const { t } = useTranslation(); return ( diff --git a/frontend/src/components/shared/buttons/stop-button.tsx b/frontend/src/components/shared/buttons/stop-button.tsx index dbb99ebd100f..bf0ce7db79c0 100644 --- a/frontend/src/components/shared/buttons/stop-button.tsx +++ b/frontend/src/components/shared/buttons/stop-button.tsx @@ -1,13 +1,17 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; + interface StopButtonProps { isDisabled?: boolean; onClick?: () => void; } export function StopButton({ isDisabled, onClick }: StopButtonProps) { + const { t } = useTranslation(); return (
    diff --git a/frontend/src/components/shared/hero-heading.tsx b/frontend/src/components/shared/hero-heading.tsx index f8dea1f89697..22dd254d865c 100644 --- a/frontend/src/components/shared/hero-heading.tsx +++ b/frontend/src/components/shared/hero-heading.tsx @@ -1,24 +1,26 @@ +import { useTranslation } from "react-i18next"; import BuildIt from "#/icons/build-it.svg?react"; +import { I18nKey } from "#/i18n/declaration"; export function HeroHeading() { + const { t } = useTranslation(); return (

    - Let's Start Building! + {t(I18nKey.LANDING$TITLE)}

    - OpenHands makes it easy to build and maintain software using a simple - prompt.{" "} + {t(I18nKey.LANDING$SUBTITLE)}{" "} - Not sure how to start?{" "} + {t(I18nKey.LANDING$START_HELP)}{" "} - Read this + {t(I18nKey.LANDING$START_HELP_LINK)}

    diff --git a/frontend/src/components/shared/inputs/api-key-input.tsx b/frontend/src/components/shared/inputs/api-key-input.tsx index 7bdafb90bd1e..0b3ff3462ab3 100644 --- a/frontend/src/components/shared/inputs/api-key-input.tsx +++ b/frontend/src/components/shared/inputs/api-key-input.tsx @@ -22,14 +22,14 @@ export function APIKeyInput({ isDisabled, isSet }: APIKeyInputProps) { {!isSet && ( )} - {t(I18nKey.SETTINGS_FORM$API_KEY_LABEL)} + {t(I18nKey.API$KEY)}

    - {t(I18nKey.SETTINGS_FORM$DONT_KNOW_API_KEY_LABEL)}{" "} + {t(I18nKey.API$DONT_KNOW_KEY)}{" "} - {t(I18nKey.SETTINGS_FORM$CLICK_HERE_FOR_INSTRUCTIONS_LABEL)} + {t(I18nKey.COMMON$CLICK_FOR_INSTRUCTIONS)}

    diff --git a/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx b/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx index 6e1363274944..5bd3f52fbc59 100644 --- a/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx +++ b/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx @@ -64,7 +64,7 @@ export function AccountSettingsForm({
    - + {config?.APP_MODE === "saas" && config?.APP_SLUG && ( - Configure Github Repositories + {t(I18nKey.GITHUB$CONFIGURE_REPOS)} )} ({ @@ -91,32 +91,32 @@ export function AccountSettingsForm({ <> - {t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "} + {t(I18nKey.GITHUB$GET_TOKEN)}{" "} - {t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)} + {t(I18nKey.COMMON$HERE)} )} {gitHubError && (

    - {t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)} + {t(I18nKey.GITHUB$TOKEN_INVALID)}

    )} {gitHubToken && !gitHubError && ( { logout(); onClose(); @@ -132,7 +132,7 @@ export function AccountSettingsForm({ type="checkbox" defaultChecked={analyticsConsent === "true"} /> - Enable analytics + {t(I18nKey.ANALYTICS$ENABLE)}
    @@ -140,11 +140,11 @@ export function AccountSettingsForm({ testId="save-settings" type="submit" intent="account" - text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$SAVE)} + text={t(I18nKey.BUTTON$SAVE)} className="bg-[#4465DB]" /> diff --git a/frontend/src/components/shared/modals/settings/model-selector.tsx b/frontend/src/components/shared/modals/settings/model-selector.tsx index baefbf349d17..811e3df66c6e 100644 --- a/frontend/src/components/shared/modals/settings/model-selector.tsx +++ b/frontend/src/components/shared/modals/settings/model-selector.tsx @@ -4,6 +4,8 @@ import { AutocompleteSection, } from "@nextui-org/react"; import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; import { mapProvider } from "#/utils/map-provider"; import { VERIFIED_MODELS, VERIFIED_PROVIDERS } from "#/utils/verified-models"; import { extractModelAndProvider } from "#/utils/extract-model-and-provider"; @@ -60,12 +62,14 @@ export function ModelSelector({ setLitellmId(null); }; + const { t } = useTranslation(); + return (
    { if (e?.toString()) handleChangeProvider(e.toString()); @@ -115,15 +119,15 @@ export function ModelSelector({
    { if (e?.toString()) handleChangeModel(e.toString()); diff --git a/frontend/src/components/shared/modals/settings/runtime-size-selector.tsx b/frontend/src/components/shared/modals/settings/runtime-size-selector.tsx index 4d22abb02721..37594d0a52bb 100644 --- a/frontend/src/components/shared/modals/settings/runtime-size-selector.tsx +++ b/frontend/src/components/shared/modals/settings/runtime-size-selector.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { Select, SelectItem } from "@nextui-org/react"; +import { I18nKey } from "#/i18n/declaration"; interface RuntimeSizeSelectorProps { isDisabled: boolean; @@ -18,7 +19,7 @@ export function RuntimeSizeSelector({ htmlFor="runtime-size" className="font-[500] text-[#A3A3A3] text-xs" > - {t("SETTINGS_FORM$RUNTIME_SIZE_LABEL")} + {t(I18nKey.SETTINGS_FORM$RUNTIME_SIZE_LABEL)}