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 1288073..798afdd 100644 --- a/job-tracker-ui/src/cv-builder-deep-link.test.tsx +++ b/job-tracker-ui/src/cv-builder-deep-link.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import '@testing-library/jest-dom'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import CvBuilderEditor from './views/CvBuilderEditor'; @@ -8,6 +8,8 @@ import { I18nProvider } from './i18n/I18nProvider'; import { ToastProvider } from './toast'; import { api } from './api'; import { emptyCvVariantSettings } from './cvBuilder'; +import { ConfirmProvider } from './confirm'; +import { PromptProvider } from './prompt'; // The Application Workspace CV section deep-links straight into the builder at // /career/builder/:id. These tests cover that entry point: the variant loads, and a variant the @@ -44,7 +46,11 @@ function renderAt(id: number) { return render( - + + + + + , ); @@ -124,20 +130,20 @@ test('failed autosave is visible and can be retried with the latest data', async test('internal navigation warns and can be cancelled before discarding a pending edit', async () => { routeGet(() => Promise.resolve({ data: variant } as any)); - const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false); renderAt(3); fireEvent.change(await screen.findByLabelText('Headline override'), { target: { value: 'Unsaved headline' } }); fireEvent.click(screen.getByRole('button', { name: 'Back to CVs' })); - expect(confirm).toHaveBeenCalledWith('This CV has unsaved changes. Leave and discard them?'); + const dialog = await screen.findByRole('dialog', { name: 'Discard unsaved CV changes?' }); + expect(within(dialog).getByText('This CV has unsaved changes. Leave and discard them?')).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); 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'); @@ -152,11 +158,16 @@ test('custom entries can be added, edited, reordered and deleted with confirmati 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' })); + let dialog = await screen.findByRole('dialog', { name: 'Delete custom entry' }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(2); fireEvent.click(screen.getByRole('button', { name: 'Delete custom entry 1' })); - expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(1); + dialog = await screen.findByRole('dialog', { name: 'Delete custom entry' }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete entry' })); + await waitFor(() => expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(1)); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); fireEvent.click(screen.getByRole('button', { name: 'Save now' })); expect(await screen.findByText('Saved')).toBeInTheDocument(); @@ -166,12 +177,15 @@ test('custom entries can be added, edited, reordered and deleted with confirmati }), })); - confirm.mockReturnValueOnce(false).mockReturnValueOnce(true); fireEvent.click(screen.getByRole('button', { name: 'Remove custom section' })); + dialog = await screen.findByRole('dialog', { name: 'Delete custom section' }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); 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(); + dialog = await screen.findByRole('dialog', { name: 'Delete custom section' }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete section' })); + await waitFor(() => expect(screen.queryByLabelText('Custom section title')).not.toBeInTheDocument()); }); test('profile-backed sections expand, reorder, hide and persist variant-only overrides', async () => { diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index 33651cb..272eaec 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -31,6 +31,7 @@ import { cvBuilderApi, moveItem, } from "../cvBuilder"; import { useAccountPlan } from "../accountPlan"; +import { useDialogActions } from "../dialogs"; const FONTS = [ "'Segoe UI', Roboto, Arial, sans-serif", @@ -49,6 +50,7 @@ export default function CvBuilderEditor() { const variantId = Number(id); const navigate = useNavigate(); const { toast } = useToast(); + const { confirmAction } = useDialogActions(); const [name, setName] = useState(""); const [settings, setSettings] = useState(null); @@ -76,6 +78,7 @@ export default function CvBuilderEditor() { const iframeRef = useRef(null); const scrollRef = useRef(null); const contentHeight = useRef(A4_PAGE_PX); + const blockerPromptOpen = useRef(false); useEffect(() => { let alive = true; @@ -186,10 +189,22 @@ export default function CvBuilderEditor() { const blocker = useBlocker(hasUnsavedChanges); useEffect(() => { - if (blocker.state !== "blocked") return; - if (window.confirm("This CV has unsaved changes. Leave and discard them?")) blocker.proceed(); - else blocker.reset(); - }, [blocker]); + if (blocker.state !== "blocked") { + blockerPromptOpen.current = false; + return; + } + if (blockerPromptOpen.current) return; + blockerPromptOpen.current = true; + void confirmAction("This CV has unsaved changes. Leave and discard them?", { + title: "Discard unsaved CV changes?", + confirmLabel: "Discard and leave", + destructive: true, + }).then((confirmed) => { + blockerPromptOpen.current = false; + if (confirmed) blocker.proceed(); + else blocker.reset(); + }); + }, [blocker, confirmAction]); useEffect(() => { const warnBeforeUnload = (event: BeforeUnloadEvent) => { @@ -403,6 +418,7 @@ function ContentTab({ settings, update, outline }: { update: (p: Partial) => void; outline: CvOutline | null; }) { + const { confirmAction } = useDialogActions(); // Full section list = configured order (once touched) else default, always including every known key. const sectionRows: CvSectionSetting[] = useMemo(() => { const base = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key })); @@ -429,8 +445,12 @@ 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 = (section: CvCustomSectionSetting) => { - if (!window.confirm(`Delete the custom section "${section.title || "Untitled"}"?`)) return; + const removeCustom = async (section: CvCustomSectionSetting) => { + if (!(await confirmAction(`Delete the custom section "${section.title || "Untitled"}" and all of its entries?`, { + title: "Delete custom section", + confirmLabel: "Delete section", + destructive: true, + }))) return; update({ customSections: settings.customSections.filter((c) => c.key !== section.key) }); }; const moveCustom = (index: number, delta: number) => @@ -440,9 +460,13 @@ function ContentTab({ settings, update, outline }: { if (!section) return; updateCustom(key, { items: section.items.map((item, itemIndex) => itemIndex === index ? value : item) }); }; - const removeCustomItem = (section: CvCustomSectionSetting, index: number) => { + const removeCustomItem = async (section: CvCustomSectionSetting, index: number) => { const value = section.items[index]; - if (value.trim() && !window.confirm("Delete this custom section entry?")) return; + if (value.trim() && !(await confirmAction("Delete this custom section entry?", { + title: "Delete custom entry", + confirmLabel: "Delete entry", + destructive: true, + }))) return; updateCustom(section.key, { items: section.items.filter((_, itemIndex) => itemIndex !== index) }); }; @@ -497,7 +521,7 @@ function ContentTab({ settings, update, outline }: { updateCustom(c.key, { hidden: !c.hidden })}> {c.hidden ? : } - removeCustom(c)}> + void removeCustom(c)}> {!c.hidden ? ( @@ -510,7 +534,7 @@ function ContentTab({ settings, update, outline }: { onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex - 1) })}> updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex + 1) })}> - removeCustomItem(c, itemIndex)}> + void removeCustomItem(c, itemIndex)}> ))}