feat: complete release readiness work #28
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import '@testing-library/jest-dom';
|
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 { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||||
|
|
||||||
import CvBuilderEditor from './views/CvBuilderEditor';
|
import CvBuilderEditor from './views/CvBuilderEditor';
|
||||||
@@ -56,11 +56,11 @@ beforeEach(() => {
|
|||||||
mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '<p>cv</p>', suggestedFileName: 'cv.pdf' } } as any);
|
mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '<p>cv</p>', suggestedFileName: 'cv.pdf' } } as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
function routeGet(onVariant: () => Promise<any>) {
|
function routeGet(onVariant: () => Promise<any>, outline: any = { sections: [] }) {
|
||||||
mockedApi.get.mockImplementation((url: string) => {
|
mockedApi.get.mockImplementation((url: string) => {
|
||||||
if (url === '/cv/variants/3') return onVariant();
|
if (url === '/cv/variants/3') return onVariant();
|
||||||
if (url === '/cv/themes') return Promise.resolve({ data: [] } as any);
|
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);
|
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();
|
expect(screen.queryByLabelText('Custom section title')).not.toBeInTheDocument();
|
||||||
confirm.mockRestore();
|
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: '<p>retry</p>', 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', '<p>retry</p>'));
|
||||||
|
await waitFor(() => expect(screen.queryByText('Preview unavailable')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export default function CvBuilderEditor() {
|
|||||||
const [html, setHtml] = useState("");
|
const [html, setHtml] = useState("");
|
||||||
const [zoom, setZoom] = useState(0.62);
|
const [zoom, setZoom] = useState(0.62);
|
||||||
const [previewing, setPreviewing] = useState(false);
|
const [previewing, setPreviewing] = useState(false);
|
||||||
|
const [previewError, setPreviewError] = useState(false);
|
||||||
|
const [previewRevision, setPreviewRevision] = useState(0);
|
||||||
const [pages, setPages] = useState(1);
|
const [pages, setPages] = useState(1);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [saveState, setSaveState] = useState<SaveState>("idle");
|
const [saveState, setSaveState] = useState<SaveState>("idle");
|
||||||
@@ -119,8 +121,9 @@ export default function CvBuilderEditor() {
|
|||||||
try {
|
try {
|
||||||
const render = await cvBuilderApi.previewSettings(settings);
|
const render = await cvBuilderApi.previewSettings(settings);
|
||||||
setHtml(render.html);
|
setHtml(render.html);
|
||||||
|
setPreviewError(false);
|
||||||
} catch {
|
} catch {
|
||||||
/* best-effort; keep last good render */
|
setPreviewError(true);
|
||||||
} finally {
|
} finally {
|
||||||
setPreviewing(false);
|
setPreviewing(false);
|
||||||
}
|
}
|
||||||
@@ -128,7 +131,7 @@ export default function CvBuilderEditor() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||||
};
|
};
|
||||||
}, [settings]);
|
}, [settings, previewRevision]);
|
||||||
|
|
||||||
const performSave = useCallback(async (next: CvVariantSettings, nextName: string, revision: number) => {
|
const performSave = useCallback(async (next: CvVariantSettings, nextName: string, revision: number) => {
|
||||||
setSaveState("saving");
|
setSaveState("saving");
|
||||||
@@ -315,6 +318,8 @@ export default function CvBuilderEditor() {
|
|||||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, px: 1, flexWrap: "wrap" }}>
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, px: 1, flexWrap: "wrap" }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>Live preview</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 700 }}>Live preview</Typography>
|
||||||
{previewing && <Chip size="small" label="updating…" variant="outlined" />}
|
{previewing && <Chip size="small" label="updating…" variant="outlined" />}
|
||||||
|
{previewError && <Chip size="small" label="Preview unavailable" color="error" variant="outlined" />}
|
||||||
|
{previewError && <Button size="small" onClick={() => setPreviewRevision((revision) => revision + 1)}>Retry preview</Button>}
|
||||||
<Box sx={{ flex: 1 }} />
|
<Box sx={{ flex: 1 }} />
|
||||||
{pages > 1 && (
|
{pages > 1 && (
|
||||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||||||
@@ -538,6 +543,7 @@ function SectionRow({
|
|||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const editable = outlineSection?.kind === "entries" && (outlineSection?.entries.length ?? 0) > 0;
|
const editable = outlineSection?.kind === "entries" && (outlineSection?.entries.length ?? 0) > 0;
|
||||||
|
const sectionLabel = row.title ?? SECTION_LABELS[row.key] ?? row.key;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -552,19 +558,19 @@ function SectionRow({
|
|||||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||||
<DragIndicatorIcon sx={{ fontSize: 18, color: "text.disabled" }} aria-hidden />
|
<DragIndicatorIcon sx={{ fontSize: 18, color: "text.disabled" }} aria-hidden />
|
||||||
<Stack>
|
<Stack>
|
||||||
<IconButton size="small" aria-label="Move section up" disabled={index === 0} onClick={() => onMove(-1)}><ArrowUpwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
<IconButton size="small" aria-label={`Move ${sectionLabel} section up`} disabled={index === 0} onClick={() => onMove(-1)}><ArrowUpwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
||||||
<IconButton size="small" aria-label="Move section down" disabled={index === total - 1} onClick={() => onMove(1)}><ArrowDownwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
<IconButton size="small" aria-label={`Move ${sectionLabel} section down`} disabled={index === total - 1} onClick={() => onMove(1)}><ArrowDownwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
<TextField variant="standard" fullWidth value={row.title ?? SECTION_LABELS[row.key] ?? row.key}
|
<TextField variant="standard" fullWidth value={row.title ?? SECTION_LABELS[row.key] ?? row.key}
|
||||||
onChange={(e) => onPatch({ title: e.target.value })}
|
onChange={(e) => onPatch({ title: e.target.value })}
|
||||||
slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": `Section name for ${row.key}` } }} />
|
slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": `Section name for ${row.key}` } }} />
|
||||||
{editable && (
|
{editable && (
|
||||||
<IconButton size="small" aria-label="Edit entries" onClick={() => setExpanded((e) => !e)}
|
<IconButton size="small" aria-label={`${expanded ? "Collapse" : "Expand"} ${sectionLabel} entries`} aria-expanded={expanded} onClick={() => setExpanded((e) => !e)}
|
||||||
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
|
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
|
||||||
<ExpandMoreIcon fontSize="small" />
|
<ExpandMoreIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<IconButton size="small" aria-label={row.hidden ? "Show section" : "Hide section"} onClick={() => onPatch({ hidden: !row.hidden })}>
|
<IconButton size="small" aria-label={`${row.hidden ? "Show" : "Hide"} ${sectionLabel} section`} onClick={() => onPatch({ hidden: !row.hidden })}>
|
||||||
{row.hidden ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
{row.hidden ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -614,6 +620,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
|||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
const ov = settings.overrides[key] ?? {};
|
const ov = settings.overrides[key] ?? {};
|
||||||
const hidden = !!ov.hidden;
|
const hidden = !!ov.hidden;
|
||||||
|
const entryLabel = ov.title || entry.title || "Untitled";
|
||||||
return (
|
return (
|
||||||
<Paper key={key} variant="outlined" {...drag.getItemProps(i)}
|
<Paper key={key} variant="outlined" {...drag.getItemProps(i)}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -624,9 +631,9 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
|||||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||||
<DragIndicatorIcon sx={{ fontSize: 16, color: "text.disabled" }} aria-hidden />
|
<DragIndicatorIcon sx={{ fontSize: 16, color: "text.disabled" }} aria-hidden />
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, flex: 1 }}>{ov.title || entry.title || "Untitled"}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700, flex: 1 }}>{ov.title || entry.title || "Untitled"}</Typography>
|
||||||
<IconButton size="small" aria-label="Move entry up" disabled={i === 0} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i - 1) })}><ArrowUpwardIcon sx={{ fontSize: 14 }} /></IconButton>
|
<IconButton size="small" aria-label={`Move ${entryLabel} entry up`} disabled={i === 0} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i - 1) })}><ArrowUpwardIcon sx={{ fontSize: 14 }} /></IconButton>
|
||||||
<IconButton size="small" aria-label="Move entry down" disabled={i === orderedKeys.length - 1} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i + 1) })}><ArrowDownwardIcon sx={{ fontSize: 14 }} /></IconButton>
|
<IconButton size="small" aria-label={`Move ${entryLabel} entry down`} disabled={i === orderedKeys.length - 1} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i + 1) })}><ArrowDownwardIcon sx={{ fontSize: 14 }} /></IconButton>
|
||||||
<IconButton size="small" aria-label={hidden ? "Show entry" : "Hide entry"} onClick={() => setOverride(key, { hidden: !hidden })}>
|
<IconButton size="small" aria-label={`${hidden ? "Show" : "Hide"} ${entryLabel} entry`} onClick={() => setOverride(key, { hidden: !hidden })}>
|
||||||
{hidden ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
|
{hidden ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
Reference in New Issue
Block a user