fix(cv): restore distinct responsive templates
This commit is contained in:
@@ -172,6 +172,50 @@ test('professional editor starts with templates and combines design and layout c
|
||||
expect(screen.getByText('Check spelling and grammar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('choosing a template restores its own structure instead of inheriting the previous template layout', async () => {
|
||||
const customizedVariant = {
|
||||
...variant,
|
||||
settings: {
|
||||
...emptyCvVariantSettings(),
|
||||
themeId: 'minimal',
|
||||
layout: 'single',
|
||||
headerStyle: 'plain',
|
||||
headingStyle: 'plain',
|
||||
sidebarWidthMm: 72,
|
||||
sidebarSections: ['skills'],
|
||||
accentColor: '#123456',
|
||||
},
|
||||
};
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/cv/variants/3') return Promise.resolve({ data: customizedVariant } as any);
|
||||
if (url === '/cv/themes') return Promise.resolve({ data: [
|
||||
{ id: 'minimal', name: 'Minimal', category: 'ATS', description: 'Quiet single column', layout: 'single', available: true, swatches: ['#111827', '#fff', '#fff'] },
|
||||
{ id: 'technical', name: 'Technical', category: 'Technical', description: 'Structured sidebar', layout: 'sidebar-left', available: true, swatches: ['#0f4c5c', '#0f4c5c', '#fff'] },
|
||||
] } as any);
|
||||
if (url === '/cv/outline') return Promise.resolve({ data: { sections: [] } } as any);
|
||||
if (url.startsWith('/cv/themes/')) return Promise.resolve({ data: { themeId: url.includes('technical') ? 'technical' : 'minimal', html: '<p>preview</p>', suggestedFileName: 'preview.pdf' } } as any);
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
});
|
||||
mockedApi.put.mockResolvedValue({ data: customizedVariant } as any);
|
||||
renderAt(3);
|
||||
|
||||
await screen.findByDisplayValue('Backend CV');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Technical/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/3', expect.objectContaining({
|
||||
settings: expect.objectContaining({
|
||||
themeId: 'technical',
|
||||
layout: null,
|
||||
headerStyle: null,
|
||||
headingStyle: null,
|
||||
sidebarWidthMm: null,
|
||||
sidebarSections: null,
|
||||
accentColor: '#123456',
|
||||
}),
|
||||
})));
|
||||
});
|
||||
|
||||
test('editor sections, design controls and document settings render in Bokmål', async () => {
|
||||
window.localStorage.setItem('uiLanguage', 'nb');
|
||||
routeGet(() => Promise.resolve({ data: variant } as any));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getCvPageCount, getCvPageMetrics, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder";
|
||||
import { applyCvTheme, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder";
|
||||
|
||||
describe("moveItem", () => {
|
||||
test("moves an item forward", () => {
|
||||
@@ -59,4 +59,41 @@ describe("CV language compatibility", () => {
|
||||
expect(normalizeCvLanguage("nb-NO")).toBe("nb-NO");
|
||||
expect(normalizeCvLanguage("en")).toBe("en");
|
||||
});
|
||||
|
||||
test("fits the physical page to the available preview width", () => {
|
||||
expect(getCvPreviewFitZoom(500, 800)).toBeCloseTo(0.595);
|
||||
expect(getCvPreviewFitZoom(1200, 800)).toBe(1);
|
||||
expect(getCvPreviewFitZoom(120, 800)).toBe(0.32);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CV template selection", () => {
|
||||
test("restores the selected template structure without discarding user content or design choices", () => {
|
||||
const settings = {
|
||||
themeId: "minimal",
|
||||
layout: "single" as const,
|
||||
headerStyle: "plain" as const,
|
||||
headingStyle: "plain" as const,
|
||||
sidebarWidthMm: 70,
|
||||
sidebarSections: ["skills"],
|
||||
accentColor: "#123456",
|
||||
showPhoto: false,
|
||||
showPageNumbers: false,
|
||||
showIcons: true,
|
||||
sections: [{ key: "experience", title: "Career" }],
|
||||
overrides: {},
|
||||
customSections: [],
|
||||
};
|
||||
|
||||
expect(applyCvTheme(settings, "technical")).toMatchObject({
|
||||
themeId: "technical",
|
||||
layout: null,
|
||||
headerStyle: null,
|
||||
headingStyle: null,
|
||||
sidebarWidthMm: null,
|
||||
sidebarSections: null,
|
||||
accentColor: "#123456",
|
||||
sections: [{ key: "experience", title: "Career" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,6 +176,27 @@ export function getCvPageCount(contentHeightPx: number, pageHeightPx: number): n
|
||||
return Math.max(1, Math.ceil(Math.max(0, contentHeightPx - 2) / pageHeightPx));
|
||||
}
|
||||
|
||||
export function getCvPreviewFitZoom(containerWidthPx: number, pageWidthPx: number, gutterPx = 24, minZoom = 0.32): number {
|
||||
if (!Number.isFinite(containerWidthPx) || !Number.isFinite(pageWidthPx) || pageWidthPx <= 0) return minZoom;
|
||||
const usableWidth = Math.max(1, containerWidthPx - gutterPx);
|
||||
return Math.min(1, Math.max(minZoom, usableWidth / pageWidthPx));
|
||||
}
|
||||
|
||||
// Selecting a template is a structural choice. Clear only the presentation overrides that can
|
||||
// flatten every template into the previous template's layout; keep content, colour, typography,
|
||||
// spacing and document settings intact.
|
||||
export function applyCvTheme(settings: CvVariantSettings, themeId: string): CvVariantSettings {
|
||||
return {
|
||||
...settings,
|
||||
themeId,
|
||||
layout: null,
|
||||
headerStyle: null,
|
||||
headingStyle: null,
|
||||
sidebarWidthMm: null,
|
||||
sidebarSections: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCvLanguage(language?: string | null): string {
|
||||
const normalized = language?.trim().toLowerCase();
|
||||
return normalized === "no" || normalized === "nb" || normalized === "nb-no" ? "nb-NO" : normalized || "en";
|
||||
|
||||
@@ -37,7 +37,7 @@ import { useDragReorder } from "../hooks/useDragReorder";
|
||||
import {
|
||||
CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
|
||||
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
|
||||
cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem, normalizeCvLanguage,
|
||||
applyCvTheme, cvBuilderApi, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
|
||||
} from "../cvBuilder";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -84,6 +84,7 @@ export default function CvBuilderEditor() {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [html, setHtml] = useState("");
|
||||
const [zoom, setZoom] = useState(0.62);
|
||||
const [autoFitPreview, setAutoFitPreview] = useState(true);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
const [previewRevision, setPreviewRevision] = useState(0);
|
||||
@@ -409,10 +410,24 @@ export default function CvBuilderEditor() {
|
||||
if (target && scrollRef.current) scrollRef.current.scrollTo({ top: Math.max(0, target.offsetTop - 12), behavior: "smooth" });
|
||||
};
|
||||
|
||||
const fitPreview = () => {
|
||||
const availableWidth = Math.max(1, (scrollRef.current?.clientWidth ?? pageMetrics.widthPx) - 24);
|
||||
setZoom(Math.min(1, Math.max(MIN_PREVIEW_ZOOM, availableWidth / pageMetrics.widthPx)));
|
||||
};
|
||||
const fitPreview = useCallback(() => {
|
||||
setZoom(getCvPreviewFitZoom(scrollRef.current?.clientWidth ?? pageMetrics.widthPx, pageMetrics.widthPx, 24, MIN_PREVIEW_ZOOM));
|
||||
}, [pageMetrics.widthPx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFitPreview) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const resize = () => fitPreview();
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize);
|
||||
observer?.observe(container);
|
||||
return () => {
|
||||
window.removeEventListener("resize", resize);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [autoFitPreview, compactEditor, fitPreview, mobilePane]);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
@@ -486,11 +501,11 @@ export default function CvBuilderEditor() {
|
||||
<Button size="small" disabled={page >= pages} onClick={() => goToPage(page + 1)}>{t("cvEditorNext")}</Button>
|
||||
</Stack>
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
|
||||
<IconButton size="small" aria-label={t("cvEditorZoomOut")} onClick={() => setZoom((z) => Math.max(MIN_PREVIEW_ZOOM, +(z - 0.1).toFixed(2)))}><ZoomOutIcon fontSize="small" /></IconButton>
|
||||
<Slider size="small" value={zoom} min={MIN_PREVIEW_ZOOM} max={1} step={0.02} onChange={(_, v) => setZoom(v as number)} sx={{ width: 90 }} aria-label={t("cvEditorZoom")} />
|
||||
<IconButton size="small" aria-label={t("cvEditorZoomIn")} onClick={() => setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2)))}><ZoomInIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" aria-label={t("cvEditorZoomOut")} onClick={() => { setAutoFitPreview(false); setZoom((z) => Math.max(MIN_PREVIEW_ZOOM, +(z - 0.1).toFixed(2))); }}><ZoomOutIcon fontSize="small" /></IconButton>
|
||||
<Slider size="small" value={zoom} min={MIN_PREVIEW_ZOOM} max={1} step={0.02} onChange={(_, v) => { setAutoFitPreview(false); setZoom(v as number); }} sx={{ width: 90 }} aria-label={t("cvEditorZoom")} />
|
||||
<IconButton size="small" aria-label={t("cvEditorZoomIn")} onClick={() => { setAutoFitPreview(false); setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2))); }}><ZoomInIcon fontSize="small" /></IconButton>
|
||||
<Typography variant="caption" sx={{ minWidth: 34, textAlign: "right" }}>{Math.round(zoom * 100)}%</Typography>
|
||||
<Button size="small" onClick={fitPreview}>{t("cvEditorFit")}</Button>
|
||||
<Button size="small" variant={autoFitPreview ? "outlined" : "text"} onClick={() => { setAutoFitPreview(true); fitPreview(); }}>{t("cvEditorFit")}</Button>
|
||||
</Stack>
|
||||
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>{t("cvEditorOverflowWarning")}</Alert>}
|
||||
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>{t("cvEditorLongDocument", { pages })}</Alert>}
|
||||
@@ -1137,16 +1152,16 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
||||
{mode === "template" && <Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("cvEditorTheme")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1.25 }}>{t("cvEditorThemeHelp")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
|
||||
{themes.map((theme) => {
|
||||
const active = theme.id === settings.themeId;
|
||||
const locked = theme.available === false;
|
||||
return (
|
||||
<Paper key={theme.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-disabled={locked}
|
||||
onClick={() => { if (!locked) update({ themeId: theme.id }); }}
|
||||
onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update({ themeId: theme.id }); } }}
|
||||
onClick={() => { if (!locked) update(applyCvTheme(settings, theme.id)); }}
|
||||
onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update(applyCvTheme(settings, theme.id)); } }}
|
||||
sx={{ p: 1, cursor: locked ? "not-allowed" : "pointer", opacity: locked ? 0.6 : 1, outline: "none", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1, "&:focus-visible": { boxShadow: 3 } }}>
|
||||
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={110} /></Box>
|
||||
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={170} /></Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{theme.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{theme.category}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.3 }}>{theme.description}</Typography>
|
||||
|
||||
Reference in New Issue
Block a user