feat: complete release readiness work #28

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