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 3781a43..6b771ff 100644 --- a/job-tracker-ui/src/cv-builder-deep-link.test.tsx +++ b/job-tracker-ui/src/cv-builder-deep-link.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import '@testing-library/jest-dom'; -import { render, screen } from '@testing-library/react'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import CvBuilderEditor from './views/CvBuilderEditor'; import { I18nProvider } from './i18n/I18nProvider'; @@ -37,14 +37,14 @@ const variant = { }; function renderAt(id: number) { + const router = createMemoryRouter([ + { path: '/career/builder/:id', element: }, + { path: '/career/builder', element:
CV list destination
}, + ], { initialEntries: [`/career/builder/${id}`] }); return render( - - - } /> - - + , ); @@ -52,6 +52,7 @@ function renderAt(id: number) { beforeEach(() => { jest.clearAllMocks(); + mockedApi.put.mockReset(); mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '

cv

', suggestedFileName: 'cv.pdf' } } as any); }); @@ -91,3 +92,44 @@ test("another user's variant is refused by the server and never rendered", async expect(await screen.findByText(/Could not open this CV/i)).toBeInTheDocument(); }); + +test('invalid names are explained and are not silently autosaved', async () => { + routeGet(() => Promise.resolve({ data: variant } as any)); + renderAt(3); + + fireEvent.change(await screen.findByLabelText('CV name'), { target: { value: '' } }); + + expect(screen.getByText('Enter a name before saving.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save now' })).toBeDisabled(); + expect(mockedApi.put).not.toHaveBeenCalled(); +}); + +test('failed autosave is visible and can be retried with the latest data', async () => { + routeGet(() => Promise.resolve({ data: variant } as any)); + mockedApi.put.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ data: variant } as any); + renderAt(3); + + fireEvent.change(await screen.findByLabelText('Headline override'), { target: { value: 'Platform Engineer' } }); + expect(screen.getByText('Unsaved')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Save now' })); + + expect(await screen.findByText('Save failed')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Save now' })); + expect(await screen.findByText('Saved')).toBeInTheDocument(); + expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({ + name: 'Backend CV', + settings: expect.objectContaining({ headline: 'Platform Engineer' }), + })); +}); + +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?'); + expect(screen.getByDisplayValue('Backend CV')).toBeInTheDocument(); + confirm.mockRestore(); +}); diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index 288c43b..d3566ff 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useBlocker, useNavigate, useParams } from "react-router-dom"; import { Alert, Box, Button, Chip, Collapse, Divider, FormControl, FormControlLabel, IconButton, InputLabel, @@ -67,6 +67,9 @@ export default function CvBuilderEditor() { const [loadError, setLoadError] = useState(null); const saveTimer = useRef | null>(null); + const saveRevision = useRef(0); + const latestSettings = useRef(null); + const latestName = useRef(""); const previewTimer = useRef | null>(null); const iframeRef = useRef(null); const scrollRef = useRef(null); @@ -96,8 +99,12 @@ export default function CvBuilderEditor() { }, [variantId]); const applyVariant = (variant: CvVariant) => { + if (saveTimer.current) clearTimeout(saveTimer.current); + saveRevision.current += 1; setName(variant.name); setSettings(variant.settings); + latestName.current = variant.name; + latestSettings.current = variant.settings; setIsPublic(variant.isPublic); setPublicSlug(variant.publicSlug); setSaveState("saved"); @@ -123,23 +130,40 @@ export default function CvBuilderEditor() { }; }, [settings]); + const performSave = useCallback(async (next: CvVariantSettings, nextName: string, revision: number) => { + setSaveState("saving"); + try { + await cvBuilderApi.save(variantId, { name: nextName, settings: next, source: "autosave" }); + if (saveRevision.current === revision) setSaveState("saved"); + return true; + } catch { + if (saveRevision.current === revision) setSaveState("error"); + return false; + } + }, [variantId]); + const scheduleSave = useCallback( - (next: CvVariantSettings, nextName?: string) => { + (next: CvVariantSettings, nextName = latestName.current) => { + latestSettings.current = next; + latestName.current = nextName; + const revision = ++saveRevision.current; setSaveState("unsaved"); if (saveTimer.current) clearTimeout(saveTimer.current); + if (!nextName.trim()) return; saveTimer.current = setTimeout(async () => { - setSaveState("saving"); - try { - await cvBuilderApi.save(variantId, { name: nextName ?? name, settings: next, source: "autosave" }); - setSaveState("saved"); - } catch { - setSaveState("error"); - } + await performSave(next, nextName, revision); }, 800); }, - [variantId, name], + [performSave], ); + const retrySave = useCallback(async () => { + if (!latestSettings.current || !latestName.current.trim()) return false; + if (saveTimer.current) clearTimeout(saveTimer.current); + const revision = ++saveRevision.current; + return performSave(latestSettings.current, latestName.current, revision); + }, [performSave]); + const update = (patch: Partial) => { setSettings((prev) => { if (!prev) return prev; @@ -151,9 +175,33 @@ export default function CvBuilderEditor() { const renameVariant = (value: string) => { setName(value); + latestName.current = value; if (settings) scheduleSave(settings, value); }; + const hasUnsavedChanges = saveState === "unsaved" || saveState === "saving" || saveState === "error"; + 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]); + + useEffect(() => { + const warnBeforeUnload = (event: BeforeUnloadEvent) => { + if (!hasUnsavedChanges) return; + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", warnBeforeUnload); + return () => window.removeEventListener("beforeunload", warnBeforeUnload); + }, [hasUnsavedChanges]); + + useEffect(() => () => { + if (saveTimer.current) clearTimeout(saveTimer.current); + }, []); + const togglePublic = async () => { try { const updated = await cvBuilderApi.setPublic(variantId, !isPublic); @@ -238,8 +286,9 @@ export default function CvBuilderEditor() { navigate("/career/builder")}> renameVariant(e.target.value)} + error={!name.trim()} helperText={!name.trim() ? "Enter a name before saving." : undefined} slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": "CV name" } }} /> - + void retrySave()} /> @@ -322,7 +371,7 @@ function EditorSkeleton() { ); } -function SaveBadge({ state }: { state: SaveState }) { +function SaveBadge({ state, canRetry, onRetry }: { state: SaveState; canRetry: boolean; onRetry: () => void }) { const map: Record = { idle: { label: "", color: "default" }, unsaved: { label: "Unsaved", color: "warning" }, @@ -332,7 +381,14 @@ function SaveBadge({ state }: { state: SaveState }) { }; const m = map[state]; if (!m.label) return null; - return ; + return ( + + + {(state === "unsaved" || state === "error") ? ( + + ) : null} + + ); } // ---------- Content tab ----------