fix(cv): protect revisions and measure rendering readiness
This commit is contained in:
@@ -60,16 +60,18 @@ beforeEach(() => {
|
||||
window.localStorage.removeItem('uiLanguage');
|
||||
jest.clearAllMocks();
|
||||
mockedApi.put.mockReset();
|
||||
mockedApi.put.mockResolvedValue({ data: { ...variant, version: variant.version + 1 } } as any);
|
||||
mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '<p>cv</p>', suggestedFileName: 'cv.pdf' } } as any);
|
||||
});
|
||||
|
||||
afterEach(() => window.localStorage.removeItem('uiLanguage'));
|
||||
|
||||
function routeGet(onVariant: () => Promise<any>, outline: any = { sections: [] }) {
|
||||
function routeGet(onVariant: () => Promise<any>, outline: any = { sections: [] }, history: any[] = []) {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/cv/variants/3') return onVariant();
|
||||
if (url === '/cv/themes') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/cv/outline') return Promise.resolve({ data: outline } as any);
|
||||
if (url === '/cv/variants/3/versions') return Promise.resolve({ data: history } as any);
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
});
|
||||
}
|
||||
@@ -153,6 +155,107 @@ test('session undo and redo restore content edits before autosave', async () =>
|
||||
expect(headline).toHaveValue('Platform Engineer');
|
||||
});
|
||||
|
||||
test('queued saves use acknowledged server versions without replacing newer local editing state', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant } as any));
|
||||
let release!: (value: any) => void;
|
||||
mockedApi.put.mockImplementationOnce(() => new Promise(resolve => { release = resolve; }));
|
||||
mockedApi.put.mockResolvedValueOnce({ data: { ...variant, version: 6 } } as any);
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
const headline = screen.getByLabelText('Headline override');
|
||||
fireEvent.change(headline, { target: { value: 'First edit' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(1));
|
||||
expect(mockedApi.put.mock.calls[0][1]).toEqual(expect.objectContaining({ expectedVersion: 4 }));
|
||||
fireEvent.change(headline, { target: { value: 'Second edit' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
release({ data: { ...variant, version: 5 } });
|
||||
await screen.findByText('Saved');
|
||||
expect(mockedApi.put.mock.calls[1][1]).toEqual(expect.objectContaining({ expectedVersion: 5, settings: expect.objectContaining({ headline: 'Second edit' }) }));
|
||||
expect(headline).toHaveValue('Second edit');
|
||||
});
|
||||
|
||||
test('revision conflict retains the draft and blocks later autosaves until explicit reload', async () => {
|
||||
let saved = variant;
|
||||
routeGet(() => Promise.resolve({ data: saved } as any));
|
||||
mockedApi.put.mockRejectedValueOnce({ response: { status: 409, data: { code: 'cv_revision_conflict' } } });
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
const headline = screen.getByLabelText('Headline override');
|
||||
fireEvent.change(headline, { target: { value: 'Recoverable draft' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
expect(await screen.findByText('CV changed elsewhere')).toBeInTheDocument();
|
||||
expect(headline).toHaveValue('Recoverable draft');
|
||||
fireEvent.change(headline, { target: { value: 'Still recoverable' } });
|
||||
expect(screen.queryByRole('button', { name: 'Save now' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download unsaved copy' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reload saved CV' }));
|
||||
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(headline).toHaveValue('Still recoverable');
|
||||
saved = { ...variant, version: 6, settings: { ...variant.settings, headline: 'Reviewed remote state' } };
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reload saved CV' }));
|
||||
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Reload saved CV' }));
|
||||
await waitFor(() => expect(headline).toHaveValue('Reviewed remote state'));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
mockedApi.put.mockResolvedValue({ data: { ...saved, version: 7 } } as any);
|
||||
fireEvent.change(headline, { target: { value: 'After review' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
await screen.findByText('Saved');
|
||||
expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({ expectedVersion: 6 }));
|
||||
});
|
||||
|
||||
test('edits made while a confirmed reload is in flight remain recoverable', async () => {
|
||||
let release!: (value: any) => void;
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
mockedApi.put.mockRejectedValue({ response: { status: 409, data: { code: 'cv_revision_conflict' } } });
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
const headline = screen.getByLabelText('Headline override');
|
||||
fireEvent.change(headline, { target: { value: 'Conflicting draft' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
await screen.findByText('CV changed elsewhere');
|
||||
routeGet(() => new Promise(resolve => { release = resolve; }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reload saved CV' }));
|
||||
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Reload saved CV' }));
|
||||
await waitFor(() => expect(release).toBeDefined());
|
||||
fireEvent.change(headline, { target: { value: 'New edit during reload' } });
|
||||
release({ data: { ...variant, version: 6, settings: { ...variant.settings, headline: 'Remote state' } } });
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(headline).toHaveValue('New edit during reload');
|
||||
expect(screen.getByText('CV changed elsewhere')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download unsaved copy' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('restoring history uses the current version and never replaces edits made during the request', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant }), { sections: [] }, [
|
||||
{ version: 4, isCurrent: true, source: 'manual', createdAtUtc: variant.updatedAtUtc },
|
||||
{ version: 3, isCurrent: false, source: 'manual', createdAtUtc: variant.updatedAtUtc },
|
||||
]);
|
||||
let release!: (value: any) => void;
|
||||
mockedApi.post.mockImplementation((url: string) => url.endsWith('/restore')
|
||||
? new Promise(resolve => { release = resolve; })
|
||||
: Promise.resolve({ data: { html: '<p>CV</p>' } } as any));
|
||||
renderAt(3);
|
||||
await screen.findByDisplayValue('Backend CV');
|
||||
expect(screen.getByRole('region', { name: 'CV editing controls' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tablist', { name: 'CV studio sections' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Restore version 3' }));
|
||||
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Restore version' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/versions/3/restore', { expectedVersion: 4 }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
|
||||
const headline = screen.getByLabelText('Headline override');
|
||||
fireEvent.change(headline, { target: { value: 'New edit during restore' } });
|
||||
release({ data: { ...variant, version: 5, settings: { ...variant.settings, headline: 'Historical content' } } });
|
||||
await screen.findByText('CV changed elsewhere');
|
||||
expect(headline).toHaveValue('New edit during restore');
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('professional editor starts with templates and combines design and layout controls', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant } as any));
|
||||
renderAt(3);
|
||||
|
||||
@@ -269,13 +269,13 @@ export const cvBuilderApi = {
|
||||
create: (body: { name?: string; jobApplicationId?: number | null; settings?: CvVariantSettings }) =>
|
||||
api.post<CvVariant>("/cv/variants", body).then((r) => r.data),
|
||||
get: (id: number) => api.get<CvVariant>(`/cv/variants/${id}`).then((r) => r.data),
|
||||
save: (id: number, body: { name?: string; settings: CvVariantSettings; source?: string }) =>
|
||||
save: (id: number, body: { name?: string; settings: CvVariantSettings; source?: string; expectedVersion: number }) =>
|
||||
api.put<CvVariant>(`/cv/variants/${id}`, body).then((r) => r.data),
|
||||
remove: (id: number) => api.delete(`/cv/variants/${id}`),
|
||||
duplicate: (id: number, name?: string) => api.post<CvVariant>(`/cv/variants/${id}/duplicate`, { name }).then((r) => r.data),
|
||||
setPublic: (id: number, isPublic: boolean) => api.put<CvVariant>(`/cv/variants/${id}/public`, { isPublic }).then((r) => r.data),
|
||||
versions: (id: number) => api.get<CvVariantVersionInfo[]>(`/cv/variants/${id}/versions`).then((r) => r.data),
|
||||
restore: (id: number, version: number) => api.post<CvVariant>(`/cv/variants/${id}/versions/${version}/restore`).then((r) => r.data),
|
||||
restore: (id: number, version: number, expectedVersion: number) => api.post<CvVariant>(`/cv/variants/${id}/versions/${version}/restore`, { expectedVersion }).then((r) => r.data),
|
||||
previewSettings: (settings: CvVariantSettings) => api.post<CvRender>("/cv/preview", { settings }).then((r) => r.data),
|
||||
aiAssist: (body: { action: string; text: string; role?: string; language?: string; context?: string }) =>
|
||||
api.post<{ original: string; result: string }>("/cv/ai/assist", body).then((r) => r.data),
|
||||
|
||||
@@ -526,6 +526,8 @@ export const translations = {
|
||||
cvEditorUndo: "Undo",
|
||||
cvEditorRedo: "Redo",
|
||||
cvEditorEdit: "Edit",
|
||||
cvEditorEditingRegion: "CV editing controls",
|
||||
cvEditorStudioNavigation: "CV studio sections",
|
||||
cvEditorPreview: "Preview",
|
||||
cvEditorDuplicate: "Duplicate",
|
||||
cvEditorUpdating: "Updating…",
|
||||
@@ -563,6 +565,11 @@ export const translations = {
|
||||
cvEditorSaving: "Saving…",
|
||||
cvEditorSaved: "Saved",
|
||||
cvEditorSaveFailed: "Save failed",
|
||||
cvEditorConflict: "CV changed elsewhere",
|
||||
cvEditorConflictMessage: "This CV changed in another editor. Your unsaved work is still here and has not overwritten the saved CV. Download a copy before reloading, then review your changes against the latest saved version.",
|
||||
cvEditorConflictDownload: "Download unsaved copy",
|
||||
cvEditorConflictReload: "Reload saved CV",
|
||||
cvEditorConflictReloadMessage: "Reloading replaces this editor's unsaved changes. Download your unsaved copy first if you want to keep it. Continue?",
|
||||
cvEditorSaveNow: "Save now",
|
||||
cvEditorNoVersions: "No saved versions yet. Edits autosave and appear here.",
|
||||
cvEditorVersionCount: "{count} saved version(s). Restoring keeps history — it adds a new version.",
|
||||
@@ -3057,6 +3064,8 @@ export const translations = {
|
||||
cvEditorUndo: "Angre",
|
||||
cvEditorRedo: "Gjør om",
|
||||
cvEditorEdit: "Rediger",
|
||||
cvEditorEditingRegion: "Redigeringskontroller for CV",
|
||||
cvEditorStudioNavigation: "Seksjoner i CV-studioet",
|
||||
cvEditorPreview: "Forhåndsvis",
|
||||
cvEditorDuplicate: "Dupliser",
|
||||
cvEditorUpdating: "Oppdaterer…",
|
||||
@@ -3094,6 +3103,11 @@ export const translations = {
|
||||
cvEditorSaving: "Lagrer…",
|
||||
cvEditorSaved: "Lagret",
|
||||
cvEditorSaveFailed: "Lagring mislyktes",
|
||||
cvEditorConflict: "CV-en er endret et annet sted",
|
||||
cvEditorConflictMessage: "CV-en ble endret i en annen editor. De ulagrede endringene dine er fortsatt her og har ikke overskrevet den lagrede CV-en. Last ned en kopi før du laster inn på nytt, og sammenlign med den nyeste lagrede versjonen.",
|
||||
cvEditorConflictDownload: "Last ned ulagret kopi",
|
||||
cvEditorConflictReload: "Last inn lagret CV",
|
||||
cvEditorConflictReloadMessage: "Dette erstatter ulagrede endringer i denne editoren. Last ned en ulagret kopi først hvis du vil beholde dem. Fortsette?",
|
||||
cvEditorSaveNow: "Lagre nå",
|
||||
cvEditorNoVersions: "Ingen lagrede versjoner ennå. Endringer lagres automatisk og vises her.",
|
||||
cvEditorVersionCount: "{count} lagret(e) versjon(er). Gjenoppretting beholder historikken og legger til en ny versjon.",
|
||||
|
||||
@@ -41,7 +41,7 @@ import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
const MIN_PREVIEW_ZOOM = 0.2;
|
||||
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error";
|
||||
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error" | "conflict";
|
||||
|
||||
function previewDocumentHtml(html: string, pageHeightPx: number, pageMarginMm: number, pageIndex = 0): string {
|
||||
const height = Math.max(1, Math.round(pageHeightPx * 100) / 100);
|
||||
@@ -89,6 +89,9 @@ export default function CvBuilderEditor() {
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const saveRevision = useRef(0);
|
||||
const saveQueue = useRef<Promise<boolean>>(Promise.resolve(true));
|
||||
const serverVersion = useRef(0);
|
||||
const editorEpoch = useRef(0);
|
||||
const revisionConflict = useRef(false);
|
||||
const latestSettings = useRef<CvVariantSettings | null>(null);
|
||||
const latestName = useRef("");
|
||||
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -101,8 +104,9 @@ export default function CvBuilderEditor() {
|
||||
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
|
||||
const pageMarginMm = useMemo(() => {
|
||||
const themeMargin = themes.find((theme) => theme.id === settings?.themeId)?.pageMarginMm;
|
||||
return settings?.pageMarginMm ?? themeMargin ?? 16;
|
||||
}, [settings?.pageMarginMm, settings?.themeId, themes]);
|
||||
const density = settings?.density === "compact" ? 0.82 : settings?.density === "roomy" ? 1.18 : 1;
|
||||
return (settings?.pageMarginMm ?? themeMargin ?? 16) * density;
|
||||
}, [settings?.pageMarginMm, settings?.themeId, settings?.density, themes]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -130,6 +134,9 @@ export default function CvBuilderEditor() {
|
||||
const applyVariant = (variant: CvVariant) => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveRevision.current += 1;
|
||||
editorEpoch.current += 1;
|
||||
serverVersion.current = variant.version;
|
||||
revisionConflict.current = false;
|
||||
setName(variant.name);
|
||||
setSettings(variant.settings);
|
||||
latestName.current = variant.name;
|
||||
@@ -167,14 +174,23 @@ export default function CvBuilderEditor() {
|
||||
}, [settings, previewRevision]);
|
||||
|
||||
const performSave = useCallback(async (next: CvVariantSettings, nextName: string, revision: number) => {
|
||||
const epoch = editorEpoch.current;
|
||||
const save = async () => {
|
||||
if (epoch !== editorEpoch.current || revisionConflict.current) return false;
|
||||
if (saveRevision.current === revision) setSaveState("saving");
|
||||
try {
|
||||
await cvBuilderApi.save(variantId, { name: nextName, settings: next, source: "autosave" });
|
||||
const saved = await cvBuilderApi.save(variantId, { name: nextName, settings: next, source: "autosave", expectedVersion: serverVersion.current });
|
||||
if (epoch !== editorEpoch.current) return false;
|
||||
serverVersion.current = saved.version;
|
||||
if (saveRevision.current === revision) setSaveState("saved");
|
||||
return true;
|
||||
} catch {
|
||||
if (saveRevision.current === revision) setSaveState("error");
|
||||
} catch (error) {
|
||||
if (epoch !== editorEpoch.current) return false;
|
||||
const code = (error as { response?: { data?: { code?: string } } })?.response?.data?.code;
|
||||
if (code === "cv_revision_conflict" || code === "cv_revision_required") {
|
||||
revisionConflict.current = true;
|
||||
setSaveState("conflict");
|
||||
} else if (saveRevision.current === revision) setSaveState("error");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -188,9 +204,9 @@ export default function CvBuilderEditor() {
|
||||
latestSettings.current = next;
|
||||
latestName.current = nextName;
|
||||
const revision = ++saveRevision.current;
|
||||
setSaveState("unsaved");
|
||||
setSaveState(revisionConflict.current ? "conflict" : "unsaved");
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
if (!nextName.trim()) return;
|
||||
if (!nextName.trim() || revisionConflict.current) return;
|
||||
saveTimer.current = setTimeout(async () => {
|
||||
await performSave(next, nextName, revision);
|
||||
}, 800);
|
||||
@@ -245,7 +261,27 @@ export default function CvBuilderEditor() {
|
||||
if (settings) scheduleSave(settings, value);
|
||||
};
|
||||
|
||||
const hasUnsavedChanges = saveState === "unsaved" || saveState === "saving" || saveState === "error";
|
||||
const hasUnsavedChanges = saveState === "unsaved" || saveState === "saving" || saveState === "error" || saveState === "conflict";
|
||||
|
||||
const downloadUnsavedCopy = () => {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify({ name: latestName.current, settings: latestSettings.current, expectedVersion: serverVersion.current }, null, 2)], { type: "application/json" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "unsaved-cv-settings.json";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const reloadSavedVariant = async () => {
|
||||
if (!(await confirmAction(t("cvEditorConflictReloadMessage"), { title: t("cvEditorConflictReload"), confirmLabel: t("cvEditorConflictReload"), destructive: true }))) return;
|
||||
const revision = saveRevision.current;
|
||||
await saveQueue.current;
|
||||
try {
|
||||
const saved = await cvBuilderApi.get(variantId);
|
||||
if (saveRevision.current === revision) applyVariant(saved);
|
||||
}
|
||||
catch (error) { toast(getApiErrorMessage(error, t("cvEditorOpenFailed")), "error"); }
|
||||
};
|
||||
const blocker = useBlocker(hasUnsavedChanges);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -356,11 +392,23 @@ export default function CvBuilderEditor() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updated = await cvBuilderApi.restore(variantId, version);
|
||||
applyVariant(updated);
|
||||
await saveQueue.current;
|
||||
const revision = saveRevision.current;
|
||||
const updated = await cvBuilderApi.restore(variantId, version, serverVersion.current);
|
||||
if (saveRevision.current === revision) applyVariant(updated);
|
||||
else {
|
||||
// Edits made while restore was in flight remain recoverable; never replace them on response.
|
||||
revisionConflict.current = true;
|
||||
setSaveState("conflict");
|
||||
}
|
||||
await loadVersions();
|
||||
toast(t("cvEditorRestoredVersion", { version }), "success");
|
||||
} catch (err) {
|
||||
const code = (err as { response?: { data?: { code?: string } } })?.response?.data?.code;
|
||||
if (code === "cv_revision_conflict" || code === "cv_revision_required") {
|
||||
revisionConflict.current = true;
|
||||
setSaveState("conflict");
|
||||
}
|
||||
toast(getApiErrorMessage(err, t("cvEditorRestoreFailed")), "error");
|
||||
}
|
||||
};
|
||||
@@ -423,6 +471,13 @@ export default function CvBuilderEditor() {
|
||||
|
||||
return (
|
||||
<Stack spacing={1.5} sx={{ minWidth: 0 }}>
|
||||
{saveState === "conflict" && <Alert severity="warning">
|
||||
{t("cvEditorConflictMessage")}
|
||||
<Stack direction="row" gap={1} flexWrap="wrap" sx={{ mt: 1 }}>
|
||||
<Button size="small" onClick={downloadUnsavedCopy}>{t("cvEditorConflictDownload")}</Button>
|
||||
<Button size="small" onClick={() => void reloadSavedVariant()}>{t("cvEditorConflictReload")}</Button>
|
||||
</Stack>
|
||||
</Alert>}
|
||||
<Paper component="header" sx={{ px: { xs: 1, sm: 1.5 }, py: 1, borderRadius: 3, border: "1px solid", borderColor: "divider" }}>
|
||||
<Stack direction="row" alignItems="center" gap={1} flexWrap="wrap">
|
||||
<Tooltip title={t("cvEditorBack")}><IconButton size="small" aria-label={t("cvEditorBack")} onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
|
||||
@@ -449,9 +504,9 @@ export default function CvBuilderEditor() {
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 480px) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
|
||||
<Paper onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" }, border: "1px solid", borderColor: "divider" }}>
|
||||
<Paper role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" }, border: "1px solid", borderColor: "divider" }}>
|
||||
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
|
||||
<Tabs aria-label={t("cvEditorStudioNavigation")} value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
|
||||
<Tab label={t("cvEditorTemplate")} />
|
||||
<Tab label={t("cvEditorContent")} />
|
||||
<Tab label={t("cvEditorCustomize")} />
|
||||
@@ -524,6 +579,7 @@ function SaveBadge({ state, canRetry, onRetry }: { state: SaveState; canRetry: b
|
||||
saving: { label: t("cvEditorSaving"), color: "warning" },
|
||||
saved: { label: t("cvEditorSaved"), color: "success" },
|
||||
error: { label: t("cvEditorSaveFailed"), color: "error" },
|
||||
conflict: { label: t("cvEditorConflict"), color: "warning" },
|
||||
};
|
||||
const m = map[state];
|
||||
if (!m.label) return null;
|
||||
@@ -1113,7 +1169,7 @@ function HistoryTab({ versions, onRestore }: { versions: CvVariantVersionInfo[];
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{relTime(v.createdAtUtc, language)} · {v.source}</Typography>
|
||||
</Box>
|
||||
{!v.isCurrent && <Button size="small" onClick={() => onRestore(v.version)}>{t("cvEditorRestore")}</Button>}
|
||||
{!v.isCurrent && <Button size="small" aria-label={t("cvEditorRestoreTitle", { version: v.version })} onClick={() => onRestore(v.version)}>{t("cvEditorRestore")}</Button>}
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function CvBuilderPage() {
|
||||
if (!nextName?.trim() || nextName.trim() === current?.name) return;
|
||||
try {
|
||||
const variant = await cvBuilderApi.get(id);
|
||||
const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual" });
|
||||
const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual", expectedVersion: variant.version });
|
||||
setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item));
|
||||
toast(t("cvDashboardRenamed"), "success");
|
||||
} catch (err) { toast(getApiErrorMessage(err, t("cvDashboardRenameFailed")), "error"); }
|
||||
|
||||
Reference in New Issue
Block a user