diff --git a/craft/src/App.dirty.test.tsx b/craft/src/App.dirty.test.tsx new file mode 100644 index 0000000..c26c77c --- /dev/null +++ b/craft/src/App.dirty.test.tsx @@ -0,0 +1,75 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { Frame, Element, useEditor } from '@craftjs/core'; +import { SaveAwareEditor } from './App'; +import { Container } from './components/layout/Container'; +import { ensureJsdomShims } from './test-utils/editorHarness'; +import { EditorConfigProvider } from './state/EditorConfigContext'; +import { SaveGuardProvider, useSaveGuard } from './state/SaveGuardContext'; +import { WhpConfig } from './types'; + +/** + * Against a REAL Craft editor: onNodesChange (what App wires to markDirty) + * must fire for content edits and must NOT fire for selection or for the + * load's own deserialize -- otherwise a freshly opened site would warn + * "unsaved changes" on close. + */ +const whpConfig: WhpConfig = { + user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1, + siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false, +}; + +let api: { actions: any; query: any; guard: ReturnType }; +const Capture: React.FC = () => { + const { actions, query } = useEditor(); + const guard = useSaveGuard(); + api = { actions, query, guard }; + return null; +}; + +let root: Root; +let container: HTMLDivElement; + +afterEach(() => { + act(() => { root.unmount(); }); + container.remove(); +}); + +const STATE = JSON.stringify({ + ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: {}, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {} }, +}); + +describe('canvas dirty tracking on a real Craft editor', () => { + test('load deserialize and selection stay clean; a prop edit makes it dirty', async () => { + ensureJsdomShims(); + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render( + + + + + + + + , + ); + }); + + // What LoadGate does: deserialize while loading, then flip to loaded. + act(() => { api.actions.deserialize(STATE); }); + act(() => { api.guard.setLoadState('loaded'); }); + await act(async () => { await Promise.resolve(); }); + expect(api.guard.dirty).toBe(false); + + act(() => { api.actions.selectNode('ROOT'); }); + expect(api.guard.dirty).toBe(false); + + act(() => { api.actions.setProp('ROOT', (p: any) => { p.tag = 'section'; }); }); + expect(api.guard.dirty).toBe(true); + }); +}); diff --git a/craft/src/App.tsx b/craft/src/App.tsx index bd6ec11..d300f58 100644 --- a/craft/src/App.tsx +++ b/craft/src/App.tsx @@ -6,6 +6,7 @@ import { componentResolver } from './components/resolver'; import { WhpConfig } from './types'; import { EditorConfigProvider } from './state/EditorConfigContext'; import { SiteDesignProvider } from './state/SiteDesignContext'; +import { SaveGuardProvider, useSaveGuard } from './state/SaveGuardContext'; import { PageProvider } from './state/PageContext'; import { SitesmithProvider, useSitesmithModal } from './state/SitesmithContext'; import { SitesmithModal } from './panels/sitesmith/SitesmithModal'; @@ -20,19 +21,32 @@ const SitesmithModalMount: React.FC = () => { return ; }; +// Craft fires onNodesChange only when the serialized node tree changes (not on +// selection, hover or DOM registration), which is exactly "the canvas was edited". +export const SaveAwareEditor: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { markDirty } = useSaveGuard(); + return ( + + {children} + + ); +}; + export const App: React.FC = ({ whpConfig }) => { return ( - - - - - - - - - - + + + + + + + + + + + + ); }; diff --git a/craft/src/editor/DirtyTracker.tsx b/craft/src/editor/DirtyTracker.tsx new file mode 100644 index 0000000..ffb66c9 --- /dev/null +++ b/craft/src/editor/DirtyTracker.tsx @@ -0,0 +1,33 @@ +import { useEffect, useRef } from 'react'; +import { useEditorConfig } from '../state/EditorConfigContext'; +import { useSaveGuard } from '../state/SaveGuardContext'; +import { usePages } from '../state/PageContext'; +import { useSiteDesign } from '../state/SiteDesignContext'; + +/** + * Marks the editor dirty when page, header/footer or design state changes + * after the site has loaded. Canvas edits arrive separately, through the + * `` hook in App.tsx. Renders nothing. + */ +export const DirtyTracker: React.FC = () => { + const { isWHP } = useEditorConfig(); + const { loadState, markDirty } = useSaveGuard(); + const { pages, headerPage, footerPage } = usePages(); + const { design } = useSiteDesign(); + const armedRef = useRef(false); + + useEffect(() => { + if (!isWHP || loadState !== 'loaded') { + armedRef.current = false; + return; + } + // The first pass after a load only records the loaded state as the baseline. + if (!armedRef.current) { + armedRef.current = true; + return; + } + markDirty(); + }, [isWHP, loadState, pages, headerPage, footerPage, design, markDirty]); + + return null; +}; diff --git a/craft/src/editor/EditorShell.tsx b/craft/src/editor/EditorShell.tsx index 5fa112c..943ffe9 100644 --- a/craft/src/editor/EditorShell.tsx +++ b/craft/src/editor/EditorShell.tsx @@ -12,6 +12,8 @@ import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; import { useIsMobile } from '../hooks/useIsMobile'; import { MobileChromeProvider } from '../state/MobileChromeContext'; import { LayerFocusProvider } from '../panels/left/LayerFocusContext'; +import { LoadGate } from './LoadGate'; +import { DirtyTracker } from './DirtyTracker'; import { DeviceMode } from '../types'; const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides'; @@ -86,6 +88,8 @@ export const EditorShell: React.FC = () => {
+ + ({ + 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(); + reloadMock.mockReset(); + 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(); +}); + + +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(); + act(() => { guard.markDirty(); }); + act(() => { q('[data-testid="conflict-reload"]')!.click(); }); + const ev = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(ev); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(ev.defaultPrevented).toBe(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); + }); +}); + +describe('revision and overwrite edge cases', () => { + test('a brand-new site (rev null) saves, then the next save sends the returned rev', async () => { + loadedThen(null, [jsonResp({ success: true, rev: 'rX' })]); + mount(); + await flush(); + await clickSave(); + await clickSave(); + expect(saveBody(0).base_rev).toBeNull(); + expect(saveBody(1).base_rev).toBe('rX'); + }); + + test('Keep mine -> empty refusal -> Undo disarms force_overwrite: the next autosave is conflict-checked', async () => { + loadedThen('r1', [ + conflict409, + jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'e' }, 409), + jsonResp({ success: true, rev: 'r2' }), + ]); + mount(); + await flush(); + await clickSave(); + await act(async () => { q('[data-testid="conflict-keep-mine"]')!.click(); }); + await flush(); + expect(saveBody(1).force_overwrite).toBe(true); + act(() => { q('[data-testid="empty-save-undo"]')!.click(); }); + act(() => { guard.markDirty(); }); + await act(async () => { vi.advanceTimersByTime(30000); }); + await flush(); + expect(saveCalls()).toHaveLength(3); + expect(saveBody(2).force_overwrite).toBeUndefined(); + expect(saveBody(2).base_rev).toBe('r1'); + }); +}); diff --git a/craft/src/editor/LoadGate.save-guard.test.tsx b/craft/src/editor/LoadGate.save-guard.test.tsx new file mode 100644 index 0000000..4c6d67a --- /dev/null +++ b/craft/src/editor/LoadGate.save-guard.test.tsx @@ -0,0 +1,257 @@ +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, +}); + +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(); +}); + +describe('load gate', () => { + test('a failed load blocks autosave and manual save, and shows the failure message', async () => { + fetchMock.mockRejectedValue(new Error('network down')); + mount(); + await flush(); + + expect(q('[data-testid="load-gate"]')!.textContent).toContain( + "We couldn't load your site. Your saved work has not been changed.", + ); + await act(async () => { vi.advanceTimersByTime(30000); }); + expect(saveCalls()).toHaveLength(0); + expect((q('button[aria-label="Save Draft"]') as HTMLButtonElement).disabled).toBe(true); + expect((q('button[aria-label="Publish to live site"]') as HTMLButtonElement).disabled).toBe(true); + }); + + test('non-ok, non-JSON and success:false responses all count as failed', async () => { + for (const bad of [ + jsonResp({ success: true, project: null }, 500), + { ok: true, status: 200, json: async () => { throw new SyntaxError('bad json'); } }, + jsonResp({ success: false, message: 'nope' }), + ]) { + fetchMock.mockReset(); + fetchMock.mockResolvedValue(bad); + mount(); + await flush(); + expect(q('[data-testid="load-retry"]')).not.toBeNull(); + act(() => { root.unmount(); }); + container.remove(); + } + mount(); // so afterEach has something to unmount + }); + + test('a page state that fails to deserialize fails the load, names the problem, and never saves', async () => { + fetchMock.mockResolvedValue(jsonResp({ + success: true, + rev: 'r1', + project: { + pages_craft_state: [{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}' }], + }, + })); + deserializeMock.mockImplementationOnce(() => { throw new Error('malformed'); }); + mount(); + await flush(); + + expect(q('[data-testid="load-gate"]')!.textContent).toContain("We couldn't read part of your saved site."); + expect(q('[data-testid="load-retry"]')).not.toBeNull(); + await act(async () => { vi.advanceTimersByTime(30000); }); + expect(saveCalls()).toHaveLength(0); + }); + + test('canvas-changing top-bar controls are disabled until the site has loaded', async () => { + fetchMock.mockRejectedValue(new Error('down')); + mount(); + await flush(); + for (const label of ['Undo', 'Redo', 'Templates', 'Custom Head Code']) { + expect((q(`button[aria-label="${label}"]`) as HTMLButtonElement).disabled).toBe(true); + } + }); + + test('shows a loading overlay while the load is in flight', async () => { + fetchMock.mockReturnValue(new Promise(() => {})); + mount(); + await flush(); + expect(q('[data-testid="load-gate"]')!.textContent).toContain('Loading your site…'); + }); + + test('Retry recovers and re-enables saving', async () => { + fetchMock.mockRejectedValueOnce(new Error('boom')); + fetchMock.mockResolvedValue(jsonResp({ success: true, project: null, rev: null })); + mount(); + await flush(); + act(() => { q('[data-testid="load-retry"]')!.click(); }); + await flush(); + + expect(q('[data-testid="load-gate"]')).toBeNull(); + expect((q('button[aria-label="Save Draft"]') as HTMLButtonElement).disabled).toBe(false); + }); + + test('project:null counts as loaded and autosave then runs', async () => { + fetchMock.mockImplementation(async (url: string) => + String(url).includes('action=load') + ? jsonResp({ success: true, project: null, rev: null }) + : jsonResp({ success: true })); + mount(); + await flush(); + expect(q('[data-testid="load-gate"]')).toBeNull(); + // Autosave only fires for unsaved changes. + act(() => { guard.markDirty(); }); + await act(async () => { vi.advanceTimersByTime(30000); }); + expect(saveCalls()).toHaveLength(1); + }); +}); + +describe('empty-overwrite guard', () => { + const loadedThen = (saveResponses: unknown[]) => { + const saves = [...saveResponses]; + fetchMock.mockImplementation(async (url: string) => + String(url).includes('action=load') + ? jsonResp({ success: true, project: null, rev: null }) + : (saves.shift() ?? jsonResp({ success: true }))); + }; + + test('Reset Entire Site makes the next save send force_empty (once)', async () => { + loadedThen([]); + mount(); + await flush(); + + act(() => { q('[data-action="open-site-reset"]')!.click(); }); + const input = q('[data-testid="site-reset-domain"]') as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!; + act(() => { setter.call(input, 'example.com'); input.dispatchEvent(new Event('input', { bubbles: true })); }); + act(() => { q('[data-action="confirm-site-reset"]')!.click(); }); + + await act(async () => { q('button[aria-label="Save Draft"]')!.click(); }); + await flush(); + await act(async () => { q('button[aria-label="Save Draft"]')!.click(); }); + await flush(); + + expect(saveBody(0).force_empty).toBe(true); + expect(saveBody(1).force_empty).toBeUndefined(); + }); + + test('a 409 shows the banner, pauses autosave, and Save anyway re-sends with force_empty', async () => { + loadedThen([ + jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'refused' }, 409), + jsonResp({ success: true }), + ]); + mount(); + await flush(); + + await act(async () => { q('button[aria-label="Save Draft"]')!.click(); }); + await flush(); + expect(q('[data-testid="empty-save-banner"]')!.textContent).toContain( + 'This would replace your saved site with an empty page.', + ); + expect(saveBody(0).force_empty).toBeUndefined(); + + await act(async () => { vi.advanceTimersByTime(60000); }); + expect(saveCalls()).toHaveLength(1); // autosave paused + + await act(async () => { q('[data-testid="empty-save-anyway"]')!.click(); }); + await flush(); + expect(saveCalls()).toHaveLength(2); + expect(saveBody(1).force_empty).toBe(true); + expect(q('[data-testid="empty-save-banner"]')).toBeNull(); + }); + + test('Undo on the banner runs Craft history undo and dismisses it', async () => { + loadedThen([jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'r' }, 409)]); + mount(); + await flush(); + await act(async () => { q('button[aria-label="Save Draft"]')!.click(); }); + await flush(); + act(() => { q('[data-testid="empty-save-undo"]')!.click(); }); + expect(undoMock).toHaveBeenCalledTimes(1); + expect(q('[data-testid="empty-save-banner"]')).toBeNull(); + }); +}); diff --git a/craft/src/editor/LoadGate.tsx b/craft/src/editor/LoadGate.tsx new file mode 100644 index 0000000..c0cc861 --- /dev/null +++ b/craft/src/editor/LoadGate.tsx @@ -0,0 +1,56 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useEditorConfig } from '../state/EditorConfigContext'; +import { useSaveGuard } from '../state/SaveGuardContext'; +import { useWhpApi, UnreadableSiteError } from '../hooks/useWhpApi'; + +/** + * Owns the initial project load (WHP mode only) and blocks the whole editor + * until it succeeds. On failure nothing is editable and nothing can save, so + * a failed load can never be autosaved over the customer's real site. + */ +export const LoadGate: React.FC = () => { + const { isWHP } = useEditorConfig(); + const { loadState, setLoadState } = useSaveGuard(); + const { load } = useWhpApi(); + const startedRef = useRef(false); + const [unreadable, setUnreadable] = useState(false); + + const runLoad = useCallback(() => { + setUnreadable(false); + setLoadState('loading'); + load() + .then(() => setLoadState('loaded')) + .catch((e) => { + console.error('Failed to load project from WHP API:', e); + setUnreadable(e instanceof UnreadableSiteError); + setLoadState('failed'); + }); + }, [load, setLoadState]); + + useEffect(() => { + if (!isWHP || startedRef.current) return; + startedRef.current = true; + runLoad(); + }, [isWHP, runLoad]); + + if (!isWHP || loadState === 'loaded') return null; + + return ( +
+ {loadState === 'loading' ? ( +