From 9bfc51b067c4da4164b84578f5dacf452125ba62 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 4 Nov 2026 09:22:59 +0100 Subject: [PATCH] fix(i18n): localize singular matching job counts --- job-tracker-ui/src/components/JobTable.tsx | 8 ++++--- job-tracker-ui/src/i18n/translations.ts | 2 ++ .../src/jobs-view-integration.test.tsx | 24 +++++++++++++++---- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 80df0f1c..1dd38876 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -458,6 +458,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col const statusOptions = ["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; const visibleDesktopColumns = 6 + Number(columns.status) + Number(columns.dateApplied) + Number(columns.daysSince) + Number(columns.jobUrl); const selectedCompanyIsLoading = companyFilterId !== "All" && !companies.some((company) => company.id === companyFilterId); + const matchingCount = boardView ? boardCount + : jobsResource.loading || jobsResource.refreshing || jobsResource.error ? null : total; const densityControl = ( {t("listDensity")} @@ -484,9 +486,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col ))} - {boardView - ? boardCount !== null ? t("jobsMatchingCount", { count: boardCount }) : "" - : jobsResource.loading || jobsResource.refreshing || jobsResource.error ? "" : t("jobsMatchingCount", { count: total })} + {matchingCount === null ? "" : matchingCount === 1 + ? t("jobsMatchingSingle", { count: matchingCount }) + : t("jobsMatchingCount", { count: matchingCount })} ) : null} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 3fbd3fc0..1230e169 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -219,6 +219,7 @@ export const translations = { jobsTableView: "Table", jobsBoardView: "Board", jobsMatchingCount: "{count} matching jobs", + jobsMatchingSingle: "{count} matching job", companies: "Companies", trash: "Trash", settings: "Settings", @@ -3436,6 +3437,7 @@ export const translations = { jobsTableView: "Tabell", jobsBoardView: "Tavle", jobsMatchingCount: "{count} jobber samsvarer", + jobsMatchingSingle: "{count} jobb samsvarer", companies: "Selskaper", trash: "Papirkurv", settings: "Innstillinger", diff --git a/job-tracker-ui/src/jobs-view-integration.test.tsx b/job-tracker-ui/src/jobs-view-integration.test.tsx index 9149d6df..6f4dc8e2 100644 --- a/job-tracker-ui/src/jobs-view-integration.test.tsx +++ b/job-tracker-ui/src/jobs-view-integration.test.tsx @@ -7,7 +7,7 @@ import JobTable from "./components/JobTable"; import { ConfirmProvider } from "./confirm"; import { PromptProvider } from "./prompt"; import { ToastProvider } from "./toast"; -import { I18nProvider } from "./i18n/I18nProvider"; +import { I18nProvider, useI18n } from "./i18n/I18nProvider"; import { translations } from "./i18n/translations"; import { getUserScopedStorageKey } from "./auth"; const t = translations.en; @@ -28,6 +28,10 @@ function show(query = "", mode: "jobs" | "trash" = "jobs") { return render(); } const reads = (path: string) => mockedApi.get.mock.calls.filter(([url]) => url === path); +function LanguageSwitch() { + const { language, setLocalLanguage } = useI18n(); + return ; +} const ids = (selector: string, attribute: string) => Array.from(document.querySelectorAll(selector), el => Number(el.getAttribute(attribute))).sort((a, b) => a - b); beforeEach(() => { localStorage.clear(); jest.clearAllMocks(); @@ -38,6 +42,18 @@ beforeEach(() => { }); }); +test.each((['en', 'nb'] as const).flatMap(language => ['table', 'board'].flatMap(view => [0, 1, 2].map(count => [language, view, count] as const))))('localised matching count: %s %s %s retains membership when language changes', async (language, view, count) => { + localStorage.setItem('uiLanguage', language); + mockedApi.get.mockImplementation(url => Promise.resolve({ data: url === '/companies' ? [jobs[0].company] : url === '/jobapplications/board' ? jobs.slice(0, count) : { items: jobs.slice(0, count), total: count, page: 1, pageSize: 15 } } as any)); + render(); + const label = (locale: 'en' | 'nb') => locale === 'en' ? `${count} matching ${count === 1 ? 'job' : 'jobs'}` : `${count} ${count === 1 ? 'jobb' : 'jobber'} samsvarer`; + await screen.findByText(label(language)); + fireEvent.click(screen.getByRole('button', { name: 'Change language' })); + await screen.findByText(label(language === 'en' ? 'nb' : 'en')); + expect(reads(view === 'board' ? '/jobapplications' : '/jobapplications/board')).toHaveLength(0); + expect(ids(view === 'board' ? '[data-kanban-card-id]' : '[data-job-row-id]', view === 'board' ? 'data-kanban-card-id' : 'data-job-row-id')).toEqual(jobs.slice(0, count).map(job => job.id)); +}); + test("Table/Board switches preserve query, complete membership, page, density and history without hidden reads", async () => { show("?q=Engineer&page=2&sortBy=jobTitle&sortDir=asc"); await screen.findByText("16–26 of 26"); @@ -72,14 +88,14 @@ test("Table/Board switches preserve query, complete membership, page, density an test.each(["?q=Engineer&status=Custom+review", "?q=absent"])("direct Board and both switch directions retain exact result membership: %s", async query => { show(`${query}&view=board`); const expected = query.includes("absent") ? [] : [2]; - await screen.findByText(`${expected.length} matching jobs`); + await screen.findByText(expected.length === 1 ? "1 matching job" : `${expected.length} matching jobs`); expect(ids("[data-kanban-card-id]", "data-kanban-card-id")).toEqual(expected); expect(reads("/jobapplications")).toHaveLength(0); fireEvent.click(screen.getByRole("link", { name: "Table" })); await waitFor(() => expect(reads("/jobapplications")).toHaveLength(1)); await waitFor(() => expect(ids("[data-job-row-id]", "data-job-row-id")).toEqual(expected)); fireEvent.click(screen.getByRole("link", { name: "Board" })); - await screen.findByText(`${expected.length} matching jobs`); + await screen.findByText(expected.length === 1 ? "1 matching job" : `${expected.length} matching jobs`); expect(ids("[data-kanban-card-id]", "data-kanban-card-id")).toEqual(expected); expect(reads("/jobapplications/board").every(([, config]) => (config?.params as { q?: string }).q === (expected.length ? "Engineer" : "absent"))).toBe(true); }); @@ -111,7 +127,7 @@ test("saved filters applied in Board reset the Table page and refresh only the a await screen.findByText("27 matching jobs"); fireEvent.click(screen.getByRole("button", { name: t.savedViewsTooltip })); fireEvent.click(await screen.findByText("Review shortlist")); - await screen.findByText("1 matching jobs"); + await screen.findByText("1 matching job"); expect(screen.getByLabelText("Route")).not.toHaveTextContent("page="); expect(ids("[data-kanban-card-id]", "data-kanban-card-id")).toEqual([2]); const before = reads("/jobapplications/board").length;