From beb5564223f23e900477955dce244fda72f386c9 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 11 Nov 2026 12:48:23 +0100 Subject: [PATCH] fix(i18n): apply UI locale to real date picker controls --- .../scripts/verify-job-date-widgets.mjs | 129 ++++++++++++++++++ job-tracker-ui/src/App.tsx | 17 ++- job-tracker-ui/src/ClientApp.tsx | 15 +- .../src/DateLocalizationProvider.tsx | 15 ++ .../src/date-picker-localisation.test.tsx | 43 ++++++ 5 files changed, 202 insertions(+), 17 deletions(-) create mode 100644 job-tracker-ui/scripts/verify-job-date-widgets.mjs create mode 100644 job-tracker-ui/src/DateLocalizationProvider.tsx create mode 100644 job-tracker-ui/src/date-picker-localisation.test.tsx diff --git a/job-tracker-ui/scripts/verify-job-date-widgets.mjs b/job-tracker-ui/scripts/verify-job-date-widgets.mjs new file mode 100644 index 00000000..302c06e7 --- /dev/null +++ b/job-tracker-ui/scripts/verify-job-date-widgets.mjs @@ -0,0 +1,129 @@ +// Exported application and real MUI pickers with synthetic API fixtures only. +import { chromium, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText; +const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`); +const out = path.resolve(import.meta.dirname, '../../docs/i18n/evidence/date-widgets'); +fs.mkdirSync(out, { recursive: true }); +const report = { journeys: [], captures: [], writes: [], errors: [], unavailableReads: [], blockedExternal: [] }; +const browser = await chromium.launch(); +try { + const zones = process.env.A3_PILOT ? ['America/Los_Angeles'] : ['America/Los_Angeles', 'Europe/Oslo', 'Pacific/Kiritimati']; + for (const zone of zones) for (const language of ['en', 'nb']) { + const mobile = language === 'nb'; + const copy = translations[language]; + const name = `${zone.replaceAll('/', '-')}-${language}`; + console.log(`Checking ${name}`); + const context = await browser.newContext({ timezoneId: zone, viewport: { width: mobile ? 390 : 1440, height: 1000 }, hasTouch: mobile, isMobile: mobile, reducedMotion: 'reduce' }); + const page = await context.newPage(); + page.on('pageerror', error => report.errors.push(error.message)); + await page.clock.setFixedTime(new Date('2026-11-23T00:30:00Z')); + await page.addInitScript(({ language, mobile }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mobile ? 'dark' : 'light'); }, { language, mobile }); + const job = { id: 42, version: 7, companyId: 1, company: { id: 1, name: 'Synthetic school' }, jobTitle: 'Teacher', status: 'Applied', dateApplied: '2026-11-23', deadline: '2026-11-23', followUpAt: '2026-11-23', responseReceived: true, responseDate: '2026-11-23', isDeleted: false }; + const writes = []; + let detailReads = 0; + await page.route('**/*', route => { + const request = route.request(), url = new URL(request.url()); + if (url.origin !== 'http://localhost:4338' && !(url.origin === 'http://localhost:5202' && url.pathname.startsWith('/api/'))) { report.blockedExternal.push(url.origin); return route.abort(); } + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (request.method() !== 'GET') { + assert.ok((request.method() === 'PUT' && url.pathname === '/api/jobapplications/42') || (request.method() === 'POST' && url.pathname === '/api/jobapplications'), `Unexpected mutation ${request.method()} ${url.pathname}`); + const item = { zone, language, method: request.method(), body: request.postDataJSON() }; + writes.push(item); report.writes.push(item); + return route.fulfill({ json: request.method() === 'POST' ? { id: 43 } : {} }); + } + if (url.pathname === '/api/jobapplications/42') detailReads++; + const fixtures = { + '/api/auth/config': { requireAuth: true }, + '/api/auth/me': { id: 'date-owner', sessionContext: 'date-session', displayName: 'Synthetic teacher', roles: [], uiLanguage: language === 'nb' ? 'nb-NO' : 'en' }, + '/api/companies': [job.company], + '/api/jobapplications': { items: [job], total: 1, page: 1, pageSize: 15 }, + '/api/jobapplications/42': job, + '/api/jobapplications/duplicate-check': { hasDuplicates: false, matches: [] }, + '/api/jobapplications/reminders': [], + '/api/notifications/unread-count': { count: 0 }, + }; + if (!Object.hasOwn(fixtures, url.pathname)) report.unavailableReads.push(url.pathname); + return route.fulfill({ status: Object.hasOwn(fixtures, url.pathname) ? 200 : 404, json: fixtures[url.pathname] ?? {} }); + }); + const dayName = language === 'nb' ? 'Dag' : 'Day'; + const assertDay = async (group, day, lang = language) => { + await expect(group.getByRole('spinbutton', { name: lang === 'nb' ? 'Dag' : 'Day', exact: true })).toHaveText(String(day)); + await expect(group.getByRole('spinbutton', { name: lang === 'nb' ? 'År' : 'Year', exact: true })).toHaveText('2026'); + }; + const choose24 = async group => { + await group.getByRole('button').click(); + const calendar = page.getByRole('dialog').filter({ has: page.getByRole('grid') }); + await calendar.getByRole('gridcell', { name: '24', exact: true }).click(); + if (mobile) await calendar.getByRole('button', { name: 'OK', exact: true }).click(); + await expect(page.getByRole('grid')).toHaveCount(0); + await assertDay(group, 24); + }; + const capture = async state => { + const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze(); + report.captures.push({ zone, language, state, violations: scan.violations }); + assert.deepEqual(scan.violations, []); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(out, `${state}-${name}.png`) }); + }; + await page.goto('http://localhost:4338/jobs'); + await page.getByRole('button', { name: `${copy.jobTableActions}: Teacher`, exact: true }).click(); + await page.getByRole('menuitem', { name: copy.jobTableEdit, exact: true }).click(); + const initialEdit = page.getByRole('dialog', { name: copy.editJobTitle, exact: true }); + await expect(initialEdit).toBeVisible(); + const titleId = await initialEdit.getAttribute('aria-labelledby'); + assert.ok(titleId); + const edit = page.locator(`[role="dialog"][aria-labelledby="${titleId}"]`); + const deadline = edit.getByRole('group', { name: copy.editJobDeadline, exact: true }); + await assertDay(deadline, 23); + await edit.getByRole('textbox', { name: copy.editJobJobTitle, exact: true }).fill('Retain teacher draft'); + const reads = detailReads; + await page.evaluate(value => { localStorage.setItem('uiLanguage', value); window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: value })); }, language === 'nb' ? 'en' : 'nb'); + const otherCopy = translations[language === 'nb' ? 'en' : 'nb']; + await assertDay(edit.getByRole('group', { name: otherCopy.editJobDeadline, exact: true }), 23, language === 'nb' ? 'en' : 'nb'); + await page.evaluate(value => { localStorage.setItem('uiLanguage', value); window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: value })); }, language); + await expect(edit.getByRole('textbox', { name: copy.editJobJobTitle, exact: true })).toHaveValue('Retain teacher draft'); + assert.equal(detailReads, reads); assert.equal(writes.length, 0); + for (const label of [copy.editJobAppliedOn, copy.editJobStatusChangedOn, copy.editJobReplyReceivedOn, copy.editJobFollowUpOn, copy.editJobDeadline]) await choose24(edit.getByRole('group', { name: label, exact: true })); + await capture('edit'); + await edit.getByRole('button', { name: copy.save, exact: true }).click(); + await expect(edit).toBeHidden(); + assert.equal(writes.length, 1); + assert.deepEqual({ expectedVersion: writes[0].body.expectedVersion, dateApplied: writes[0].body.dateApplied, deadline: writes[0].body.deadline, followUpAt: writes[0].body.followUpAt, responseDate: writes[0].body.responseDate }, { expectedVersion: 7, dateApplied: '2026-11-24', deadline: '2026-11-24', followUpAt: '2026-11-24', responseDate: '2026-11-24' }); + await page.getByRole('button', { name: copy.addJob, exact: true }).click(); + const initialAdd = page.getByRole('dialog'); + await expect(initialAdd).toBeVisible(); + const addTitleId = await initialAdd.getAttribute('aria-labelledby'); + assert.ok(addTitleId); + const add = page.locator(`[role="dialog"][aria-labelledby="${addTitleId}"]`); + await add.getByRole('button', { name: copy.addJobModalEnterManually, exact: true }).click(); + const today = await page.evaluate(() => new Date().getDate()); + await expect(add.getByRole('group', { name: copy.addJobModalDateApplied, exact: true }).getByRole('spinbutton', { name: dayName, exact: true })).toHaveText(String(today)); + await add.getByRole('combobox', { name: copy.company, exact: true }).fill('Synthetic'); + await page.getByRole('option', { name: 'Synthetic school', exact: true }).click(); + await add.getByRole('textbox', { name: copy.addJobModalJobTitle, exact: true }).fill('Teacher application'); + await choose24(add.getByRole('group', { name: copy.addJobModalDateApplied, exact: true })); + const addDeadline = add.getByRole('group', { name: copy.addJobModalDeadline, exact: true }); + // Empty deadline opens the current fixed month, November2026. + await choose24(addDeadline); + await capture('add'); + await add.getByRole('button', { name: copy.continue, exact: true }).click(); + for (let step = 0; step < 3; step++) await add.getByRole('button', { name: copy.skipAndContinue, exact: true }).click(); + await add.getByRole('button', { name: copy.createJob, exact: true }).click(); + await expect(add).toBeHidden(); + assert.equal(writes.length, 2); + assert.equal(writes[1].body.dateApplied, '2026-11-24'); assert.equal(writes[1].body.deadline, '2026-11-24'); + report.journeys.push({ zone, language, width: mobile ? 390 : 1440, touch: mobile, theme: mobile ? 'dark' : 'light', sevenCalendarSelections: 'PASS', localeDraftRetention: 'PASS', localToday: today, fixtureSaveCreate: 'PASS' }); + await context.close(); + } + assert.equal(report.journeys.length, zones.length * 2); assert.deepEqual(report.errors, []); + console.log(`PASS ${report.journeys.length} real-widget journeys, ${report.captures.length} axe captures, ${report.writes.length} intercepted fixture writes`); +} finally { + fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify(report, null, 2)); + await browser.close(); +} diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index ac441ee3..fdac85e1 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -45,6 +45,7 @@ import { AccountPlanProvider } from "./accountPlan"; import AppShell, { NavItem } from "./layout/AppShell"; import { getThemeModePref, setThemeModePref, subscribeToThemePreferenceChanges, ThemeModePref } from "./themePrefs"; import JobTrackerThemeProvider from "./ThemeProvider"; +import DateLocalizationProvider from "./DateLocalizationProvider"; import { getSessionContext, isSessionInvalidated, SESSION_INVALIDATED_EVENT } from "./sessionBoundary"; const AddJobModal = lazy(() => import("./components/AddJobModal")); @@ -563,13 +564,15 @@ function Application() { - - - - - - - + + + + + + + + + diff --git a/job-tracker-ui/src/ClientApp.tsx b/job-tracker-ui/src/ClientApp.tsx index 07314226..5875da29 100644 --- a/job-tracker-ui/src/ClientApp.tsx +++ b/job-tracker-ui/src/ClientApp.tsx @@ -1,20 +1,15 @@ "use client"; -import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; -import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; - import App from "./App"; import ErrorBoundary from "./components/ErrorBoundary"; import { I18nProvider } from "./i18n/I18nProvider"; export default function ClientApp() { return ( - - - - - - - + + + + + ); } diff --git a/job-tracker-ui/src/DateLocalizationProvider.tsx b/job-tracker-ui/src/DateLocalizationProvider.tsx new file mode 100644 index 00000000..96ca1a11 --- /dev/null +++ b/job-tracker-ui/src/DateLocalizationProvider.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; +import { nbNO } from "@mui/x-date-pickers/locales"; +import { enUS, nb } from "date-fns/locale"; +import { useI18n } from "./i18n/I18nProvider"; + +export default function DateLocalizationProvider({ children }: { children: ReactNode }) { + const { language } = useI18n(); + return {children}; +} diff --git a/job-tracker-ui/src/date-picker-localisation.test.tsx b/job-tracker-ui/src/date-picker-localisation.test.tsx new file mode 100644 index 00000000..266334de --- /dev/null +++ b/job-tracker-ui/src/date-picker-localisation.test.tsx @@ -0,0 +1,43 @@ +import React, { useState } from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import DateLocalizationProvider from "./DateLocalizationProvider"; +import { I18nProvider, useI18n } from "./i18n/I18nProvider"; + +function Form() { + const { setLocalLanguage } = useI18n(); + const [date, setDate] = useState(new Date(2026, 10, 23)); + return <> + + + + + ; +} + +beforeEach(() => localStorage.clear()); + +test("Bokmål localizes date sections, calendar controls and displayed format", () => { + localStorage.setItem("uiLanguage", "nb"); + render(
); + expect(screen.getByRole("spinbutton", { name: "Dag" })).toHaveTextContent("23"); + expect(screen.getByRole("spinbutton", { name: "Måned" })).toHaveTextContent("11"); + expect(screen.getByRole("spinbutton", { name: "År" })).toHaveTextContent("2026"); + expect(screen.getByRole("button", { name: /^Velg dato, valgt dato er/ })).toBeVisible(); + expect(screen.getByRole("group", { name: "Deadline" }).querySelector("input")).toHaveValue("23.11.2026"); +}); + +test("live UI language changes retain the selected day and unsaved form draft", () => { + render(); + const title = screen.getByRole("textbox", { name: "Unsaved title" }); + fireEvent.change(title, { target: { value: "Retain this draft" } }); + expect(screen.getByRole("spinbutton", { name: "Day" })).toHaveTextContent("23"); + fireEvent.click(screen.getByRole("button", { name: "Bokmål" })); + expect(screen.getByRole("spinbutton", { name: "Dag" })).toHaveTextContent("23"); + expect(title).toHaveValue("Retain this draft"); + fireEvent.click(screen.getByRole("button", { name: "English" })); + expect(screen.getByRole("spinbutton", { name: "Day" })).toHaveTextContent("23"); + expect(screen.getByRole("button", { name: /^Choose date, selected date is/ })).toBeVisible(); + expect(title).toHaveValue("Retain this draft"); +});