feat: complete release readiness work #28

Open
cesnimda wants to merge 110 commits from release-readiness into main
2 changed files with 58 additions and 12 deletions
Showing only changes of commit 20433491db - Show all commits
@@ -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());
});
+16 -9
View File
@@ -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>