feat(cv): qualify templates and reliable local import processing

This commit is contained in:
cesnimda
2026-10-28 14:36:12 +01:00
parent 73ea888375
commit 3f4db8ca81
69 changed files with 2597 additions and 180 deletions
+2 -1
View File
@@ -263,10 +263,11 @@ export type CoverLetter = {
export type CoverLetterDocumentSettings = {
schemaVersion: 1;
designVersion?: number | null;
language?: "en" | "nb-NO";
senderName: string; senderTitle: string; senderEmail: string; senderPhone: string; senderLocation: string;
recipient: string; subject: string; date: string;
themeId: string; accentColor: string; bodyFont: string;
themeId: string; accentColor: string; bodyFont: string; headingFont?: string | null;
baseFontSizePt: number; lineHeight: number; pageMarginMm: number; pageSize: string;
sourceCvId?: number | null;
};
@@ -76,6 +76,26 @@ beforeEach(() => {
mockedApi.post.mockResolvedValue(source as any);
});
test.each(["en", "nb"] as const)("disabled CV worker upload gives actionable %s recovery without changing the profile", async language => {
window.localStorage.setItem("uiLanguage", language);
const strings = translations[language];
mockedApi.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 503, data: { code: "cv_worker_disabled", message: "Raw worker disabled message" } } });
renderPage();
await waitFor(() => expect(screen.getByLabelText(strings.profileCvContactFullName)).toBeEnabled());
const upload = document.querySelector<HTMLInputElement>('input[type="file"][accept*=".pdf"]')!;
fireEvent.change(upload, { target: { files: [new File(["Synthetic CV"], "synthetic.pdf", { type: "application/pdf" })] } });
const expected = language === "en"
? "CV importing is currently unavailable. Ask an administrator to enable CV processing, then try uploading again."
: "CV-import er ikke tilgjengelig nå. Be en administrator om å aktivere CV-behandling, og prøv deretter å laste opp på nytt.";
await failure(importView(), expected);
expect(importView().queryByText("Raw worker disabled message")).not.toBeInTheDocument();
expect(screen.getByLabelText(strings.profileCvContactFullName)).toHaveValue("Recorded owner");
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith("/profile-cv/import", expect.any(FormData), expect.any(Object));
expect(mockedApi.put).not.toHaveBeenCalled();
expect(readCount("/career/profile")).toBe(1);
});
test("failed history reads have a scoped Retry that preserves unsaved source fields", async () => {
const read = mockedApi.get.getMockImplementation()!;
let failHistory = true;
@@ -19,11 +19,13 @@ type Props = { settings: CoverLetterDocumentSettings; disabled: boolean; onChang
export function CoverLetterDocumentFields({ settings, disabled, onChange }: Props) {
const { t } = useI18n();
const [copy, setCopy] = useState<"contact" | "design" | null>(null);
const [choosingTemplate, setChoosingTemplate] = useState(false);
return <Stack spacing={1.5} sx={{ mb: 2 }}>
<Typography component="h4" variant="subtitle2">{t("letterDetails")}</Typography>
<Stack direction="row" gap={1} flexWrap="wrap">
<Button disabled={disabled} onClick={() => setCopy("contact")}>{t("letterCopyContact")}</Button>
<Button disabled={disabled} onClick={() => setCopy("design")}>{t("letterMatchDesign")}</Button>
<Button disabled={disabled} onClick={() => setChoosingTemplate(true)}>{t("letterChooseTemplate")}</Button>
</Stack>
<TextField select size="small" label={t("letterDocumentLanguage")} value={settings.language ?? "en"} disabled={disabled}
onChange={event => onChange({ ...settings, language: event.target.value as "en" | "nb-NO" })}>
@@ -36,10 +38,52 @@ export function CoverLetterDocumentFields({ settings, disabled, onChange }: Prop
onChange={event => onChange({ ...settings, [key]: event.target.value })} />)}
</Box>
{copy && <CopyLetterSnapshot kind={copy} settings={settings} onClose={() => setCopy(null)} onApply={next => { if (!disabled) onChange(next); setCopy(null); }} />}
{choosingTemplate && <LetterTemplatePicker settings={settings} disabled={disabled} onClose={() => setChoosingTemplate(false)} onApply={next => { if (!disabled) onChange(next); setChoosingTemplate(false); }} />}
</Stack>;
}
type ThemeDefaults = CvTheme & { bodyFont: string; baseFontSizePt: number; lineHeight: number };
function LetterTemplatePicker({ settings, disabled, onClose, onApply }: Omit<Props, "onChange"> & { onClose: () => void; onApply: (settings: CoverLetterDocumentSettings) => void }) {
const { t } = useI18n();
const id = useId();
const [themes, setThemes] = useState<CvTheme[]>([]);
const [selected, setSelected] = useState(settings.themeId);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let current = true;
setLoading(true); setFailed(false);
cvBuilderApi.themes().then(rows => {
if (current) setThemes(rows);
}).catch(() => { if (current) setFailed(true); }).finally(() => { if (current) setLoading(false); });
return () => { current = false; };
}, [attempt]);
const theme = themes.find(row => row.id === selected);
const ready = theme?.available && !!theme.bodyFont && !!theme.baseFontSizePt && !!theme.lineHeight && !!theme.pageMarginMm;
const apply = () => {
if (!ready || !theme || disabled) return;
onApply({ ...settings, designVersion: 2, themeId: theme.id, accentColor: theme.accent,
bodyFont: theme.bodyFont!, headingFont: theme.headingFont ?? theme.bodyFont!, baseFontSizePt: theme.baseFontSizePt!,
lineHeight: theme.lineHeight!, pageMarginMm: theme.pageMarginMm! });
};
return <Dialog open onClose={onClose} fullWidth maxWidth="sm" aria-labelledby={`${id}-title`} aria-describedby={`${id}-help`}>
<DialogTitle id={`${id}-title`}>{t("letterChooseTemplate")}</DialogTitle>
<DialogContent dividers><Stack spacing={2}>
<Typography id={`${id}-help`} variant="body2">{t("letterTemplateHelp")}</Typography>
{loading && <Typography role="status">{t("loading")}</Typography>}
{failed && <Alert severity="error" action={<Button disabled={disabled} onClick={() => setAttempt(value => value + 1)}>{t("retry")}</Button>}>{t("letterTemplateFailed")}</Alert>}
<TextField select fullWidth size="small" label={t("letterTemplate")} disabled={loading || failed || disabled}
value={themes.some(row => row.id === selected) ? selected : ""} onChange={event => setSelected(event.target.value)}>
{themes.map(row => <MenuItem key={row.id} value={row.id} disabled={!row.available}>{row.name ?? row.id}{row.available ? "" : ` — ${t("cvEditorPro")}`}</MenuItem>)}
</TextField>
{!loading && !failed && !ready && <Typography variant="body2">{t("letterTemplateUnavailable")}</Typography>}
</Stack></DialogContent>
<DialogActions><Button autoFocus onClick={onClose}>{t("cancel")}</Button><Button variant="contained" disabled={disabled || loading || failed || !ready} onClick={apply}>{t("letterTemplateApply")}</Button></DialogActions>
</Dialog>;
}
function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "contact" | "design"; settings: CoverLetterDocumentSettings; onClose: () => void; onApply: (settings: CoverLetterDocumentSettings) => void }) {
const { t } = useI18n();
const id = useId();
@@ -75,7 +119,8 @@ function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "conta
const source = variant.settings;
const density = source.density === "compact" ? 0.82 : source.density === "roomy" ? 1.18 : 1;
const margin = Math.min(28, Math.max(8, Math.round((source.pageMarginMm ?? theme.pageMarginMm) * density * 100) / 100));
return { ...settings, themeId: theme.id, accentColor: source.accentColor ?? theme.accent, bodyFont: source.bodyFont ?? theme.bodyFont,
return { ...settings, designVersion: 2, themeId: theme.id, accentColor: source.accentColor ?? theme.accent, bodyFont: source.bodyFont ?? theme.bodyFont,
headingFont: source.headingFont ?? theme.headingFont ?? theme.bodyFont,
baseFontSizePt: source.baseFontSizePt ?? theme.baseFontSizePt, lineHeight: source.lineHeight ?? theme.lineHeight,
pageMarginMm: margin, pageSize: source.pageSize ?? "a4", sourceCvId: selected };
};
@@ -210,7 +210,17 @@ export default function CropImageDialog({
<Box sx={{ px: 1 }}>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 700 }}>{t("cropDialogZoom")}</Typography>
<Slider min={1} max={3} step={0.01} value={zoom} onChange={(_, value) => setZoom(value as number)} />
<Slider aria-label={t("cropDialogZoom")} disabled={saving} min={1} max={3} step={0.01} value={zoom} onChange={(_, value) => setZoom(value as number)} />
<Typography variant="body2" color="text.secondary">{t("cropDialogKeyboardHelp")}</Typography>
{(["x", "y"] as const).map(axis => {
const min = rendered ? Math.min(0, CROPPER_SIZE - (axis === "x" ? rendered.width : rendered.height)) : 0;
const label = t(axis === "x" ? "cropDialogHorizontal" : "cropDialogVertical");
return <Box key={axis}>
<Typography variant="body2">{label}</Typography>
<Slider aria-label={label} disabled={saving || min === 0} min={min} max={0} step={1} value={position[axis]}
onChange={(_, value) => setPosition(previous => ({ ...previous, [axis]: clamp(value as number, min, 0) }))} />
</Box>;
})}
</Box>
</DialogContent>
<DialogActions>
@@ -33,7 +33,7 @@ export function getCvTemplateLabels(theme: CvTheme, language: string, t: (key: T
return { name: labels ? t(labels[0]) : theme.name, category: labels ? t(labels[1]) : theme.category, description: labels ? t(labels[2]) : theme.description };
}
export default function CvTemplateGallery({ themes, selectedId, onSelect, previewHeight = 170, showDescriptions = true, disabled = false }: Props) {
export default function CvTemplateGallery({ themes, selectedId, onSelect, previewHeight = 230, showDescriptions = true, disabled = false }: Props) {
const { t, language } = useI18n();
const id = useId();
const [atsOnly, setAtsOnly] = useState(true);
@@ -1,34 +1,64 @@
import React from "react";
import { Box } from "@mui/material";
import React, { useEffect, useRef, useState } from "react";
import { Box, Skeleton, Typography } from "@mui/material";
import { cvBuilderApi, CvTheme, getCvPageMetrics } from "../cvBuilder";
import { useI18n } from "../i18n/I18nProvider";
import { CvTheme } from "../cvBuilder";
// Only the server's synthetic template samples are cached, never a user's CV.
const samples = new Map<string, Promise<string>>();
function sample(themeId: string) {
let pending = samples.get(themeId);
if (!pending) {
pending = cvBuilderApi.themePreview(themeId).then(result => {
if (typeof result.html !== "string") throw new Error("Template preview unavailable");
return result.html;
}).catch(error => { samples.delete(themeId); throw error; });
samples.set(themeId, pending);
}
return pending;
}
export default function CvTemplateThumbnail({ theme, height = 150 }: { theme?: CvTheme; height?: number }) {
const accent = theme?.swatches?.[0] || "#3157d5";
const sidebar = theme?.swatches?.[1] || "#eef1f4";
const paper = theme?.swatches?.[2] || "#ffffff";
const layout = theme?.layout || "header-band";
const hasSidebar = layout === "sidebar-left" || layout === "sidebar-right";
const sidebarRight = layout === "sidebar-right";
const lines = [92, 72, 84, 61, 88, 76, 95, 67];
const { t } = useI18n();
const host = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(typeof IntersectionObserver === "undefined");
const [html, setHtml] = useState<{ id: string; value: string } | null>(null);
const [failed, setFailed] = useState(false);
const [width, setWidth] = useState(210 * height / 297);
const metrics = getCvPageMetrics("a4");
const scale = Math.min(width / metrics.widthPx, height / metrics.heightPx);
const body = <Box sx={{ p: "7%", minWidth: 0 }}>
<Box sx={{ width: "48%", height: 5, borderRadius: 2, bgcolor: accent, mb: 1 }} />
{lines.map((width, index) => <Box key={index} sx={{ width: `${width}%`, height: index === 4 ? 4 : 2.5, borderRadius: 2, bgcolor: index === 4 ? accent : "rgba(51,65,85,.24)", mt: index === 4 ? 1.25 : 0, mb: 0.7 }} />)}
</Box>;
const side = <Box sx={{ bgcolor: sidebar, p: "13% 12%", minWidth: 0 }}>
<Box sx={{ width: 24, height: 24, borderRadius: theme?.photoShape === "circle" ? "50%" : 1, bgcolor: "rgba(255,255,255,.78)", border: "1px solid rgba(15,23,42,.1)", mb: 1.2 }} />
{[66, 86, 54, 74, 62, 79].map((width) => <Box key={width} sx={{ width: `${width}%`, height: 2.5, borderRadius: 2, bgcolor: "rgba(30,41,59,.3)", mb: 0.8 }} />)}
</Box>;
useEffect(() => {
const element = host.current;
if (!element || typeof ResizeObserver === "undefined") return;
const resize = new ResizeObserver(() => setWidth(element.clientWidth));
resize.observe(element);
return () => resize.disconnect();
}, []);
return (
<Box aria-hidden sx={{ height, bgcolor: paper, border: "1px solid", borderColor: "divider", borderRadius: 1.5, overflow: "hidden", boxShadow: "0 8px 20px rgba(15,23,42,.08)", display: "flex", flexDirection: "column" }}>
{!hasSidebar && <Box sx={{ height: layout === "header-band" ? "25%" : 8, bgcolor: layout === "header-band" ? accent : paper, borderBottom: layout === "header-band" ? 0 : `2px solid ${accent}`, p: layout === "header-band" ? "6%" : 0 }}>
{layout === "header-band" && <><Box sx={{ width: "42%", height: 5, bgcolor: "rgba(255,255,255,.9)", mb: 0.75 }} /><Box sx={{ width: "30%", height: 2.5, bgcolor: "rgba(255,255,255,.65)" }} /></>}
</Box>}
<Box sx={{ flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: hasSidebar ? (sidebarRight ? "1fr 32%" : "32% 1fr") : "1fr" }}>
{hasSidebar && !sidebarRight ? side : null}{body}{hasSidebar && sidebarRight ? side : null}
</Box>
</Box>
);
useEffect(() => {
if (visible || !host.current || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) { setVisible(true); observer.disconnect(); }
}, { rootMargin: "200px" });
observer.observe(host.current);
return () => observer.disconnect();
}, [visible]);
useEffect(() => {
if (!visible || !theme?.id) return;
let alive = true;
setFailed(false);
sample(theme.id).then(value => { if (alive) setHtml({ id: theme.id, value }); })
.catch(() => { if (alive) setFailed(true); });
return () => { alive = false; };
}, [theme?.id, visible]);
const current = html?.id === theme?.id ? html?.value : null;
return <Box ref={host} aria-hidden sx={{ height, bgcolor: "grey.100", border: "1px solid", borderColor: "divider", borderRadius: 1, overflow: "hidden", display: "flex", justifyContent: "center", pointerEvents: "none" }}>
{current ? <Box sx={{ width: metrics.widthPx * scale, height: metrics.heightPx * scale, overflow: "hidden", bgcolor: "common.white" }}>
<iframe title={t("cvEditorTemplateSampleTitle")} srcDoc={current} sandbox="" tabIndex={-1}
style={{ border: 0, display: "block", width: metrics.widthPx, height: metrics.heightPx, transform: `scale(${scale})`, transformOrigin: "top left", pointerEvents: "none" }} />
</Box> : failed ? <Typography variant="caption" sx={{ p: 1 }}>{t("cvEditorPreviewUnavailable")}</Typography>
: <Skeleton variant="rectangular" width="100%" height="100%" />}
</Box>;
}
@@ -1,4 +1,5 @@
import React, { useEffect, useId, useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import CheckIcon from "@mui/icons-material/Check";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
@@ -34,6 +35,7 @@ export default function CvCustomizeTab({ mode, settings, update, themes, section
const [candidate, setCandidate] = useState<CvTheme | null>(null);
const activeTheme = themes.find(theme => theme.id === settings.themeId);
const supports = (setting: string) => !activeTheme?.supportedSettings?.length || activeTheme.supportedSettings.includes(setting);
const supportsPhoto = !!activeTheme && activeTheme.photoShape !== "none" && supports("photo");
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
const layout = settings.layout ?? activeTheme?.layout;
const selectTemplate = async (theme: CvTheme) => {
@@ -98,6 +100,13 @@ export default function CvCustomizeTab({ mode, settings, update, themes, section
</>}
</Stack>
{supportsPhoto && <Stack component="section" aria-labelledby={id + "-photo"} spacing={1}>
<Typography id={id + "-photo"} component="h3" variant="subtitle2">{t("cvEditorProfilePhoto")}</Typography>
<FormControlLabel control={<Switch checked={settings.showPhoto} onChange={event => update({ showPhoto: event.target.checked })} />} label={t("cvEditorShowPhoto")} />
<Typography variant="body2" color="text.secondary">{t(settings.includeProfileContact === false ? "cvEditorPhotoContactHelp" : "cvEditorPhotoHelp")}</Typography>
<Button component={RouterLink} to="/career?section=profile" sx={{ alignSelf: "flex-start" }}>{t("cvEditorManagePhoto")}</Button>
</Stack>}
<Accordion disableGutters elevation={0} sx={{ border: "1px solid", borderColor: "divider", "&:before": { display: "none" } }}>
<AccordionSummary id={id + "-advanced"} aria-controls={id + "-advanced-content"} expandIcon={<ExpandMoreIcon />}>
<Typography component="h3" variant="body2" sx={{ fontWeight: 600 }}>{t("cvEditorAdvancedSettings")}</Typography>
@@ -130,7 +139,6 @@ export default function CvCustomizeTab({ mode, settings, update, themes, section
</>}
</Stack>}
<Stack>
{supports("photo") && <FormControlLabel control={<Switch checked={settings.showPhoto} onChange={event => update({ showPhoto: event.target.checked })} />} label={t("cvEditorShowPhoto")} />}
{supports("icons") && <FormControlLabel control={<Switch checked={settings.showIcons} onChange={event => update({ showIcons: event.target.checked })} />} label={t("cvEditorContactIcons")} />}
</Stack>
</Stack></AccordionDetails>
@@ -1,7 +1,7 @@
import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { CoverLetterDocumentFields } from "./components/CoverLetterDocument";
import { CoverLetterDocumentFields, CoverLetterDocumentPreview } from "./components/CoverLetterDocument";
import { ApplicationCoverLetterSection } from "./components/ApplicationAssets";
import { CoverLetterDocumentSettings } from "./applicationWorkspace";
import { api } from "./api";
@@ -116,11 +116,93 @@ test("design copy resolves null overrides through actual theme defaults and effe
await screen.findByRole("region", { name: "Design snapshot" });
expect(onChange).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Use this copy" }));
expect(onChange).toHaveBeenCalledWith({ ...settings, accentColor: "#112233", bodyFont: "Georgia, 'Times New Roman', serif", baseFontSizePt: 11, lineHeight: 1.5, pageMarginMm: 13.12, sourceCvId: 3 });
expect(onChange).toHaveBeenCalledWith({ ...settings, designVersion: 2, accentColor: "#112233", bodyFont: "Georgia, 'Times New Roman', serif", headingFont: "Georgia, 'Times New Roman', serif", baseFontSizePt: 11, lineHeight: 1.5, pageMarginMm: 13.12, sourceCvId: 3 });
expect(mockedApi.get).not.toHaveBeenCalledWith("/career/profile");
expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled();
});
test("letter template chooser uses nine actual matching themes and preserves destination letter details", async () => {
const originalGet = mockedApi.get.getMockImplementation()!;
const themeRows = ["Modern", "Minimal", "Executive", "Technical", "ATS Classic", "Nordic", "Elegant", "Creative", "Code"].map(name => ({
id: name.toLowerCase().replace(/ /g, "-"), name, description: `${name} letter design`, available: name !== "Executive", requiresPro: name === "Executive",
accent: "#3157d5", bodyFont: "Arial, Helvetica, sans-serif", headingFont: "Georgia, 'Times New Roman', serif", baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 18,
}));
mockedApi.get.mockImplementation(((url: string) => url === "/cv/themes" ? Promise.resolve({ data: themeRows }) : originalGet(url)) as any);
const onChange = jest.fn();
render(wrap(<CoverLetterDocumentFields settings={{ ...settings, pageSize: "letter" }} disabled={false} onChange={onChange} />));
fireEvent.click(screen.getByRole("button", { name: "Choose letter template" }));
const select = await screen.findByRole("combobox", { name: "Letter template" });
await waitFor(() => expect(select).not.toHaveAttribute("aria-disabled", "true"));
fireEvent.mouseDown(select);
expect(await screen.findAllByRole("option")).toHaveLength(9);
expect(screen.getByRole("option", { name: /Executive/ })).toHaveAttribute("aria-disabled", "true");
fireEvent.click(screen.getByRole("option", { name: "Nordic" }));
expect(onChange).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Use this template" }));
expect(onChange).toHaveBeenCalledWith({ ...settings, pageSize: "letter", designVersion: 2, themeId: "nordic", accentColor: "#3157d5", bodyFont: "Arial, Helvetica, sans-serif", headingFont: "Georgia, 'Times New Roman', serif", baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 18 });
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("letter template load failures retry and cancel never changes the local letter", async () => {
const originalGet = mockedApi.get.getMockImplementation()!;
let attempts = 0;
mockedApi.get.mockImplementation(((url: string) => url === "/cv/themes" && ++attempts === 1 ? Promise.reject(new Error("Synthetic theme failure")) : originalGet(url)) as any);
const onChange = jest.fn();
render(wrap(<CoverLetterDocumentFields settings={settings} disabled={false} onChange={onChange} />));
fireEvent.click(screen.getByRole("button", { name: "Choose letter template" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load letter templates.");
expect(screen.getByRole("button", { name: "Use this template" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Use this template" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onChange).not.toHaveBeenCalled();
});
test("applying a letter template updates the existing letter preview using the current body without saving", async () => {
mockedApi.post.mockResolvedValue({ data: { html: '<main class="page">Actual letter preview</main>' } } as any);
function Draft() {
const [draft, setDraft] = React.useState(settings);
return <><CoverLetterDocumentFields settings={draft} disabled={false} onChange={setDraft} />
<CoverLetterDocumentPreview jobId={7} text="Current authored letter body" settings={draft} active /></>;
}
render(wrap(<Draft />));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole("button", { name: "Choose letter template" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Use this template" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "Use this template" }));
await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/jobapplications/7/cover-letter/preview', {
text: 'Current authored letter body', documentSettings: expect.objectContaining({ designVersion: 2, headingFont: "Georgia, 'Times New Roman', serif", recipient: settings.recipient, language: settings.language }),
}));
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.get).not.toHaveBeenCalledWith('/cv/themes/modern/preview');
});
test("disabled letter drafting cannot open the template chooser", () => {
const onChange = jest.fn();
render(wrap(<CoverLetterDocumentFields settings={settings} disabled onChange={onChange} />));
const chooser = screen.getByRole('button', { name: 'Choose letter template' });
expect(chooser).toBeDisabled();
fireEvent.click(chooser);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
expect(mockedApi.get).not.toHaveBeenCalled();
});
test("matching a customized CV copies heading density and paper but preserves letter language", async () => {
const originalGet = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation(((url: string) => url === "/cv/variants/3" ? Promise.resolve({ data: { ...variant, settings: {
...variant.settings, headingFont: "Georgia, 'Times New Roman', serif", bodyFont: "Arial, Helvetica, sans-serif", accentColor: "#abcdef", pageMarginMm: 20, density: "roomy", pageSize: "letter",
} } }) : originalGet(url)) as any);
const onChange = jest.fn();
render(wrap(<CoverLetterDocumentFields settings={settings} disabled={false} onChange={onChange} />));
fireEvent.click(screen.getByRole("button", { name: "Match CV design" }));
await selectSource();
await screen.findByRole("region", { name: "Design snapshot" });
fireEvent.click(screen.getByRole("button", { name: "Use this copy" }));
expect(onChange).toHaveBeenCalledWith({ ...settings, pageSize: "letter", designVersion: 2, headingFont: "Georgia, 'Times New Roman', serif", bodyFont: "Arial, Helvetica, sans-serif", accentColor: "#abcdef", baseFontSizePt: 11, lineHeight: 1.5, pageMarginMm: 23.6, sourceCvId: 3 });
});
test("closing a copy dialog ignores a late selected CV read", async () => {
const selected = deferred<any>();
const originalGet = mockedApi.get.getMockImplementation()!;
+5 -4
View File
@@ -235,7 +235,7 @@ test('held CV creation locks the draft and gallery, then keeps a failed draft fo
expect(mockNavigate).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/cv/variants', { name: 'Application-specific CV', settings: { ...emptyCvVariantSettings('clean'), sectionsExplicit: true, includeProfileContact: false } }));
await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/cv/variants', { name: 'Application-specific CV', settings: { ...emptyCvVariantSettings('clean'), designVersion: 2, sectionsExplicit: true, includeProfileContact: false } }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42'));
});
@@ -252,7 +252,7 @@ test('a locked CV template remains identifiable and cannot change the creation r
fireEvent.keyDown(premium, { key: 'Enter' });
expect(within(dialog).getByRole('button', { name: 'Modern' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ settings: { ...emptyCvVariantSettings('modern'), sectionsExplicit: true, includeProfileContact: false } })));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ settings: { ...emptyCvVariantSettings('modern'), designVersion: 2, sectionsExplicit: true, includeProfileContact: false } })));
});
test('a held duplicate blocks another action for the same CV and shows the persisted refreshed copy', async () => {
@@ -344,7 +344,8 @@ test('a failed creation keeps its draft and translates its failure after closing
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Could not create a CV.');
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
const reads = mockedApi.get.mock.calls.length;
const profileReads = () => mockedApi.get.mock.calls.filter(([url]) => url === '/cv/variants' || url === '/cv/themes').length;
const reads = profileReads();
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })); });
fireEvent.click(screen.getByRole('button', { name: 'Ny CV' }));
const reopened = await screen.findByRole('dialog', { name: 'Opprett en CV' });
@@ -352,7 +353,7 @@ test('a failed creation keeps its draft and translates its failure after closing
expect(within(reopened).getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-pressed', 'true');
expect(within(reopened).getByRole('alert')).toHaveTextContent('Kunne ikke opprette en CV.');
expect(within(reopened).queryByText('Could not create a CV.')).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
expect(profileReads()).toBe(reads);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockNavigate).not.toHaveBeenCalled();
});
@@ -1,4 +1,5 @@
import React, { useState } from "react";
import { MemoryRouter } from "react-router-dom";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import CvCustomizeTab from "./components/cv/CvCustomizeTab";
@@ -34,7 +35,7 @@ function mount(initial = emptyCvVariantSettings(), mode: "customize" | "template
current = settings;
return <CvCustomizeTab mode={mode} settings={settings} themes={availableThemes} sectionLabel={(_, fallback) => fallback} update={patch => { update(patch); setSettings(previous => ({ ...previous, ...patch })); }} />;
}
render(<I18nProvider><ConfirmProvider><Harness /></ConfirmProvider></I18nProvider>);
render(<MemoryRouter><I18nProvider><ConfirmProvider><Harness /></ConfirmProvider></I18nProvider></MemoryRouter>);
return { update, settings: () => current };
}
@@ -0,0 +1,66 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import CropImageDialog from "./components/CropImageDialog";
import CvCustomizeTab from "./components/cv/CvCustomizeTab";
import { emptyCvVariantSettings, type CvTheme } from "./cvBuilder";
import { ConfirmProvider } from "./confirm";
import { I18nProvider } from "./i18n/I18nProvider";
beforeEach(() => {
localStorage.clear();
URL.createObjectURL = jest.fn(() => "blob:portrait");
URL.revokeObjectURL = jest.fn();
});
afterEach(() => jest.restoreAllMocks());
test("keyboard crop positions are bounded and feed the same exported square as dragging", async () => {
const drawImage = jest.fn();
jest.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ drawImage } as any);
jest.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation(callback => callback(new Blob(["portrait"], { type: "image/png" })));
const save = jest.fn();
render(<I18nProvider><CropImageDialog open file={new File(["portrait"], "portrait.png", { type: "image/png" })} onClose={jest.fn()} onSave={save} /></I18nProvider>);
const image = screen.getByRole("img", { name: "Crop preview" });
Object.defineProperties(image, { naturalWidth: { value: 400 }, naturalHeight: { value: 200 } });
fireEvent.load(image);
const horizontal = screen.getByRole("slider", { name: "Horizontal position" });
const vertical = screen.getByRole("slider", { name: "Vertical position" });
expect(vertical).toBeDisabled();
fireEvent.keyDown(horizontal, { key: "Home" });
expect(horizontal).toHaveAttribute("aria-valuenow", "-280");
fireEvent.keyDown(horizontal, { key: "ArrowLeft" });
expect(horizontal).toHaveAttribute("aria-valuenow", "-280");
fireEvent.change(screen.getByRole("slider", { name: "Zoom" }), { target: { value: "2" } });
fireEvent.keyDown(vertical, { key: "Home" });
expect(vertical).toHaveAttribute("aria-valuenow", "-280");
fireEvent.click(screen.getByRole("button", { name: "Save image" }));
await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
expect(drawImage).toHaveBeenLastCalledWith(image, 100, 100, 100, 100, 0, 0, 512, 512);
});
const theme: CvTheme = { id: "modern", name: "Modern", category: "Professional", description: "", layout: "single", accent: "#3157d5", photoShape: "circle", supportsIcons: true, atsFriendly: true, requiresPro: false, available: true, swatches: [] };
function mountPhoto(photoShape: CvTheme["photoShape"], includeProfileContact = true) {
const update = jest.fn();
render(<MemoryRouter><I18nProvider><ConfirmProvider><CvCustomizeTab mode="customize" settings={{ ...emptyCvVariantSettings(), includeProfileContact }} themes={[{ ...theme, photoShape }]} update={update} sectionLabel={(_, fallback) => fallback} /></ConfirmProvider></I18nProvider></MemoryRouter>);
return update;
}
test("capable CV designs link directly to existing portrait management without changing the CV", () => {
const update = mountPhoto("circle");
expect(screen.getByRole("link", { name: "Manage profile photo" })).toHaveAttribute("href", "/career?section=profile");
expect(screen.getByRole("switch", { name: "Show profile photo" })).toBeInTheDocument();
expect(update).not.toHaveBeenCalled();
});
test("photo-free designs do not offer portrait controls", () => {
mountPhoto("none");
fireEvent.click(screen.getByRole("button", { name: "Advanced document settings" }));
expect(screen.queryByRole("switch", { name: "Show profile photo" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Manage profile photo" })).not.toBeInTheDocument();
});
test("explicitly excluded profile contact explains why the portrait is omitted", () => {
mountPhoto("circle", false);
expect(screen.getByText("Include Career Profile contact details in Content to show its photo. A missing photo leaves the document unchanged.")).toBeInTheDocument();
});
+17 -74
View File
@@ -1,76 +1,19 @@
import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import CvTemplatePreviewDialog from "./components/cv/CvTemplatePreviewDialog";
import { cvBuilderApi, CvRender, CvTheme } from "./cvBuilder";
import { I18nProvider } from "./i18n/I18nProvider";
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import CvTemplateThumbnail from './components/CvTemplateThumbnail';
import { cvBuilderApi, CvTheme } from './cvBuilder';
import { I18nProvider } from './i18n/I18nProvider';
const modern: CvTheme = { id: "modern", name: "Modern", category: "Professional", description: "A clear document", layout: "single", accent: "#3157d5", photoShape: "none", supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: [] };
const code = { ...modern, id: "code", name: "Code" };
const sample = (id: string): CvRender => ({ themeId: id, html: `<html><body><div class="page">${id} sample</div></body></html>`, suggestedFileName: "sample.pdf" });
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(yes => { resolve = yes; });
return { promise, resolve };
}
beforeEach(() => {
localStorage.clear();
global.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} };
jest.spyOn(cvBuilderApi, "themePreview").mockResolvedValue(sample("modern"));
});
afterEach(() => jest.restoreAllMocks());
test("candidate loads a labelled sample without applying; Cancel preserves the draft", async () => {
const onApply = jest.fn(); const onClose = jest.fn();
render(<I18nProvider><CvTemplatePreviewDialog theme={modern} onClose={onClose} onApply={onApply} /></I18nProvider>);
expect(await screen.findByTitle("Template sample preview")).toHaveAttribute("srcdoc", expect.stringContaining("modern sample"));
expect(screen.getByText(/Sample content/)).toBeVisible();
expect(onApply).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onApply).not.toHaveBeenCalled();
});
test("sample scales down to a narrow preview host", async () => {
const width = jest.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(280);
render(<I18nProvider><CvTemplatePreviewDialog theme={modern} onClose={jest.fn()} onApply={jest.fn()} /></I18nProvider>);
const frame = await screen.findByTitle("Template sample preview");
expect(Number((frame as HTMLIFrameElement).style.transform.match(/scale\(([^)]+)\)/)?.[1])).toBeLessThan(0.4);
width.mockRestore();
});
test("a late candidate response cannot replace the selected template", async () => {
const held = deferred<CvRender>();
jest.mocked(cvBuilderApi.themePreview).mockImplementation(id => id === "modern" ? held.promise : Promise.resolve(sample("code")));
const view = render(<I18nProvider><CvTemplatePreviewDialog theme={modern} onClose={jest.fn()} onApply={jest.fn()} /></I18nProvider>);
view.rerender(<I18nProvider><CvTemplatePreviewDialog theme={code} onClose={jest.fn()} onApply={jest.fn()} /></I18nProvider>);
await waitFor(() => expect(screen.getByTitle("Template sample preview")).toHaveAttribute("srcdoc", expect.stringContaining("code sample")));
await act(async () => held.resolve(sample("modern")));
expect(screen.getByTitle("Template sample preview")).toHaveAttribute("srcdoc", expect.stringContaining("code sample"));
});
test("failed preview has an explicit retry and cannot apply until it succeeds", async () => {
jest.mocked(cvBuilderApi.themePreview).mockRejectedValueOnce(new Error("synthetic failure"));
const onApply = jest.fn();
render(<I18nProvider><CvTemplatePreviewDialog theme={modern} onClose={jest.fn()} onApply={onApply} /></I18nProvider>);
expect(await screen.findByRole("alert")).toHaveTextContent("Could not preview this design");
expect(screen.getByRole("button", { name: "Apply template" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Retry preview" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Apply template" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "Apply template" }));
expect(onApply).toHaveBeenCalledWith(modern);
});
test("locked candidates cannot request or apply a preview", async () => {
render(<I18nProvider><CvTemplatePreviewDialog theme={{ ...modern, available: false }} onClose={jest.fn()} onApply={jest.fn()} /></I18nProvider>);
expect(screen.getByRole("button", { name: "Apply template" })).toBeDisabled();
expect(cvBuilderApi.themePreview).not.toHaveBeenCalled();
});
test("Bokmål sample labelling and actions are localised", async () => {
localStorage.setItem("uiLanguage", "nb");
render(<I18nProvider><CvTemplatePreviewDialog theme={modern} onClose={jest.fn()} onApply={jest.fn()} /></I18nProvider>);
expect(await screen.findByTitle("Eksempelforhåndsvisning av mal")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Bruk mal" })).toBeEnabled();
test('template cards use the server-rendered sample in a sandboxed noninteractive preview', async () => {
const theme = { id: 'preview-test', name: 'Preview test', layout: 'single', swatches: [] } as unknown as CvTheme;
const request = jest.spyOn(cvBuilderApi, 'themePreview').mockResolvedValue({ themeId: theme.id, html: '<html><body><h1>Rendered sample</h1></body></html>', suggestedFileName: 'sample.pdf' });
try {
render(<I18nProvider><CvTemplateThumbnail theme={theme} /></I18nProvider>);
const iframe = await screen.findByTitle('Template sample preview');
expect(iframe).toHaveAttribute('srcdoc', expect.stringContaining('Rendered sample'));
expect(iframe).toHaveAttribute('sandbox', '');
expect(iframe).toHaveAttribute('tabindex', '-1');
expect(request).toHaveBeenCalledWith(theme.id);
} finally { request.mockRestore(); }
});
@@ -1,8 +1,9 @@
import React from "react";
import { render } from "@testing-library/react";
import { render, waitFor } from "@testing-library/react";
import CvTemplateThumbnail from "./components/CvTemplateThumbnail";
import { CvTheme } from "./cvBuilder";
import { cvBuilderApi, CvTheme } from "./cvBuilder";
import { I18nProvider } from "./i18n/I18nProvider";
const theme = (id: string, layout: CvTheme["layout"], swatches: string[]): CvTheme => ({
id,
@@ -19,13 +20,20 @@ const theme = (id: string, layout: CvTheme["layout"], swatches: string[]): CvThe
swatches,
});
test("template thumbnails stay distinct and never replace themselves with sandboxed frames", () => {
const minimal = render(<CvTemplateThumbnail theme={theme("minimal", "single", ["#111827", "#ffffff", "#ffffff"])} />);
const minimalMarkup = minimal.container.innerHTML;
test("template thumbnails retain distinct server-rendered samples in noninteractive sandboxed frames", async () => {
const preview = jest.spyOn(cvBuilderApi, "themePreview").mockImplementation(async id => ({ html: `<html><body>${id} synthetic sample</body></html>` } as any));
const minimal = render(<I18nProvider><CvTemplateThumbnail theme={theme("minimal", "single", ["#111827", "#ffffff", "#ffffff"])} /></I18nProvider>);
await waitFor(() => expect(minimal.container.querySelector("iframe")?.srcdoc).toContain("minimal synthetic sample"));
const minimalMarkup = minimal.container.querySelector("iframe")!.srcdoc;
minimal.unmount();
const nordic = render(<CvTemplateThumbnail theme={theme("nordic", "sidebar-right", ["#3b6ea5", "#eef3f8", "#ffffff"])} />);
const nordic = render(<I18nProvider><CvTemplateThumbnail theme={theme("nordic", "sidebar-right", ["#3b6ea5", "#eef3f8", "#ffffff"])} /></I18nProvider>);
expect(nordic.container.querySelector("iframe")).toBeNull();
expect(nordic.container.innerHTML).not.toBe(minimalMarkup);
await waitFor(() => expect(nordic.container.querySelector("iframe")?.srcdoc).toContain("nordic synthetic sample"));
const frame = nordic.container.querySelector("iframe")!;
expect(frame.getAttribute("sandbox")).toBe("");
expect(frame.tabIndex).toBe(-1);
expect(frame.srcdoc).not.toBe(minimalMarkup);
expect(preview.mock.calls.map(([id]) => id)).toEqual(["minimal", "nordic"]);
preview.mockRestore();
});
+17 -1
View File
@@ -1,4 +1,4 @@
import { applyCvTheme, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, getVisibleCvThemes, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder";
import { applyCvTheme, emptyCvVariantSettings, newCvVariantSettings, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, getVisibleCvThemes, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder";
describe("moveItem", () => {
test("moves an item forward", () => {
@@ -68,6 +68,22 @@ describe("CV language compatibility", () => {
});
describe("CV template selection", () => {
test("new documents opt into revised design while legacy fixture defaults stay unchanged", () => {
expect(newCvVariantSettings("nordic")).toEqual({ ...emptyCvVariantSettings("nordic"), designVersion: 2 });
expect(emptyCvVariantSettings()).not.toHaveProperty("designVersion");
});
test("explicit template Apply opts into revised design and preserves inclusion and authored content", () => {
const legacy = {
themeId: "modern", showPhoto: true, showPageNumbers: false, showIcons: true,
designVersion: 1, sectionsExplicit: true, includeProfileContact: false,
headline: "Authored headline", sections: [{ key: "summary", items: ["Authored summary"] }], overrides: {}, customSections: [],
};
const next = applyCvTheme(legacy, "technical");
expect(next).toMatchObject({ designVersion: 2, themeId: "technical", sectionsExplicit: true, includeProfileContact: false,
headline: "Authored headline", sections: legacy.sections, showPhoto: true });
expect(legacy.designVersion).toBe(1);
});
test("restores the selected template visual defaults without discarding user content", () => {
const settings = {
themeId: "minimal",
+10
View File
@@ -23,6 +23,7 @@ export type CvSkillGroupSetting = { name: string; items: string[] };
export type CvVariantSettings = {
themeId: string;
designVersion?: number | null;
accentColor?: string | null;
headingFont?: string | null;
bodyFont?: string | null;
@@ -73,6 +74,10 @@ export type CvTheme = {
swatches: string[];
supportedSettings?: string[];
pageMarginMm?: number;
headingFont?: string;
bodyFont?: string;
baseFontSizePt?: number;
lineHeight?: number;
};
// Master profile resolved to sections+entries (GET /api/cv/outline) — what the Content tab edits.
@@ -197,6 +202,7 @@ export function applyCvTheme(settings: CvVariantSettings, themeId: string): CvVa
return {
...settings,
themeId,
designVersion: 2,
accentColor: null,
headingFont: null,
bodyFont: null,
@@ -243,6 +249,10 @@ export function emptyCvVariantSettings(themeId = "modern"): CvVariantSettings {
};
}
export function newCvVariantSettings(themeId = "modern"): CvVariantSettings {
return { ...emptyCvVariantSettings(themeId), designVersion: 2 };
}
export async function getCvExportFailureKey(error: unknown): Promise<"cvEmptyContent" | "cvPdfBusy" | "cvPdfTimedOut" | undefined> {
const response = (error as { response?: { status?: number; data?: unknown } })?.response;
if (response?.status === 503) return "cvPdfBusy";
+28
View File
@@ -796,6 +796,12 @@ export const translations = {
letterCopyContactHelp: "Choose the contact fields to copy. Location is optional and starts unselected. Blank or unselected fields keep your existing letter details.",
letterCopyContact: "Copy contact from CV",
letterMatchDesign: "Match CV design",
letterChooseTemplate: "Choose letter template",
letterTemplate: "Letter template",
letterTemplateHelp: "Choose a design matching the CV templates. Only styling changes; your letter text, details, language and paper size stay unchanged. Review the result in Document preview.",
letterTemplateApply: "Use this template",
letterTemplateFailed: "Could not load letter templates. Try again.",
letterTemplateUnavailable: "Choose an available template with complete design defaults.",
letterCopyHelp: "Review this snapshot before applying. Your letter text and recipient stay unchanged; future profile or CV edits do not update this copy.",
letterSourceCv: "Source CV",
letterCopyApply: "Use this copy",
@@ -1044,6 +1050,10 @@ export const translations = {
cvEditorSidebarWidth: "Sidebar width",
cvEditorContactDetails: "Contact details",
cvEditorShowPhoto: "Show profile photo",
cvEditorProfilePhoto: "Profile photo",
cvEditorManagePhoto: "Manage profile photo",
cvEditorPhotoHelp: "Upload, preview, crop or remove your photo in Career Profile. It is shared with your account and appears here only when enabled; a missing photo leaves the document unchanged.",
cvEditorPhotoContactHelp: "Include Career Profile contact details in Content to show its photo. A missing photo leaves the document unchanged.",
cvEditorContactIcons: "Contact icons",
cvEditorOpenFailed: "Could not open this CV.",
cvEditorSourceUnavailable: "This CV is unavailable or you no longer have access to it.",
@@ -1557,6 +1567,7 @@ export const translations = {
profileCopyCvText: "Copy CV text",
profileCvUploaded: "CV uploaded and processed.",
profileCvUploadFailed: "Failed to upload CV.",
cvImportWorkerDisabled: "CV importing is currently unavailable. Ask an administrator to enable CV processing, then try uploading again.",
profileCvQueuedUpload: "Queued CV upload (run {id}).",
profileCvQueuedRebuild: "Queued CV rebuild (run {id}).",
profileCvQueuedImprove: "Queued CV improvement (run {id}).",
@@ -1811,6 +1822,9 @@ export const translations = {
cropDialogTitle: "Crop profile image",
cropDialogBody: "Position and zoom your image. The saved avatar will be exported as a 512×512 square.",
cropDialogZoom: "Zoom",
cropDialogHorizontal: "Horizontal position",
cropDialogVertical: "Vertical position",
cropDialogKeyboardHelp: "Use the position sliders with arrow keys, or Home and End, to adjust the crop without dragging.",
cropDialogPreviewAlt: "Crop preview",
cropDialogSave: "Save image",
dashboardSnapshotScope: "Your tracker today. Activity charts have a separate period.",
@@ -3808,6 +3822,12 @@ export const translations = {
letterCopyContactHelp: "Velg kontaktfeltene du vil kopiere. Sted er valgfritt og er ikke valgt som standard. Tomme eller ikke valgte felt beholder de eksisterende opplysningene i brevet.",
letterCopyContact: "Kopier kontakt fra CV",
letterMatchDesign: "Bruk CV-design",
letterChooseTemplate: "Velg brevmal",
letterTemplate: "Brevmal",
letterTemplateHelp: "Velg et design som passer til CV-malene. Bare utformingen endres; brevtekst, opplysninger, språk og papirstørrelse beholdes. Se resultatet i dokumentforhåndsvisningen.",
letterTemplateApply: "Bruk denne malen",
letterTemplateFailed: "Kunne ikke laste brevmalene. Prøv igjen.",
letterTemplateUnavailable: "Velg en tilgjengelig mal med fullstendige designinnstillinger.",
letterCopyHelp: "Se gjennom kopien før du bruker den. Brevteksten og mottakeren beholdes; senere profil- eller CV-endringer oppdaterer ikke denne kopien.",
letterSourceCv: "Kilde-CV",
letterCopyApply: "Bruk denne kopien",
@@ -4056,6 +4076,10 @@ export const translations = {
cvEditorSidebarWidth: "Bredde på sidestolpe",
cvEditorContactDetails: "Kontaktopplysninger",
cvEditorShowPhoto: "Vis profilbilde",
cvEditorProfilePhoto: "Profilbilde",
cvEditorManagePhoto: "Administrer profilbilde",
cvEditorPhotoHelp: "Last opp, forhåndsvis, beskjær eller fjern bildet ditt i Karriereprofil. Bildet deles med kontoen din og vises her bare når det er aktivert. Et manglende bilde endrer ikke dokumentet.",
cvEditorPhotoContactHelp: "Ta med kontaktopplysninger fra Karriereprofil under Innhold for å vise bildet. Et manglende bilde endrer ikke dokumentet.",
cvEditorContactIcons: "Kontaktikoner",
cvEditorOpenFailed: "Kunne ikke åpne denne CV-en.",
cvEditorSourceUnavailable: "Denne CV-en er utilgjengelig, eller du har ikke lenger tilgang til den.",
@@ -4569,6 +4593,7 @@ export const translations = {
profileCopyCvText: "Kopier CV-tekst",
profileCvUploaded: "CV lastet opp og behandlet.",
profileCvUploadFailed: "Kunne ikke laste opp CV.",
cvImportWorkerDisabled: "CV-import er ikke tilgjengelig nå. Be en administrator om å aktivere CV-behandling, og prøv deretter å laste opp på nytt.",
profileCvQueuedUpload: "CV-opplasting satt i kø (kjøring {id}).",
profileCvQueuedRebuild: "Gjenoppbygging av CV satt i kø (kjøring {id}).",
profileCvQueuedImprove: "CV-forbedring satt i kø (kjøring {id}).",
@@ -4823,6 +4848,9 @@ export const translations = {
cropDialogTitle: "Beskjær profilbilde",
cropDialogBody: "Plasser og zoom bildet. Det lagrede avataren eksporteres som en kvadratisk 512×512-fil.",
cropDialogZoom: "Zoom",
cropDialogHorizontal: "Vannrett posisjon",
cropDialogVertical: "Loddrett posisjon",
cropDialogKeyboardHelp: "Bruk piltastene eller Home og End på posisjonsglidebryterne for å justere beskjæringen uten å dra.",
cropDialogPreviewAlt: "Forhåndsvisning av beskjæring",
cropDialogSave: "Lagre bilde",
dashboardSnapshotScope: "Jobbsøket ditt nå. Aktivitetsdiagrammene har et eget periodevalg.",
@@ -344,7 +344,10 @@ export default function CareerProfilePage() {
await loadExtractionRuns();
toast(t("profileCvQueuedUpload", { id: res.data.extractionRunId }), "info");
} catch (error) {
const message = getApiErrorMessage(error, t("profileCvUploadFailed"));
const response = (error as { response?: { status?: number; data?: { code?: string } } }).response;
const message = response?.status === 503 && response.data?.code === "cv_worker_disabled"
? translationRef.current("cvImportWorkerDisabled")
: getApiErrorMessage(error, t("profileCvUploadFailed"));
setUploadError(message);
toast(message, "error");
} finally {
+2 -2
View File
@@ -10,7 +10,7 @@ import PublicIcon from "@mui/icons-material/Public";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { CvOutline, CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings, getCvExportFailureKey } from "../cvBuilder";
import { CvOutline, CvTheme, CvVariantSummary, cvBuilderApi, newCvVariantSettings, getCvExportFailureKey } from "../cvBuilder";
import { useDialogActions } from "../dialogs";
import CvTemplateGallery from "../components/CvTemplateGallery";
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
@@ -92,7 +92,7 @@ export default function CvBuilderPage() {
setCreating(true);
try {
const variant = await cvBuilderApi.create({ name: newName.trim(), settings: {
...emptyCvVariantSettings(newTheme),
...newCvVariantSettings(newTheme),
sectionsExplicit: true,
includeProfileContact: contentSource === "profile" && includeContact,
sections: contentSource === "profile" ? selectedSections.map((key) => ({ key })) : [],