diff --git a/apps/admin/app/(all)/(dashboard)/general/form.tsx b/apps/admin/app/(all)/(dashboard)/general/form.tsx index e38d7848eb7..7aebab78280 100644 --- a/apps/admin/app/(all)/(dashboard)/general/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/general/form.tsx @@ -10,6 +10,7 @@ import { Telescope } from "lucide-react"; // plane imports import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { useTranslation } from "@plane/i18n"; import type { IInstance, IInstanceAdmin } from "@plane/types"; import { Input, ToggleSwitch } from "@plane/ui"; // components @@ -25,6 +26,7 @@ export interface IGeneralConfigurationForm { export const GeneralConfigurationForm = observer(function GeneralConfigurationForm(props: IGeneralConfigurationForm) { const { instance, instanceAdmins } = props; // hooks + const { t } = useTranslation(); const { updateInstanceInfo } = useInstance(); // form data @@ -46,7 +48,7 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo .then(() => setToast({ type: TOAST_TYPE.SUCCESS, - title: "Success", + title: t("success"), message: "Settings updated successfully", }) ) @@ -70,7 +72,7 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo />
-

Email

+

{t("email")}

- {isSubmitting ? "Saving" : "Save changes"} + {isSubmitting ? t("saving") : t("save_changes")}
diff --git a/apps/admin/app/(all)/(home)/sign-in-form.tsx b/apps/admin/app/(all)/(home)/sign-in-form.tsx index 7b03b8c1cd8..024c6df681d 100644 --- a/apps/admin/app/(all)/(home)/sign-in-form.tsx +++ b/apps/admin/app/(all)/(home)/sign-in-form.tsx @@ -10,6 +10,7 @@ import { Eye, EyeOff } from "lucide-react"; // plane internal packages import type { EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants"; import { API_BASE_URL } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; import { AuthService } from "@plane/services"; import { Input, Spinner } from "@plane/ui"; @@ -50,6 +51,7 @@ const defaultFromData: TFormData = { }; export function InstanceSignInForm() { + const { t } = useTranslation(); // search params const searchParams = useSearchParams(); const emailParam = searchParams.get("email") || undefined; @@ -133,15 +135,15 @@ export function InstanceSignInForm() {
handleFormChange("email", e.target.value)} autoComplete="off" @@ -151,16 +153,16 @@ export function InstanceSignInForm() {
handleFormChange("password", e.target.value)} autoComplete="off" @@ -168,7 +170,7 @@ export function InstanceSignInForm() { {showPassword ? (
diff --git a/apps/admin/components/instance/setup-form.tsx b/apps/admin/components/instance/setup-form.tsx index fdb7c809173..993321ebff5 100644 --- a/apps/admin/components/instance/setup-form.tsx +++ b/apps/admin/components/instance/setup-form.tsx @@ -10,6 +10,7 @@ import { useSearchParams } from "next/navigation"; import { Eye, EyeOff } from "lucide-react"; // plane internal packages import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; import { AuthService } from "@plane/services"; import { Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui"; @@ -58,6 +59,7 @@ const defaultFromData: TFormData = { }; export function InstanceSetupForm() { + const { t } = useTranslation(); // search params const searchParams = useSearchParams(); const firstNameParam = searchParams?.get("first_name") || undefined; @@ -163,10 +165,10 @@ export function InstanceSetupForm() {
handleFormChange("email", e.target.value)} hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false} @@ -234,7 +236,7 @@ export function InstanceSetupForm() { Company name *
handleFormChange("password", e.target.value)} hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false} @@ -273,7 +275,7 @@ export function InstanceSetupForm() { {showPassword.password ? (
@@ -366,7 +368,7 @@ export function InstanceSetupForm() {
diff --git a/apps/admin/package.json b/apps/admin/package.json index f76cd3c15a2..ba4a24d92e2 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -10,6 +10,7 @@ "build": "react-router build", "preview": "react-router build && serve -s build/client -l 3001", "start": "serve -s build/client -l 3001", + "test:i18n": "node --experimental-strip-types --test tests/i18n-wiring.test.ts", "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist && rm -rf build", "check:lint": "oxlint --max-warnings=759 .", "check:types": "react-router typegen && tsc --noEmit", @@ -25,6 +26,7 @@ "@headlessui/react": "catalog:", "@plane/constants": "workspace:*", "@plane/hooks": "workspace:*", + "@plane/i18n": "workspace:*", "@plane/propel": "workspace:*", "@plane/services": "workspace:*", "@plane/types": "workspace:*", diff --git a/apps/admin/providers/core.tsx b/apps/admin/providers/core.tsx index 3b22c70878a..6b012878ef0 100644 --- a/apps/admin/providers/core.tsx +++ b/apps/admin/providers/core.tsx @@ -11,6 +11,7 @@ import { AppProgressBar } from "@/lib/b-progress"; import { ToastWithTheme } from "./toast"; import { StoreProvider } from "./store.provider"; import { InstanceProvider } from "./instance.provider"; +import { AdminTranslationProvider } from "./translation-provider"; import { UserProvider } from "./user.provider"; const DEFAULT_SWR_CONFIG = { @@ -24,16 +25,18 @@ const DEFAULT_SWR_CONFIG = { export function CoreProviders({ children }: { children: React.ReactNode }) { return ( - - - - - - - {children} - - - - + + + + + + + + {children} + + + + + ); } diff --git a/apps/admin/providers/translation-provider.tsx b/apps/admin/providers/translation-provider.tsx new file mode 100644 index 00000000000..47ef98fd972 --- /dev/null +++ b/apps/admin/providers/translation-provider.tsx @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect } from "react"; +import { TranslationProvider, useTranslation } from "@plane/i18n"; + +type TAdminTranslationProviderProps = { + children: React.ReactNode; +}; + +function DocumentLanguageSync({ children }: TAdminTranslationProviderProps) { + const { currentLocale } = useTranslation(); + + useEffect(() => { + document.documentElement.lang = currentLocale; + }, [currentLocale]); + + return children; +} + +export function AdminTranslationProvider({ children }: TAdminTranslationProviderProps) { + return ( + + {children} + + ); +} diff --git a/apps/admin/tests/i18n-wiring.test.ts b/apps/admin/tests/i18n-wiring.test.ts new file mode 100644 index 00000000000..848d5c36646 --- /dev/null +++ b/apps/admin/tests/i18n-wiring.test.ts @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); + +function read(relativePath: string): string { + return fs.readFileSync(path.join(REPO_ROOT, relativePath), "utf8"); +} + +test("God Mode uses the shared Plane translation runtime", () => { + const packageJson = JSON.parse(read("apps/admin/package.json")) as { + dependencies?: Record; + }; + const coreProviders = read("apps/admin/providers/core.tsx"); + const translationProvider = read("apps/admin/providers/translation-provider.tsx"); + + assert.equal(packageJson.dependencies?.["@plane/i18n"], "workspace:*"); + assert.match(coreProviders, /AdminTranslationProvider/); + assert.match(translationProvider, /TranslationProvider/); + assert.match(translationProvider, /useTranslation/); +}); + +test("God Mode synchronizes the document language after translations initialize", () => { + const translationProvider = read("apps/admin/providers/translation-provider.tsx"); + + assert.match(translationProvider, /document\.documentElement\.lang\s*=\s*currentLocale/); +}); + +test("God Mode breadcrumb segments resolve existing shared translation keys", () => { + const labels = read("apps/admin/components/common/header/core.ts"); + const header = read("apps/admin/components/common/header/index.tsx"); + + assert.match(labels, /general:\s*"common\.general"/); + assert.match(labels, /authentication:\s*"workspace_settings\.settings\.members\.details\.authentication"/); + assert.match(labels, /workspace:\s*"common\.workspace"/); + assert.match(labels, /create:\s*"common\.create"/); + assert.match(header, /useTranslation/); + assert.match(header, /t\(translationKey\)/); + assert.match(header, /t\("settings"\)/); +}); + +test("God Mode does not add an English-placeholder Admin namespace", () => { + const localesRoot = path.join(REPO_ROOT, "packages/i18n/src/locales"); + const unexpected = fs + .readdirSync(localesRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(localesRoot, entry.name, "admin.json")) + .filter((localeFile) => fs.existsSync(localeFile)); + + assert.deepEqual(unexpected, []); +}); + +test("God Mode reuses shared translations for common fields and actions", () => { + const signIn = read("apps/admin/app/(all)/(home)/sign-in-form.tsx"); + const setup = read("apps/admin/components/instance/setup-form.tsx"); + const general = read("apps/admin/app/(all)/(dashboard)/general/form.tsx"); + const newUserPopup = read("apps/admin/components/common/new-user-popup.tsx"); + + assert.match(signIn, /t\("auth\.common\.email\.label"\)/); + assert.match(signIn, /t\("auth\.common\.password\.label"\)/); + assert.match(setup, /t\("first_name"\)/); + assert.match(setup, /t\("last_name"\)/); + assert.match(setup, /t\("common\.continue"\)/); + assert.match(general, /t\("save_changes"\)/); + assert.match(general, /t\("saving"\)/); + assert.match(newUserPopup, /t\("create_workspace"\)/); + assert.match(newUserPopup, /t\("close"\)/); + + const importantSurfaceClassCount = [signIn, setup].reduce( + (count, source) => count + (source.match(/\bbg-surface-1!/g)?.length ?? 0), + 0 + ); + assert.equal(importantSurfaceClassCount, 8); + assert.doesNotMatch(signIn, /!bg-surface-1/); + assert.doesNotMatch(setup, /!bg-surface-1/); + assert.match(newUserPopup, /Start your journey by creating your first workspace\./); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbf11e23d18..d5093420653 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -625,6 +625,9 @@ importers: '@plane/hooks': specifier: workspace:* version: link:../../packages/hooks + '@plane/i18n': + specifier: workspace:* + version: link:../../packages/i18n '@plane/propel': specifier: workspace:* version: link:../../packages/propel