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 6b771ff..d82e9f3 100644 --- a/job-tracker-ui/src/cv-builder-deep-link.test.tsx +++ b/job-tracker-ui/src/cv-builder-deep-link.test.tsx @@ -133,3 +133,43 @@ test('internal navigation warns and can be cancelled before discarding a pending expect(screen.getByDisplayValue('Backend CV')).toBeInTheDocument(); 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(); +}); diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index d3566ff..01cb117 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -424,7 +424,22 @@ function ContentTab({ settings, update, outline }: { }; const updateCustom = (key: string, patch: Partial) => 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 ( @@ -466,15 +481,38 @@ function ContentTab({ settings, update, outline }: { )} - {settings.customSections.map((c) => ( + {settings.customSections.map((c, sectionIndex) => ( updateCustom(c.key, { title: e.target.value })} slotProps={{ htmlInput: { "aria-label": "Custom section title" } }} /> - removeCustom(c.key)}> + moveCustom(sectionIndex, -1)}> + moveCustom(sectionIndex, 1)}> + updateCustom(c.key, { hidden: !c.hidden })}> + {c.hidden ? : } + + removeCustom(c)}> - updateCustom(c.key, { items: e.target.value.split("\n") })} /> + {!c.hidden ? ( + + {c.items.map((item, itemIndex) => ( + + updateCustomItem(c.key, itemIndex, event.target.value)} /> + updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex - 1) })}> + updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex + 1) })}> + removeCustomItem(c, itemIndex)}> + + ))} + + + ) : ( + Hidden from this CV. + )} ))}