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 ? : }