diff --git a/i18next.config.ts b/i18next.config.ts index c72c0753..52fdd864 100644 --- a/i18next.config.ts +++ b/i18next.config.ts @@ -8,14 +8,23 @@ use(Backend) .use(initReactI18next) .init({ fallbackLng: "en", + load: "languageOnly", detection: { order: ["localStorage", "cookie"], caches: ["localStorage", "cookie"], }, ns: ["common"], + defaultNS: "common", interpolation: { escapeValue: false, }, + backend: { + loadPath: "/locales/{{lng}}/{{ns}}.json", + queryStringParams: { v: "2" }, + requestOptions: { + cache: "no-store", + }, + }, }); export default I18n; diff --git a/nginx.conf b/nginx.conf index aec8180d..35cc333f 100644 --- a/nginx.conf +++ b/nginx.conf @@ -69,6 +69,13 @@ server { expires 1y; add_header Cache-Control "public, immutable"; } + + # Locale JSON is not content-hashed. Do not SPA-fallback missing + # languages to index.html (i18next would parse HTML as translations). + location /locales/ { + add_header Cache-Control "no-cache"; + try_files $uri =404; + } # Health check endpoint location /health { diff --git a/src/components/VersionDiff/index.test.tsx b/src/components/VersionDiff/index.test.tsx index 0bdf14b4..a9b34f38 100644 --- a/src/components/VersionDiff/index.test.tsx +++ b/src/components/VersionDiff/index.test.tsx @@ -8,7 +8,7 @@ import VersionDiff from "./"; describe("VersionDiff Component", () => { test("renders an empty state when there are no hunks", () => { render(); - expect(screen.getByText("version_diff.no_line_changes")).toBeDefined(); + expect(screen.getByText("No line changes")).toBeDefined(); }); test("renders changed lines with line numbers", () => { diff --git a/src/components/VersionDiff/index.tsx b/src/components/VersionDiff/index.tsx index 1ab0a213..f555bd56 100644 --- a/src/components/VersionDiff/index.tsx +++ b/src/components/VersionDiff/index.tsx @@ -24,11 +24,15 @@ const signForType = (type: DiffLineType): string => { }; const VersionDiff: FC = ({ hunks }) => { - const { t } = useTranslation(["models"]); + const { t } = useTranslation(["models"], { useSuspense: false }); if (!hunks.length) { return ( -
{t("version_diff.no_line_changes")}
+
+ {t("models:version_diff.no_line_changes", { + defaultValue: "No line changes", + })} +
); } diff --git a/src/components/VersionsList/index.test.tsx b/src/components/VersionsList/index.test.tsx index b863eab2..70c11a98 100644 --- a/src/components/VersionsList/index.test.tsx +++ b/src/components/VersionsList/index.test.tsx @@ -79,7 +79,7 @@ describe("VersionsList Component", () => { render( {}} />); expect(screen.getByText("+1")).toBeDefined(); expect(screen.getByText("-1")).toBeDefined(); - expect(screen.getByText("version_diff.initial_version")).toBeDefined(); + expect(screen.getByText("Initial version")).toBeDefined(); }); test("previews restore then confirms", () => { @@ -87,9 +87,7 @@ describe("VersionsList Component", () => { render(); fireEvent.click(screen.getByText("common:words.restore")); expect(mockOnRestore).not.toHaveBeenCalled(); - expect( - screen.getByText("version_diff.restore_preview_title") - ).toBeDefined(); + expect(screen.getByText("Restore this version?")).toBeDefined(); fireEvent.click(screen.getByTestId("confirm-restore")); expect(mockOnRestore).toHaveBeenCalledWith( mockVersions[1].checksum, diff --git a/src/components/VersionsList/index.tsx b/src/components/VersionsList/index.tsx index 6a14824f..9031a3db 100644 --- a/src/components/VersionsList/index.tsx +++ b/src/components/VersionsList/index.tsx @@ -35,6 +35,41 @@ import type { TableProps } from "antd"; const { Title } = Typography; const EMPTY_IDS: string[] = []; +const VERSION_DIFF_EN = { + changes: "Changes", + changed_files: "Files", + initial_version: "Initial version", + no_file_changes: "No file changes", + no_line_changes: "No line changes", + compared_to_previous: "Line changes from the previous version", + added: "Added", + removed: "Removed", + modified: "Modified", + unchanged: "Unchanged", + compare: "Compare", + compare_from: "From (base)", + compare_to: "To", + compare_caption: "Line changes between the selected versions", + same_version: "Select two different versions", + restore_preview_title: "Restore this version?", + restore_preview_caption: "Line changes versus the current version", + confirm_restore: "Restore", + unknown_user: "Unknown user", + restored_from: "Restored from {{time}}", +} as const; + +type VersionDiffKey = keyof typeof VERSION_DIFF_EN; + +const versionDiffT = ( + t: (key: string, options?: Record) => string, + key: VersionDiffKey, + options?: Record +) => + t(`models:version_diff.${key}`, { + defaultValue: VERSION_DIFF_EN[key], + ...options, + }); + type VersionUser = { display_name?: string | null; avatarUrl?: string | null; @@ -65,13 +100,13 @@ const VersionFileChanges: FC<{ caption: string; loading?: boolean; }> = ({ diff, caption, loading }) => { - const { t } = useTranslation(["models"]); + const { t } = useTranslation(["models"], { useSuspense: false }); const files = diff?.changedFiles ?? []; if (!files.length) { return (
- {t("version_diff.no_file_changes")} + {versionDiffT(t, "no_file_changes")}
); } @@ -85,7 +120,7 @@ const VersionFileChanges: FC<{ {value} - {t(`version_diff.${file.kind}`)} + {versionDiffT(t, file.kind)} ), @@ -120,7 +155,7 @@ const VersionFileChanges: FC<{ }; const VersionsList: FC = ({ onRestore, branch }) => { - const { t } = useTranslation(["models", "common"]); + const { t } = useTranslation(["models", "common"], { useSuspense: false }); const [restoreTarget, setRestoreTarget] = useState(null); const [compareFromId, setCompareFromId] = useState(); const [compareToId, setCompareToId] = useState(); @@ -281,7 +316,7 @@ const VersionsList: FC = ({ onRestore, branch }) => { if (diff.isInitial) { return ( - {t("version_diff.initial_version")} + {versionDiffT(t, "initial_version")} ); } @@ -289,7 +324,7 @@ const VersionsList: FC = ({ onRestore, branch }) => { if (!diff.changedFiles.length) { return ( - {t("version_diff.no_file_changes")} + {versionDiffT(t, "no_file_changes")} ); } @@ -299,7 +334,7 @@ const VersionsList: FC = ({ onRestore, branch }) => { +{diff.addedLines} -{diff.removedLines} - {t("version_diff.changed_files")}: {diff.changedFiles.length} + {versionDiffT(t, "changed_files")}: {diff.changedFiles.length} ); @@ -333,8 +368,7 @@ const VersionsList: FC = ({ onRestore, branch }) => { {restoredFrom && ( - {t("models:version_diff.restored_from", { - defaultValue: "Restored from {{time}}", + {versionDiffT(t, "restored_from", { time: formatTime( restoredFrom.created_at, "YYYY-MM-DD HH:mm" @@ -353,7 +387,7 @@ const VersionsList: FC = ({ onRestore, branch }) => { key: "created_at", render: (value, record) => { const user = record.user as VersionUser | null | undefined; - const name = authorLabel(user, t("version_diff.unknown_user")); + const name = authorLabel(user, versionDiffT(t, "unknown_user")); return (
@@ -377,7 +411,7 @@ const VersionsList: FC = ({ onRestore, branch }) => { }, }, { - title: t("version_diff.changes"), + title: versionDiffT(t, "changes"), key: "changes", render: (_, record) => renderChangeSummary(diffsById.get(record.id)), }, @@ -410,8 +444,8 @@ const VersionsList: FC = ({ onRestore, branch }) => { diff={diff} caption={ diff?.isInitial - ? t("version_diff.initial_version") - : t("version_diff.compared_to_previous") + ? versionDiffT(t, "initial_version") + : versionDiffT(t, "compared_to_previous") } loading={fetching} /> @@ -434,11 +468,11 @@ const VersionsList: FC = ({ onRestore, branch }) => { {totalCount > 1 && ( - {t("version_diff.compare")} + {versionDiffT(t, "compare")} = ({ onRestore, branch }) => { )} {compareFromId && compareToId && compareFromId === compareToId && ( -
{t("version_diff.same_version")}
+
+ {versionDiffT(t, "same_version")} +
)} {compareDiff && ( )} @@ -485,7 +521,7 @@ const VersionsList: FC = ({ onRestore, branch }) => { /> setRestoreTarget(null)} zIndex={2000} width={920} @@ -499,13 +535,13 @@ const VersionsList: FC = ({ onRestore, branch }) => { onClick={confirmRestore} data-testid="confirm-restore" > - {t("version_diff.confirm_restore")} + {versionDiffT(t, "confirm_restore")} , ]} > diff --git a/tests.setup.ts b/tests.setup.ts index 881fb520..44d166cd 100644 --- a/tests.setup.ts +++ b/tests.setup.ts @@ -31,7 +31,8 @@ vi.mock("react-i18next", () => ({ // this mock makes sure any components using the translate hook can use it without a warning being shown useTranslation: () => { return { - t: (str: string) => str, + t: (str: string, options?: { defaultValue?: string }) => + options?.defaultValue ?? str, i18n: { changeLanguage: () => new Promise(() => {}), language: "en",