From 20433491db9c201548d99d51a55402396c032be6 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 9 Aug 2026 21:06:11 +0200 Subject: [PATCH] fix(cv): expose editor errors and controls --- .../src/cv-builder-deep-link.test.tsx | 45 +++++++++++++++++-- job-tracker-ui/src/views/CvBuilderEditor.tsx | 25 +++++++---- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/job-tracker-ui/src/cv-builder-deep-link.test.tsx b/job-tracker-ui/src/cv-builder-deep-link.test.tsx index d82e9f3..ebe32b1 100644 --- a/job-tracker-ui/src/cv-builder-deep-link.test.tsx +++ b/job-tracker-ui/src/cv-builder-deep-link.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import '@testing-library/jest-dom'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import CvBuilderEditor from './views/CvBuilderEditor'; @@ -56,11 +56,11 @@ beforeEach(() => { mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '

cv

', suggestedFileName: 'cv.pdf' } } as any); }); -function routeGet(onVariant: () => Promise) { +function routeGet(onVariant: () => Promise, outline: any = { sections: [] }) { mockedApi.get.mockImplementation((url: string) => { if (url === '/cv/variants/3') return onVariant(); if (url === '/cv/themes') return Promise.resolve({ data: [] } as any); - if (url === '/cv/outline') return Promise.resolve({ data: { sections: [] } } as any); + if (url === '/cv/outline') return Promise.resolve({ data: outline } as any); return Promise.resolve({ data: [] } as any); }); } @@ -173,3 +173,42 @@ test('custom entries can be added, edited, reordered and deleted with confirmati expect(screen.queryByLabelText('Custom section title')).not.toBeInTheDocument(); confirm.mockRestore(); }); + +test('profile-backed sections expand, reorder, hide and persist variant-only overrides', async () => { + routeGet(() => Promise.resolve({ data: variant } as any), { + sections: [{ + key: 'experience', title: 'Experience', kind: 'entries', bullets: [], tags: [], entries: [ + { key: 'job-1', title: 'Engineer', subtitle: 'First Co', bullets: ['Built APIs'], tags: [] }, + { key: 'job-2', title: 'Lead', subtitle: 'Second Co', bullets: ['Led teams'], tags: [] }, + ], + }], + }); + mockedApi.put.mockResolvedValue({ data: variant } as any); + renderAt(3); + + fireEvent.click(await screen.findByRole('button', { name: 'Expand Experience entries' })); + expect(screen.getByRole('button', { name: 'Collapse Experience entries' })).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'Move Engineer entry down' })); + fireEvent.click(screen.getByRole('button', { name: 'Hide Lead entry' })); + fireEvent.click(screen.getByRole('button', { name: 'Move Experience section down' })); + fireEvent.click(screen.getByRole('button', { name: 'Save now' })); + + expect(await screen.findByText('Saved')).toBeInTheDocument(); + expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({ + settings: expect.objectContaining({ + sections: expect.arrayContaining([expect.objectContaining({ key: 'experience', itemOrder: ['job-2', 'job-1'] })]), + overrides: expect.objectContaining({ 'job-2': expect.objectContaining({ hidden: true }) }), + }), + })); +}); + +test('preview failure is visible and retryable without leaving the editor', async () => { + routeGet(() => Promise.resolve({ data: variant } as any)); + mockedApi.post.mockRejectedValueOnce(new Error('preview offline')).mockResolvedValueOnce({ data: { themeId: 'modern', html: '

retry

', suggestedFileName: 'cv.pdf' } } as any); + renderAt(3); + + expect(await screen.findByText('Preview unavailable')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry preview' })); + await waitFor(() => expect(screen.getByTitle('CV preview')).toHaveAttribute('srcdoc', '

retry

')); + await waitFor(() => expect(screen.queryByText('Preview unavailable')).not.toBeInTheDocument()); +}); diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index 01cb117..33651cb 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -60,6 +60,8 @@ export default function CvBuilderEditor() { const [html, setHtml] = useState(""); const [zoom, setZoom] = useState(0.62); const [previewing, setPreviewing] = useState(false); + const [previewError, setPreviewError] = useState(false); + const [previewRevision, setPreviewRevision] = useState(0); const [pages, setPages] = useState(1); const [page, setPage] = useState(1); const [saveState, setSaveState] = useState("idle"); @@ -119,8 +121,9 @@ export default function CvBuilderEditor() { try { const render = await cvBuilderApi.previewSettings(settings); setHtml(render.html); + setPreviewError(false); } catch { - /* best-effort; keep last good render */ + setPreviewError(true); } finally { setPreviewing(false); } @@ -128,7 +131,7 @@ export default function CvBuilderEditor() { return () => { if (previewTimer.current) clearTimeout(previewTimer.current); }; - }, [settings]); + }, [settings, previewRevision]); const performSave = useCallback(async (next: CvVariantSettings, nextName: string, revision: number) => { setSaveState("saving"); @@ -315,6 +318,8 @@ export default function CvBuilderEditor() { Live preview {previewing && } + {previewError && } + {previewError && } {pages > 1 && ( @@ -538,6 +543,7 @@ function SectionRow({ }) { const [expanded, setExpanded] = useState(false); const editable = outlineSection?.kind === "entries" && (outlineSection?.entries.length ?? 0) > 0; + const sectionLabel = row.title ?? SECTION_LABELS[row.key] ?? row.key; return ( - onMove(-1)}> - onMove(1)}> + onMove(-1)}> + onMove(1)}> onPatch({ title: e.target.value })} slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": `Section name for ${row.key}` } }} /> {editable && ( - setExpanded((e) => !e)} + setExpanded((e) => !e)} sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}> )} - onPatch({ hidden: !row.hidden })}> + onPatch({ hidden: !row.hidden })}> {row.hidden ? : } @@ -614,6 +620,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: { if (!entry) return null; const ov = settings.overrides[key] ?? {}; const hidden = !!ov.hidden; + const entryLabel = ov.title || entry.title || "Untitled"; return ( {ov.title || entry.title || "Untitled"} - onPatch({ itemOrder: moveItem(orderedKeys, i, i - 1) })}> - onPatch({ itemOrder: moveItem(orderedKeys, i, i + 1) })}> - setOverride(key, { hidden: !hidden })}> + onPatch({ itemOrder: moveItem(orderedKeys, i, i - 1) })}> + onPatch({ itemOrder: moveItem(orderedKeys, i, i + 1) })}> + setOverride(key, { hidden: !hidden })}> {hidden ? : }