Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve i18n support and add missing translations #6070

Open
wants to merge 78 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
853a62b
Add Japanese translations to frontend
openhands-agent Jan 6, 2025
e010788
Add Japanese language option to language selector
openhands-agent Jan 6, 2025
2228353
Add Japanese translations and update UI components
openhands-agent Jan 6, 2025
e05d7a9
Fix frontend tests and lint issues
openhands-agent Jan 6, 2025
49b12d9
Add translations for settings modal and sidebar tooltips
openhands-agent Jan 6, 2025
2039f15
Add translations for main page content
openhands-agent Jan 6, 2025
f7f7b6a
Apply suggestions from code review
neubig Jan 6, 2025
2a70fdc
Fix frontend linting issues
openhands-agent Jan 6, 2025
ace4f58
Update frontend/src/i18n/translation.json
neubig Jan 6, 2025
3241ff4
Fix suggestion localization by using proper translation keys
openhands-agent Jan 6, 2025
986de97
Add translations for all suggestions
openhands-agent Jan 6, 2025
959a181
Fix remaining linting issues
openhands-agent Jan 6, 2025
d281d5e
Fix suggestion localization and add test
openhands-agent Jan 6, 2025
72bbf93
Add missing Japanese translations for UI elements
openhands-agent Jan 6, 2025
1b55a7e
Add tests for Japanese translations and fix missing translations
openhands-agent Jan 6, 2025
8f24a99
Add more Japanese translations for UI elements
openhands-agent Jan 6, 2025
e5538da
Add more Japanese translations for UI elements
openhands-agent Jan 6, 2025
ae66a4e
Add tests for Japanese translations
openhands-agent Jan 6, 2025
0a49f21
Consolidate Japanese translation tests into landing-translations.test…
openhands-agent Jan 6, 2025
0fd1582
Improve i18n support:
openhands-agent Jan 6, 2025
4b80de4
fix: Add missing translations and improve i18n testing
openhands-agent Jan 6, 2025
998e16a
Consolidate duplicate translation keys: LETS_START_BUILDING -> LANDIN…
openhands-agent Jan 6, 2025
9645ff8
Consolidate duplicate translation keys:
openhands-agent Jan 6, 2025
f348cf1
Remove more unused translation keys:
openhands-agent Jan 6, 2025
cd59652
Merge branch 'main' into add-japanese-translations
neubig Jan 6, 2025
e1ab79c
Add missing Japanese translations and fix tests
openhands-agent Jan 7, 2025
1a87c85
Add Japanese translations for project buttons
openhands-agent Jan 7, 2025
ba04181
feat(i18n): Add Japanese translations for welcome message and VS Code…
openhands-agent Jan 7, 2025
42b3e69
feat(i18n): Update Japanese translation for welcome message
openhands-agent Jan 7, 2025
73c50f3
feat(i18n): Simplify Japanese translations for workspace, browser, an…
openhands-agent Jan 7, 2025
d3d4acd
test: Update Japanese translation tests to match new translations
openhands-agent Jan 7, 2025
a90cc37
feat(i18n): Simplify Japanese translations for workspace, browser, an…
openhands-agent Jan 7, 2025
b7701b8
fix: Update VS Code button to use correct translation key
openhands-agent Jan 7, 2025
06a8e5c
fix: Add Japanese translation for Terminal label
openhands-agent Jan 7, 2025
579c58f
fix: Initialize i18n with language from settings
openhands-agent Jan 7, 2025
7d7ae46
fix: Handle localStorage access during SSR
openhands-agent Jan 7, 2025
9e4c9e0
Add Japanese translations for LLM provider settings
openhands-agent Jan 7, 2025
91d6529
Fix Japanese translations for LLM provider settings
openhands-agent Jan 7, 2025
bd685b9
Fix i18n initialization to properly load translations
openhands-agent Jan 7, 2025
f13a1aa
Fix i18n initialization to properly transform translations
openhands-agent Jan 7, 2025
24a1921
Fix translations in settings form
openhands-agent Jan 7, 2025
77b6229
Fix translations in account settings form
openhands-agent Jan 7, 2025
40b09a2
fix: Fix code formatting and remove unused imports
openhands-agent Jan 7, 2025
b7fc110
feat: Add German translations for landing page and account settings
openhands-agent Jan 7, 2025
6b33720
fix: Restore complete translations that were accidentally removed
openhands-agent Jan 7, 2025
8697a02
fix: consolidate Japanese translations for 'What do you want to build…
openhands-agent Jan 7, 2025
e5346c7
fix: consolidate duplicate translation keys and add test
openhands-agent Jan 7, 2025
db6ce0c
Add Japanese translation for Logout
openhands-agent Jan 7, 2025
89581ff
Add tests for Japanese translations
openhands-agent Jan 7, 2025
1a05c2a
Add Japanese translations for Account Settings
openhands-agent Jan 7, 2025
43240e1
Modify translation test to check all keys
openhands-agent Jan 7, 2025
96782b3
Add missing Japanese translations
openhands-agent Jan 7, 2025
1550d9f
Add missing Chinese (Simplified) translations
openhands-agent Jan 7, 2025
cb8d704
Add missing Chinese (Traditional) translations
openhands-agent Jan 7, 2025
9df8d94
Add missing Korean translations
openhands-agent Jan 7, 2025
5a5e6ec
Add missing German translations
openhands-agent Jan 7, 2025
6844a1a
Add missing Turkish translations
openhands-agent Jan 7, 2025
95f1e5e
Add remaining Japanese translations
openhands-agent Jan 7, 2025
b3c883a
Add missing translations for various languages
openhands-agent Jan 7, 2025
1da76b3
Add missing translations for various languages
openhands-agent Jan 7, 2025
3f9e652
Merge branch 'main' into add-japanese-translations
neubig Jan 7, 2025
b8ac8aa
Remove patch file
openhands-agent Jan 7, 2025
eafaec7
Remove OpenHands submodule
openhands-agent Jan 7, 2025
f04cc01
Delete frontend/scripts/fix-duplicate-translations.py
neubig Jan 7, 2025
392f601
Remove Jest dependencies since we use Vitest
openhands-agent Jan 7, 2025
dd88322
refactor: use I18nKey enum instead of direct string translations
openhands-agent Jan 7, 2025
89793d8
refactor: fix remaining direct string translations and update tests
openhands-agent Jan 7, 2025
716c338
Delete frontend/src/i18n/translation.json.new
neubig Jan 7, 2025
5d0ceff
Update keys
openhands-agent Jan 7, 2025
bec51b7
merge
openhands-agent Jan 7, 2025
d55fd12
fix: Remove duplicate i18n keys and update tests to match translations
openhands-agent Jan 7, 2025
caab131
Convert Unicode escapes to actual characters in translation.json
openhands-agent Jan 7, 2025
4be4c4c
Remove placeholder prop from ChatInput and use translation key only
openhands-agent Jan 7, 2025
bb7f00f
Remove placeholder prop from ChatInput and update components
openhands-agent Jan 7, 2025
f164e45
Fix lint issues: Remove unused imports and variables, fix formatting
openhands-agent Jan 7, 2025
d83b509
One last piece of localization
neubig Jan 7, 2025
7a9228f
Merge branch 'add-japanese-translations' of github.com:All-Hands-AI/O…
neubig Jan 7, 2025
edb4ed6
Fix linting issues: Fix import order and add newlines at end of files
openhands-agent Jan 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions frontend/__tests__/components/chat/chat-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,10 @@ describe("ChatInput", () => {
expect(onSubmitMock).not.toHaveBeenCalled();
});

it("should render a placeholder", () => {
render(
<ChatInput placeholder="Enter your message" onSubmit={onSubmitMock} />,
);
it("should render a placeholder with translation key", () => {
render(<ChatInput onSubmit={onSubmitMock} />);

const textarea = screen.getByPlaceholderText("Enter your message");
const textarea = screen.getByPlaceholderText("SUGGESTIONS$WHAT_TO_BUILD");
expect(textarea).toBeInTheDocument();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe("ConversationPanel", () => {

renderConversationPanel();

const emptyState = await screen.findByText("No conversations found");
const emptyState = await screen.findByText("CONVERSATION$NO_CONVERSATIONS");
expect(emptyState).toBeInTheDocument();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe("GitHubRepositorySelector", () => {
);

expect(
screen.getByPlaceholderText("Select a GitHub project"),
screen.getByPlaceholderText("LANDING$SELECT_REPO"),
).toBeInTheDocument();
});

Expand Down
17 changes: 9 additions & 8 deletions frontend/__tests__/components/feedback-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -28,20 +29,20 @@ describe("FeedbackForm", () => {
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);

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(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
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();
Expand All @@ -59,7 +60,7 @@ describe("FeedbackForm", () => {
renderWithProviders(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
await user.click(screen.getByRole("button", { name: "Cancel" }));
await user.click(screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL }));

expect(onCloseMock).toHaveBeenCalled();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
190 changes: 190 additions & 0 deletions frontend/__tests__/components/landing-translations.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div>
{children}
<div>{content}</div>
</div>
),
}));

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<string, Record<string, string>>)[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<string, any>) {
const seen = new Set<string>();
const duplicates = new Set<string>();

// 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<string, Record<string, string>>)[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 (
<div>
<UserAvatar onClick={() => {}} />
<div data-testid="main-content">
<h1>{t("LANDING$TITLE")}</h1>
<button>{t("VSCODE$OPEN")}</button>
<button>{t("SUGGESTIONS$INCREASE_TEST_COVERAGE")}</button>
<button>{t("SUGGESTIONS$AUTO_MERGE_PRS")}</button>
<button>{t("SUGGESTIONS$FIX_README")}</button>
<button>{t("SUGGESTIONS$CLEAN_DEPENDENCIES")}</button>
</div>
<div data-testid="tabs">
<span>{t("WORKSPACE$TERMINAL_TAB_LABEL")}</span>
<span>{t("WORKSPACE$BROWSER_TAB_LABEL")}</span>
<span>{t("WORKSPACE$JUPYTER_TAB_LABEL")}</span>
<span>{t("WORKSPACE$CODE_EDITOR_TAB_LABEL")}</span>
</div>
<div data-testid="workspace-label">{t("WORKSPACE$TITLE")}</div>
<button data-testid="new-project">{t("PROJECT$NEW_PROJECT")}</button>
<div data-testid="status">
<span>{t("TERMINAL$WAITING_FOR_CLIENT")}</span>
<span>{t("STATUS$CONNECTED")}</span>
<span>{t("STATUS$CONNECTED_TO_SERVER")}</span>
</div>
<div data-testid="time">
<span>{`5 ${t("TIME$MINUTES_AGO")}`}</span>
<span>{`2 ${t("TIME$HOURS_AGO")}`}</span>
<span>{`3 ${t("TIME$DAYS_AGO")}`}</span>
</div>
</div>
);
};

render(<TestComponent />);

// 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<string, string[]>();
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(', ')}`);
}
});
});
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
27 changes: 27 additions & 0 deletions frontend/__tests__/components/suggestion-item.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"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" };
Expand All @@ -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(<SuggestionItem suggestion={translatedSuggestion} onClick={onClick} />);
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(<SuggestionItem suggestion={suggestionItem} onClick={onClick} />);
Expand Down
8 changes: 4 additions & 4 deletions frontend/__tests__/components/user-avatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ describe("UserAvatar", () => {
render(<UserAvatar onClick={onClickMock} />);
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
expect(
screen.getByLabelText("user avatar placeholder"),
screen.getByLabelText("USER$AVATAR_PLACEHOLDER"),
).toBeInTheDocument();
});

Expand All @@ -38,21 +38,21 @@ describe("UserAvatar", () => {

expect(screen.getByAltText("user avatar")).toBeInTheDocument();
expect(
screen.queryByLabelText("user avatar placeholder"),
screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"),
).not.toBeInTheDocument();
});

it("should display a loading spinner instead of an avatar when isLoading is true", () => {
const { rerender } = render(<UserAvatar onClick={onClickMock} />);
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
expect(
screen.getByLabelText("user avatar placeholder"),
screen.getByLabelText("USER$AVATAR_PLACEHOLDER"),
).toBeInTheDocument();

rerender(<UserAvatar onClick={onClickMock} isLoading />);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
expect(
screen.queryByLabelText("user avatar placeholder"),
screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"),
).not.toBeInTheDocument();

rerender(
Expand Down
Loading
Loading