diff --git a/craft/src/App.tsx b/craft/src/App.tsx index bd6ec11..8557289 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 } from './state/SaveGuardContext'; import { PageProvider } from './state/PageContext'; import { SitesmithProvider, useSitesmithModal } from './state/SitesmithContext'; import { SitesmithModal } from './panels/sitesmith/SitesmithModal'; @@ -23,6 +24,7 @@ const SitesmithModalMount: React.FC = () => { export const App: React.FC = ({ whpConfig }) => { return ( + @@ -33,6 +35,7 @@ export const App: React.FC = ({ whpConfig }) => { + ); }; diff --git a/craft/src/editor/EditorShell.tsx b/craft/src/editor/EditorShell.tsx index 5fa112c..4eb2e58 100644 --- a/craft/src/editor/EditorShell.tsx +++ b/craft/src/editor/EditorShell.tsx @@ -12,6 +12,7 @@ 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 { DeviceMode } from '../types'; const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides'; @@ -86,6 +87,7 @@ 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 } 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(); }); + +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(); +}); + +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('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(); + 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..70e69ba --- /dev/null +++ b/craft/src/editor/LoadGate.tsx @@ -0,0 +1,49 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { useEditorConfig } from '../state/EditorConfigContext'; +import { useSaveGuard } from '../state/SaveGuardContext'; +import { useWhpApi } 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 runLoad = useCallback(() => { + setLoadState('loading'); + load() + .then(() => setLoadState('loaded')) + .catch((e) => { + console.error('Failed to load project from WHP API:', e); + 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' ? ( +