feat: complete release readiness work #28
@@ -133,3 +133,43 @@ test('internal navigation warns and can be cancelled before discarding a pending
|
|||||||
expect(screen.getByDisplayValue('Backend CV')).toBeInTheDocument();
|
expect(screen.getByDisplayValue('Backend CV')).toBeInTheDocument();
|
||||||
confirm.mockRestore();
|
confirm.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('custom entries can be added, edited, reordered and deleted with confirmation', async () => {
|
||||||
|
routeGet(() => Promise.resolve({ data: variant } as any));
|
||||||
|
mockedApi.put.mockResolvedValue({ data: variant } as any);
|
||||||
|
const confirm = jest.spyOn(window, 'confirm');
|
||||||
|
renderAt(3);
|
||||||
|
|
||||||
|
await screen.findByLabelText('Headline override');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('Custom section title'), { target: { value: 'Selected projects' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('Entry 1'), { target: { value: 'First project' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('Entry 2'), { target: { value: 'Second project' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Move custom entry 2 up' }));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Entry 1')).toHaveValue('Second project');
|
||||||
|
expect(screen.getByLabelText('Entry 2')).toHaveValue('First project');
|
||||||
|
|
||||||
|
confirm.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete custom entry 1' }));
|
||||||
|
expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(2);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete custom entry 1' }));
|
||||||
|
expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(1);
|
||||||
|
|
||||||
|
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({
|
||||||
|
customSections: [expect.objectContaining({ title: 'Selected projects', items: ['First project'] })],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
confirm.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Remove custom section' }));
|
||||||
|
expect(screen.getByLabelText('Custom section title')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Remove custom section' }));
|
||||||
|
expect(screen.queryByLabelText('Custom section title')).not.toBeInTheDocument();
|
||||||
|
confirm.mockRestore();
|
||||||
|
});
|
||||||
|
|||||||
@@ -424,7 +424,22 @@ function ContentTab({ settings, update, outline }: {
|
|||||||
};
|
};
|
||||||
const updateCustom = (key: string, patch: Partial<CvCustomSectionSetting>) =>
|
const updateCustom = (key: string, patch: Partial<CvCustomSectionSetting>) =>
|
||||||
update({ customSections: settings.customSections.map((c) => (c.key === key ? { ...c, ...patch } : c)) });
|
update({ customSections: settings.customSections.map((c) => (c.key === key ? { ...c, ...patch } : c)) });
|
||||||
const removeCustom = (key: string) => update({ customSections: settings.customSections.filter((c) => c.key !== key) });
|
const removeCustom = (section: CvCustomSectionSetting) => {
|
||||||
|
if (!window.confirm(`Delete the custom section "${section.title || "Untitled"}"?`)) return;
|
||||||
|
update({ customSections: settings.customSections.filter((c) => c.key !== section.key) });
|
||||||
|
};
|
||||||
|
const moveCustom = (index: number, delta: number) =>
|
||||||
|
update({ customSections: moveItem(settings.customSections, index, index + delta) });
|
||||||
|
const updateCustomItem = (key: string, index: number, value: string) => {
|
||||||
|
const section = settings.customSections.find((item) => item.key === key);
|
||||||
|
if (!section) return;
|
||||||
|
updateCustom(key, { items: section.items.map((item, itemIndex) => itemIndex === index ? value : item) });
|
||||||
|
};
|
||||||
|
const removeCustomItem = (section: CvCustomSectionSetting, index: number) => {
|
||||||
|
const value = section.items[index];
|
||||||
|
if (value.trim() && !window.confirm("Delete this custom section entry?")) return;
|
||||||
|
updateCustom(section.key, { items: section.items.filter((_, itemIndex) => itemIndex !== index) });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
@@ -466,15 +481,38 @@ function ContentTab({ settings, update, outline }: {
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Stack spacing={1} sx={{ mt: 1 }}>
|
<Stack spacing={1} sx={{ mt: 1 }}>
|
||||||
{settings.customSections.map((c) => (
|
{settings.customSections.map((c, sectionIndex) => (
|
||||||
<Paper key={c.key} variant="outlined" sx={{ p: 1 }}>
|
<Paper key={c.key} variant="outlined" sx={{ p: 1 }}>
|
||||||
<Stack direction="row" alignItems="center" spacing={1}>
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
<TextField variant="standard" fullWidth value={c.title ?? ""} placeholder="Section title"
|
<TextField variant="standard" fullWidth value={c.title ?? ""} placeholder="Section title"
|
||||||
|
error={!c.title?.trim()} helperText={!c.title?.trim() ? "Enter a section title." : undefined}
|
||||||
onChange={(e) => updateCustom(c.key, { title: e.target.value })} slotProps={{ htmlInput: { "aria-label": "Custom section title" } }} />
|
onChange={(e) => updateCustom(c.key, { title: e.target.value })} slotProps={{ htmlInput: { "aria-label": "Custom section title" } }} />
|
||||||
<IconButton size="small" aria-label="Remove section" onClick={() => removeCustom(c.key)}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
<IconButton size="small" aria-label="Move custom section up" disabled={sectionIndex === 0} onClick={() => moveCustom(sectionIndex, -1)}><ArrowUpwardIcon fontSize="small" /></IconButton>
|
||||||
|
<IconButton size="small" aria-label="Move custom section down" disabled={sectionIndex === settings.customSections.length - 1} onClick={() => moveCustom(sectionIndex, 1)}><ArrowDownwardIcon fontSize="small" /></IconButton>
|
||||||
|
<IconButton size="small" aria-label={c.hidden ? "Show custom section" : "Hide custom section"} onClick={() => updateCustom(c.key, { hidden: !c.hidden })}>
|
||||||
|
{c.hidden ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" aria-label="Remove custom section" onClick={() => removeCustom(c)}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
<TextField multiline minRows={2} fullWidth size="small" sx={{ mt: 1 }} placeholder="One item per line"
|
{!c.hidden ? (
|
||||||
value={c.items.join("\n")} onChange={(e) => updateCustom(c.key, { items: e.target.value.split("\n") })} />
|
<Stack spacing={1} sx={{ mt: 1 }}>
|
||||||
|
{c.items.map((item, itemIndex) => (
|
||||||
|
<Stack key={`${c.key}-${itemIndex}`} direction="row" alignItems="flex-start" spacing={0.5}>
|
||||||
|
<TextField fullWidth size="small" multiline minRows={2} label={`Entry ${itemIndex + 1}`} value={item}
|
||||||
|
error={!item.trim()} helperText={!item.trim() ? "Enter content or delete this entry." : undefined}
|
||||||
|
onChange={(event) => updateCustomItem(c.key, itemIndex, event.target.value)} />
|
||||||
|
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} up`} disabled={itemIndex === 0}
|
||||||
|
onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex - 1) })}><ArrowUpwardIcon fontSize="small" /></IconButton>
|
||||||
|
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} down`} disabled={itemIndex === c.items.length - 1}
|
||||||
|
onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex + 1) })}><ArrowDownwardIcon fontSize="small" /></IconButton>
|
||||||
|
<IconButton size="small" aria-label={`Delete custom entry ${itemIndex + 1}`} onClick={() => removeCustomItem(c, itemIndex)}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => updateCustom(c.key, { items: [...c.items, ""] })}>Add entry</Button>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption" color="text.secondary">Hidden from this CV.</Typography>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
Reference in New Issue
Block a user