fix(cv): protect unsaved builder edits
This commit is contained in:
@@ -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: <CvBuilderEditor /> },
|
||||
{ path: '/career/builder', element: <div>CV list destination</div> },
|
||||
], { initialEntries: [`/career/builder/${id}`] });
|
||||
return render(
|
||||
<I18nProvider>
|
||||
<ToastProvider>
|
||||
<MemoryRouter initialEntries={[`/career/builder/${id}`]} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<Routes>
|
||||
<Route path="/career/builder/:id" element={<CvBuilderEditor />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
<RouterProvider router={router} future={{ v7_startTransition: true }} />
|
||||
</ToastProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
@@ -52,6 +52,7 @@ function renderAt(id: number) {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedApi.put.mockReset();
|
||||
mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '<p>cv</p>', 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();
|
||||
});
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const saveRevision = useRef(0);
|
||||
const latestSettings = useRef<CvVariantSettings | null>(null);
|
||||
const latestName = useRef("");
|
||||
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(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<CvVariantSettings>) => {
|
||||
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() {
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<Tooltip title="Back to CVs"><IconButton size="small" onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<TextField variant="standard" fullWidth value={name} onChange={(e) => 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" } }} />
|
||||
<SaveBadge state={saveState} />
|
||||
<SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} />
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: "wrap", gap: 1 }}>
|
||||
<Button size="small" variant="outlined" startIcon={<PictureAsPdfIcon />} onClick={exportPdf}>PDF</Button>
|
||||
@@ -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<SaveState, { label: string; color: "default" | "warning" | "success" | "error" }> = {
|
||||
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 <Chip size="small" label={m.label} color={m.color} variant="outlined" aria-live="polite" />;
|
||||
return (
|
||||
<Stack spacing={0.5} alignItems="flex-end" aria-live="polite">
|
||||
<Chip size="small" label={m.label} color={m.color} variant="outlined" />
|
||||
{(state === "unsaved" || state === "error") ? (
|
||||
<Button size="small" disabled={!canRetry} onClick={onRetry}>Save now</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Content tab ----------
|
||||
|
||||
Reference in New Issue
Block a user