From 50f276c6c0728c07bc41a2820b8d8a236f0322d8 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 28 Aug 2026 20:06:01 +0200 Subject: [PATCH] feat(i18n): localise document editing controls --- .../src/application-assets.test.tsx | 20 ++++++++++++++++- .../src/components/ApplicationAssets.tsx | 22 +++++++++++++++---- .../src/components/RichTextField.tsx | 10 +++++---- job-tracker-ui/src/i18n/translations.ts | 16 ++++++++++++++ 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/job-tracker-ui/src/application-assets.test.tsx b/job-tracker-ui/src/application-assets.test.tsx index 74325b4..834728a 100644 --- a/job-tracker-ui/src/application-assets.test.tsx +++ b/job-tracker-ui/src/application-assets.test.tsx @@ -66,7 +66,12 @@ function routeGet(overrides: Record = {}) { }); } -beforeEach(() => jest.clearAllMocks()); +beforeEach(() => { + window.localStorage.removeItem("uiLanguage"); + jest.clearAllMocks(); +}); + +afterEach(() => window.localStorage.removeItem("uiLanguage")); // ---------- CV ---------- @@ -185,6 +190,19 @@ test("an empty cover letter offers the template and an empty history", async () .toContain("Dear Hiring Manager"); }); +test("the empty cover-letter template and rich-text controls follow Bokmål UI language", async () => { + window.localStorage.setItem("uiLanguage", "nb"); + routeGet({ coverLetter: { text: null, currentVersion: 0, versions: [], aiSuggestionCount: 0 } }); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /start fra mal/i })); + expect((screen.getByLabelText("Søknadsbrev") as HTMLTextAreaElement).value) + .toContain("Kjære rekrutteringsansvarlig"); + expect(screen.getByRole("button", { name: "Fet" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Lenke" })).toBeInTheDocument(); +}); + test("AI cover letter suggestions use the linked CV and require explicit apply", async () => { routeGet(); mockedApi.post.mockResolvedValue({ diff --git a/job-tracker-ui/src/components/ApplicationAssets.tsx b/job-tracker-ui/src/components/ApplicationAssets.tsx index 7ec1d78..b6090fc 100644 --- a/job-tracker-ui/src/components/ApplicationAssets.tsx +++ b/job-tracker-ui/src/components/ApplicationAssets.tsx @@ -253,7 +253,8 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) { // ---------- Cover letter ---------- -const TEMPLATE = `Dear Hiring Manager, +const COVER_LETTER_TEMPLATES = { + en: `Dear Hiring Manager, I am writing to apply for the [role] position at [company]. [One sentence on why this company, specifically.] @@ -264,10 +265,23 @@ In my current role I [the most relevant thing you have done, with a concrete out I would welcome the chance to talk it through. Kind regards, -[Your name]`; +[Your name]`, + nb: `Kjære rekrutteringsansvarlig, + +Jeg søker med dette på stillingen som [stilling] hos [selskap]. [Én konkret setning om hvorfor akkurat dette selskapet.] + +I min nåværende rolle har jeg [det mest relevante du har gjort, med et konkret resultat]. [Et annet eksempel som samsvarer med det stillingsannonsen etterspør.] + +[Hvorfor du ønsker denne stillingen, med egne ord.] + +Jeg ser frem til muligheten til å utdype dette i et intervju. + +Med vennlig hilsen +[Navnet ditt]`, +} as const; export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) { - const { t } = useI18n(); + const { language, t } = useI18n(); const { data, error, loading, setData, setError } = useAsset( () => applicationAssetsApi.coverLetter(jobId), [jobId], @@ -341,7 +355,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: diff --git a/job-tracker-ui/src/components/RichTextField.tsx b/job-tracker-ui/src/components/RichTextField.tsx index 433e5c6..fe349a1 100644 --- a/job-tracker-ui/src/components/RichTextField.tsx +++ b/job-tracker-ui/src/components/RichTextField.tsx @@ -7,6 +7,7 @@ import FormatUnderlinedIcon from "@mui/icons-material/FormatUnderlined"; import LinkIcon from "@mui/icons-material/Link"; import { wrapSelection } from "../cvBuilder"; +import { useI18n } from "../i18n/I18nProvider"; // Lightweight rich text: a plain textarea plus a markdown toolbar. Storage stays plain text // (**bold**, *italic*, __underline__, [text](url)); the server renderer converts the same whitelist @@ -29,6 +30,7 @@ export default function RichTextField({ ariaLabel?: string; disabled?: boolean; }) { + const { t } = useI18n(); const ref = useRef(null); const apply = (before: string, after: string, placeholderText: string) => { @@ -53,10 +55,10 @@ export default function RichTextField({ return ( - {btn("Bold", , "**", "**", "bold text")} - {btn("Italic", , "*", "*", "italic text")} - {btn("Underline", , "__", "__", "underlined")} - {btn("Link", , "[", "](https://)", "link text")} + {btn(t("richTextBold"), , "**", "**", t("richTextBoldPlaceholder"))} + {btn(t("richTextItalic"), , "*", "*", t("richTextItalicPlaceholder"))} + {btn(t("richTextUnderline"), , "__", "__", t("richTextUnderlinePlaceholder"))} + {btn(t("richTextLink"), , "[", "](https://)", t("richTextLinkPlaceholder"))}