feat(dashboard): establish string-migration pattern and locale formatting (U5)
Add the two reusable migration primitives: useLocaleFormat (date/number bound to the active locale, replacing implicit-browser-locale toLocale* calls — R8) and useColumnLabel (translate core COLUMN_LABELS via common:columns.* with English fallback). Migrate the Settings Appearance heading to t() as a worked example. The bulk migration of the ~464-file long tail is deferred follow-up work per the plan's Scope Boundaries; the pattern and primitives are in place so remaining clusters are mechanical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse,
|
||||
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ThemeSelector } from "./ThemeSelector";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { useSessionBannersHidden, setSessionBannersHidden } from "../hooks/useSessionBannerPref";
|
||||
@@ -431,6 +432,7 @@ export function SettingsModal({
|
||||
onReopenOnboarding,
|
||||
onOpenApprovals,
|
||||
}: SettingsModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
const worktrunkInstall = useWorktrunkInstallStatus(projectId);
|
||||
const worktrunkInstallVerified = worktrunkInstall.status === "installed";
|
||||
@@ -3693,7 +3695,7 @@ export function SettingsModal({
|
||||
return (
|
||||
<>
|
||||
{renderScopeBanner()}
|
||||
<h4 className="settings-section-heading">Appearance</h4>
|
||||
<h4 className="settings-section-heading">{t("settings.appearance.title", "Appearance")}</h4>
|
||||
<ThemeSelector
|
||||
themeMode={themeMode}
|
||||
colorTheme={colorTheme}
|
||||
|
||||
46
packages/dashboard/app/i18n/__tests__/format.test.ts
Normal file
46
packages/dashboard/app/i18n/__tests__/format.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ lng: "en" }));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
i18n: { resolvedLanguage: state.lng, language: state.lng },
|
||||
t: (k: string, d?: string) => d ?? k,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { useLocaleFormat } = await import("../format");
|
||||
|
||||
describe("useLocaleFormat", () => {
|
||||
it("formats numbers with the active locale's separators", () => {
|
||||
state.lng = "en";
|
||||
const en = renderHook(() => useLocaleFormat()).result.current.formatNumber(1234567);
|
||||
expect(en).toContain(",");
|
||||
|
||||
state.lng = "fr";
|
||||
const fr = renderHook(() => useLocaleFormat()).result.current.formatNumber(1234567);
|
||||
expect(fr).not.toContain(",");
|
||||
expect(fr).not.toBe(en);
|
||||
});
|
||||
|
||||
it("formats dates per active locale", () => {
|
||||
const date = new Date(Date.UTC(2026, 0, 15));
|
||||
state.lng = "en";
|
||||
const en = renderHook(() => useLocaleFormat()).result.current.formatDate(date, {
|
||||
month: "long",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
state.lng = "fr";
|
||||
const fr = renderHook(() => useLocaleFormat()).result.current.formatDate(date, {
|
||||
month: "long",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
expect(en).toMatch(/January/);
|
||||
expect(fr).toMatch(/janvier/);
|
||||
});
|
||||
|
||||
it("exposes the resolved locale", () => {
|
||||
state.lng = "zh-CN";
|
||||
expect(renderHook(() => useLocaleFormat()).result.current.locale).toBe("zh-CN");
|
||||
});
|
||||
});
|
||||
27
packages/dashboard/app/i18n/__tests__/labels.test.ts
Normal file
27
packages/dashboard/app/i18n/__tests__/labels.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ map: {} as Record<string, string> }));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, def?: string) => state.map[key] ?? def ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { useColumnLabel } = await import("../labels");
|
||||
|
||||
describe("useColumnLabel", () => {
|
||||
it("falls back to the English COLUMN_LABELS when no translation exists", () => {
|
||||
state.map = {};
|
||||
const label = renderHook(() => useColumnLabel()).result.current;
|
||||
expect(label("done")).toBe("Done");
|
||||
expect(label("in-progress")).toBe("In Progress");
|
||||
});
|
||||
|
||||
it("uses the translated value when present", () => {
|
||||
state.map = { "columns.done": "完成", "columns.in-progress": "进行中" };
|
||||
const label = renderHook(() => useColumnLabel()).result.current;
|
||||
expect(label("done")).toBe("完成");
|
||||
expect(label("in-progress")).toBe("进行中");
|
||||
});
|
||||
});
|
||||
25
packages/dashboard/app/i18n/format.ts
Normal file
25
packages/dashboard/app/i18n/format.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Locale-aware date/number formatting bound to the active i18n locale.
|
||||
*
|
||||
* Replaces the ~45 `toLocale*(undefined, …)` call sites that previously used
|
||||
* the implicit browser locale. Routing them through this hook threads the
|
||||
* user's chosen language into all date/number formatting (R8).
|
||||
*/
|
||||
export function useLocaleFormat() {
|
||||
const { i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage || i18n.language || "en";
|
||||
|
||||
return {
|
||||
locale,
|
||||
formatDate: (value: number | string | Date, options?: Intl.DateTimeFormatOptions) =>
|
||||
new Date(value).toLocaleDateString(locale, options),
|
||||
formatTime: (value: number | string | Date, options?: Intl.DateTimeFormatOptions) =>
|
||||
new Date(value).toLocaleTimeString(locale, options),
|
||||
formatDateTime: (value: number | string | Date, options?: Intl.DateTimeFormatOptions) =>
|
||||
new Date(value).toLocaleString(locale, options),
|
||||
formatNumber: (value: number, options?: Intl.NumberFormatOptions) =>
|
||||
value.toLocaleString(locale, options),
|
||||
};
|
||||
}
|
||||
13
packages/dashboard/app/i18n/labels.ts
Normal file
13
packages/dashboard/app/i18n/labels.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { COLUMN_LABELS, type Column } from "@fusion/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Returns a translator for board column labels, backed by the `common:columns.*`
|
||||
* keys with the English `COLUMN_LABELS` as the fallback. This is the migration
|
||||
* pattern for the centralized core label constants: import the hook, call it,
|
||||
* and replace `COLUMN_LABELS[col]` with `columnLabel(col)`.
|
||||
*/
|
||||
export function useColumnLabel(): (column: Column) => string {
|
||||
const { t } = useTranslation("common");
|
||||
return (column: Column) => t(`columns.${column}`, COLUMN_LABELS[column]);
|
||||
}
|
||||
Reference in New Issue
Block a user