diff --git a/craft/src/editor/LoadGate.conflict.test.tsx b/craft/src/editor/LoadGate.conflict.test.tsx new file mode 100644 index 0000000..bb4760d --- /dev/null +++ b/craft/src/editor/LoadGate.conflict.test.tsx @@ -0,0 +1,232 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +/** + * Data-safety: a failed initial load must never let autosave overwrite the + * saved site with a blank canvas. Drives the real LoadGate + TopBar + + * SiteDesignPanel against a mocked `fetch` (the backend contract) and fake + * timers; only `@craftjs/core`'s useEditor and sibling chrome are stubbed. + */ +const undoMock = vi.fn(); +const deserializeMock = vi.fn(); +vi.mock('@craftjs/core', () => ({ + useEditor: (collector?: (state: unknown, query: unknown) => Record) => { + const query = { + serialize: () => '{}', + history: { canUndo: () => false, canRedo: () => false }, + }; + const actions = { history: { undo: undoMock, redo: vi.fn() }, deserialize: deserializeMock }; + const collected = collector ? collector({}, query) : {}; + return { actions, query, ...collected }; + }, +})); +vi.mock('../panels/topbar/TemplateModal', () => ({ TemplateModal: () => null })); +vi.mock('../panels/topbar/HeadCodeModal', () => ({ HeadCodeModal: () => null })); +vi.mock('../panels/sitesmith/SitesmithButton', () => ({ SitesmithButton: () => null })); + +import { LoadGate } from './LoadGate'; +import { TopBar } from '../panels/topbar/TopBar'; +import { SiteDesignPanel } from '../panels/right/SiteDesignPanel'; +import { EditorConfigProvider } from '../state/EditorConfigContext'; +import { SaveGuardProvider, useSaveGuard } from '../state/SaveGuardContext'; +import { PageProvider } from '../state/PageContext'; +import { SiteDesignProvider } from '../state/SiteDesignContext'; +import { WhpConfig } from '../types'; + +const whpConfig: WhpConfig = { + user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1, + siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false, +}; + +const jsonResp = (body: unknown, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, +}); + +const reloadMock = vi.fn(); +let container: HTMLDivElement; +let root: Root; +let fetchMock: ReturnType; + +const saveCalls = () => fetchMock.mock.calls.filter(([u]) => String(u).includes('action=save')); +const saveBody = (i: number) => JSON.parse(saveCalls()[i][1].body); +const q = (sel: string) => container.querySelector(sel) as HTMLElement | null; +const flush = () => act(async () => { await Promise.resolve(); await Promise.resolve(); }); + +let guard: ReturnType; +const GuardProbe: React.FC = () => { guard = useSaveGuard(); return null; }; + +function mount() { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render( + + + + + + {}} showGuides onToggleGuides={() => {}} /> + + + + + + , + ); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'error').mockImplementation(() => {}); + undoMock.mockClear(); + deserializeMock.mockReset(); +}); + +afterEach(() => { + act(() => { root.unmount(); }); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + + +beforeEach(() => { reloadMock.mockReset(); }); + +const loadedThen = (rev: string | null, saveResponses: unknown[]) => { + const saves = [...saveResponses]; + fetchMock.mockImplementation(async (url: string) => + String(url).includes('action=load') + ? jsonResp({ success: true, project: null, rev }) + : (saves.shift() ?? jsonResp({ success: true }))); +}; +const clickSave = async () => { + await act(async () => { q('button[aria-label="Save Draft"]')!.click(); }); + await flush(); +}; +const conflict409 = jsonResp({ success: false, error: 'conflict', message: 'm', current_rev: 'r9' }, 409); + +describe('revision tracking', () => { + test('base_rev from load is sent, and rev updates after each successful save', async () => { + loadedThen('r1', [jsonResp({ success: true, rev: 'r2' }), jsonResp({ success: true, rev: 'r3' })]); + mount(); + await flush(); + await clickSave(); + await clickSave(); + expect(saveBody(0).base_rev).toBe('r1'); + expect(saveBody(1).base_rev).toBe('r2'); + expect(saveBody(0)._rev).toBeUndefined(); + }); + + test('a project-less site sends base_rev: null (key present)', async () => { + loadedThen(null, []); + mount(); + await flush(); + await clickSave(); + expect('base_rev' in saveBody(0)).toBe(true); + expect(saveBody(0).base_rev).toBeNull(); + }); + + test('autosave sends base_rev too', async () => { + loadedThen('r1', []); + mount(); + await flush(); + act(() => { guard.markDirty(); }); + await act(async () => { vi.advanceTimersByTime(30000); }); + expect(saveBody(0).base_rev).toBe('r1'); + }); + + test('a load without rev (older backend) sends no base_rev', async () => { + fetchMock.mockImplementation(async (url: string) => + jsonResp(String(url).includes('action=load') ? { success: true, project: null } : { success: true })); + mount(); + await flush(); + await clickSave(); + expect('base_rev' in saveBody(0)).toBe(false); + }); +}); + +describe('conflict modal', () => { + test('a 409 conflict opens the modal (not the toast) and pauses autosave', async () => { + loadedThen('r1', [conflict409]); + mount(); + await flush(); + await clickSave(); + expect(q('[data-testid="conflict-modal"]')!.textContent).toContain( + 'This site was changed in another tab or device since you opened it.', + ); + expect(q('[data-testid="error-notice"]')).toBeNull(); + act(() => { guard.markDirty(); }); + await act(async () => { vi.advanceTimersByTime(90000); }); + expect(saveCalls()).toHaveLength(1); + }); + + test('Keep mine re-saves with force_overwrite, stores the new rev and closes the modal', async () => { + loadedThen('r1', [conflict409, jsonResp({ success: true, rev: 'r10' }), jsonResp({ success: true })]); + mount(); + await flush(); + await clickSave(); + await act(async () => { q('[data-testid="conflict-keep-mine"]')!.click(); }); + await flush(); + expect(saveBody(0).force_overwrite).toBeUndefined(); + expect(saveBody(1).force_overwrite).toBe(true); + expect(q('[data-testid="conflict-modal"]')).toBeNull(); + await clickSave(); + expect(saveBody(2).base_rev).toBe('r10'); + expect(saveBody(2).force_overwrite).toBeUndefined(); + }); + + test('Reload latest suppresses the unload prompt, reloads, and sends no save', async () => { + loadedThen('r1', [conflict409]); + mount(); + await flush(); + await clickSave(); + const prevented: boolean[] = []; + act(() => { guard.markDirty(); }); + act(() => { q('[data-testid="conflict-reload"]')!.click(); }); + const ev = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(ev); + prevented.push(ev.defaultPrevented); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(prevented).toEqual([false]); + expect(saveCalls()).toHaveLength(1); + }); + + test('a conflict inside publish opens the modal and never publishes', async () => { + loadedThen('r1', [conflict409]); + mount(); + await flush(); + await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); }); + await flush(); + expect(q('[data-testid="conflict-modal"]')).not.toBeNull(); + expect(fetchMock.mock.calls.filter(([u]) => String(u).includes('action=publish'))).toHaveLength(0); + expect(q('[data-testid="error-notice"]')).toBeNull(); + }); + + test('Keep mine refused as empty falls to the banner, and Save anyway still overwrites', async () => { + loadedThen('r1', [ + conflict409, + jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'e' }, 409), + jsonResp({ success: true, rev: 'r5' }), + ]); + mount(); + await flush(); + await clickSave(); + await act(async () => { q('[data-testid="conflict-keep-mine"]')!.click(); }); + await flush(); + expect(q('[data-testid="conflict-modal"]')).toBeNull(); + expect(q('[data-testid="empty-save-banner"]')).not.toBeNull(); + await act(async () => { q('[data-testid="empty-save-anyway"]')!.click(); }); + await flush(); + expect(saveBody(2).force_overwrite).toBe(true); + expect(saveBody(2).force_empty).toBe(true); + }); +}); diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts index 17b7041..9d27e64 100644 --- a/craft/src/hooks/useWhpApi.ts +++ b/craft/src/hooks/useWhpApi.ts @@ -277,6 +277,8 @@ async function readApiResult(resp: Response, action: 'Save' | 'Publish'): Promis export interface SaveOptions { forceEmpty?: boolean; + /** "Keep mine": bypass the another-tab conflict check. */ + forceOverwrite?: boolean; } export function useWhpApi() { @@ -296,7 +298,10 @@ export function useWhpApi() { } = usePages(); const { design, updateDesign } = useSiteDesign(); - const { loadState, forceEmptyNextSave, beginSave, endSave } = useSaveGuard(); + const { + loadState, forceEmptyNextSave, beginSave, endSave, + getRev, setRev, setConflict, forceOverwriteNextSave, + } = useSaveGuard(); const save = useCallback(async (opts?: SaveOptions): Promise => { if (!isWHP || !whpConfig) return null; @@ -314,7 +319,9 @@ export function useWhpApi() { // isEditingFooter). const liveCraftState = query.serialize(); + const baseRev = getRev(); const forceEmpty = !!opts?.forceEmpty || forceEmptyNextSave.current; + const forceOverwrite = !!opts?.forceOverwrite || forceOverwriteNextSave.current; const payload = buildSavePayload({ siteId: whpConfig.siteId, @@ -336,7 +343,14 @@ export function useWhpApi() { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken, }, - body: JSON.stringify(forceEmpty ? { ...payload, force_empty: true } : payload), + body: JSON.stringify({ + ...payload, + // Present on every save so the server can detect a newer copy; left + // out only when load never reported a rev (an older backend). + ...(baseRev !== undefined ? { base_rev: baseRev } : {}), + ...(forceEmpty ? { force_empty: true } : {}), + ...(forceOverwrite ? { force_overwrite: true } : {}), + }), }); // 409 empty_overwrite_refused is an expected outcome the UI handles, so @@ -350,20 +364,25 @@ export function useWhpApi() { message: body.message || 'This would replace your saved site with an empty page.', }; } - return body ?? { success: false, error: 'conflict' }; + // Opens the conflict modal (here, so the save inside publish raises it too). + const message = body?.message || 'This site was changed in another tab or device since you opened it.'; + setConflict(message); + return { ...(body ?? {}), success: false, error: 'conflict', message }; } const result = await readApiResult(resp, 'Save'); // The one-shot force_empty is spent only once a save actually went through. if (result.success) { if (forceEmpty) forceEmptyNextSave.current = false; + if (forceOverwrite) forceOverwriteNextSave.current = false; + if (typeof result.rev === 'string') setRev(result.rev); succeeded = true; } return result; } finally { endSave(ticket, succeeded); } - }, [isWHP, whpConfig, loadState, forceEmptyNextSave, beginSave, endSave, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]); + }, [isWHP, whpConfig, loadState, forceEmptyNextSave, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]); const publish = useCallback(async (): Promise => { if (!isWHP || !whpConfig) return null; @@ -420,6 +439,10 @@ export function useWhpApi() { throw new Error(`Load failed: ${data?.message || data?.error || 'server reported failure'}`); } + // The baseline for every later save's conflict check. proj._rev (inside + // the project) is ignored: buildSavePayload never echoes it back. + if (typeof data.rev === 'string' || data.rev === null) setRev(data.rev); + // project: null is a success (brand-new site, nothing saved yet). if (data.project) { const proj = data.project; @@ -514,7 +537,7 @@ export function useWhpApi() { } } return data; - }, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter, updateDesign]); + }, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter, updateDesign, setRev]); const uploadAsset = useCallback( async (file: File) => { diff --git a/craft/src/panels/topbar/ConflictModal.tsx b/craft/src/panels/topbar/ConflictModal.tsx new file mode 100644 index 0000000..cace451 --- /dev/null +++ b/craft/src/panels/topbar/ConflictModal.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +export interface ConflictModalProps { + /** Why the save was refused (the server's message); a fixed fallback when empty. */ + message?: string | null; + /** True while "Keep mine" is re-saving. */ + busy: boolean; + /** Set when "Keep mine" failed, so the customer sees why the modal is still up. */ + error?: string | null; + onReload: () => void; + onKeepMine: () => void; +} + +/** Blocking choice shown when the server holds a newer copy of the site than + * the one this editor opened (another tab or device saved). Autosave stays + * paused while it's up; there is deliberately no dismiss. */ +export const ConflictModal: React.FC = ({ busy, error, onReload, onKeepMine }) => ( +
+
+

Site changed elsewhere

+

This site was changed in another tab or device since you opened it.

+

Reload latest discards your changes here and loads the newer copy. Keep mine overwrites the newer copy with what you have now.

+ {error &&

{error}

} +
+ + +
+
+
+); diff --git a/craft/src/panels/topbar/TopBar.tsx b/craft/src/panels/topbar/TopBar.tsx index 404021b..f110586 100644 --- a/craft/src/panels/topbar/TopBar.tsx +++ b/craft/src/panels/topbar/TopBar.tsx @@ -13,6 +13,7 @@ import { ReportIssueModal } from './ReportIssueModal'; import { TopBarOverflowMenu } from './TopBarOverflowMenu'; import { PublishWarnings } from './PublishWarnings'; import { EmptySaveBanner } from './EmptySaveBanner'; +import { ConflictModal } from './ConflictModal'; import { ErrorNotice } from './ErrorNotice'; import { SaveStatusPill, SaveStatus } from './SaveStatusPill'; import { useSaveGuard } from '../../state/SaveGuardContext'; @@ -34,7 +35,10 @@ export const TopBar: React.FC = ({ device, onDeviceChange, showGuid canRedo: query.history.canRedo(), })); const { save, publish } = useWhpApi(); - const { loadState, emptyRefusal, setEmptyRefusal, dirty, isDirty, getVersion } = useSaveGuard(); + const { + loadState, emptyRefusal, setEmptyRefusal, dirty, isDirty, getVersion, + conflict, setConflict, armForceOverwriteNextSave, reloadLatest, + } = useSaveGuard(); const canSave = loadState === 'loaded'; const { headerPage, footerPage, pages, activePageId } = usePages(); const { design } = useSiteDesign(); @@ -80,9 +84,13 @@ export const TopBar: React.FC = ({ device, onDeviceChange, showGuid setSaving(true); try { const result = await save(opts); - if (result?.error === 'empty_overwrite_refused') { + if (result?.error === 'conflict') { + // The conflict modal (opened by save()) owns this outcome, not the error toast. + } else if (result?.error === 'empty_overwrite_refused') { + setConflict(null); setEmptyRefusal(result.message ?? null); } else if (result?.success) { + setConflict(null); setEmptyRefusal(null); setSaveFailed(false); setErrorNotice(null); @@ -99,18 +107,18 @@ export const TopBar: React.FC = ({ device, onDeviceChange, showGuid savingRef.current = false; setSaving(false); } - }, [save, canSave, isDirty, setEmptyRefusal]); + }, [save, canSave, isDirty, setEmptyRefusal, setConflict]); // Auto-save every 30 seconds useEffect(() => { // Paused until the site has loaded, and while the empty-save banner is up. - if (!isWHP || !canSave || emptyRefusal) return; + if (!isWHP || !canSave || emptyRefusal || conflict) return; // A running publish does its own save; a second one would race it. // Read through refs/isDirty() at tick time, never a stale closure. const autosaveDue = () => isDirty() && !publishingRef.current; const interval = setInterval(() => { if (autosaveDue()) void runSave(); }, 30000); return () => clearInterval(interval); - }, [isWHP, canSave, emptyRefusal, runSave, isDirty]); + }, [isWHP, canSave, emptyRefusal, conflict, runSave, isDirty]); const handleSave = runSave; @@ -123,7 +131,10 @@ export const TopBar: React.FC = ({ device, onDeviceChange, showGuid setPublishWarnings([]); try { const result = await publish(); - if (result?.error === 'empty_overwrite_refused') { + if (result?.error === 'conflict') { + // Nothing was published: the conflict modal (opened by save()) takes over. + setSaveFailed(true); + } else if (result?.error === 'empty_overwrite_refused') { setEmptyRefusal(result.message ?? null); } else if (result?.success) { const unchanged = getVersion() === versionAtStart; @@ -151,6 +162,24 @@ export const TopBar: React.FC = ({ device, onDeviceChange, showGuid } }, [publish, canSave, getVersion, setEmptyRefusal]); + // "Keep mine": arm force_overwrite so it survives an empty-overwrite refusal + // followed by "Save anyway", then re-save. runSave closes the modal on success. + const handleKeepMine = useCallback(async () => { + setErrorNotice(null); + armForceOverwriteNextSave(); + await runSave(); + }, [armForceOverwriteNextSave, runSave]); + + const conflictModal = conflict ? ( + { void handleKeepMine(); }} + /> + ) : null; + const emptyBanner = emptyRefusal ? ( { void handleSave({ forceEmpty: true }); }} @@ -252,6 +281,7 @@ export const TopBar: React.FC = ({ device, onDeviceChange, showGuid