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()} />
} onClick={exportPdf}>PDF
@@ -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 ----------