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 a3b3b38..d300f58 100644 --- a/craft/src/App.tsx +++ b/craft/src/App.tsx @@ -6,7 +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 { SaveGuardProvider, useSaveGuard } from './state/SaveGuardContext'; import { PageProvider } from './state/PageContext'; import { SitesmithProvider, useSitesmithModal } from './state/SitesmithContext'; import { SitesmithModal } from './panels/sitesmith/SitesmithModal'; @@ -21,19 +21,30 @@ 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 4eb2e58..943ffe9 100644 --- a/craft/src/editor/EditorShell.tsx +++ b/craft/src/editor/EditorShell.tsx @@ -13,6 +13,7 @@ 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'; @@ -88,6 +89,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: vi.fn(), redo: vi.fn() }, deserialize: vi.fn() }; + 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 { DirtyTracker } from './DirtyTracker'; +import { TopBar } from '../panels/topbar/TopBar'; +import { EditorConfigProvider } from '../state/EditorConfigContext'; +import { SaveGuardProvider, useSaveGuard } from '../state/SaveGuardContext'; +import { PageProvider } from '../state/PageContext'; +import { SiteDesignProvider, useSiteDesign } 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 htmlResp = (status: number) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => { throw new SyntaxError('Unexpected token <'); }, +}); + +let container: HTMLDivElement; +let root: Root; +let fetchMock: ReturnType; +let guard: ReturnType; +let design: ReturnType; + +const Probe: React.FC = () => { + guard = useSaveGuard(); + design = useSiteDesign(); + return null; +}; + +const q = (sel: string) => container.querySelector(sel) as HTMLElement | null; +const pill = () => q('[data-testid="save-status-pill"]')!.getAttribute('data-status'); +const flush = () => act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); }); +const calls = (action: string) => fetchMock.mock.calls.filter(([u]) => String(u).includes(`action=${action}`)); +const edit = () => act(() => { guard.markDirty(); }); +const clickSave = async () => { await act(async () => { q('button[aria-label="Save Draft"]')!.click(); }); await flush(); }; +const clickPublish = async () => { await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); }); await flush(); }; + +function respondWith(handlers: { save?: unknown[]; publish?: unknown[] }) { + const save = [...(handlers.save ?? [])]; + const publish = [...(handlers.publish ?? [])]; + fetchMock.mockImplementation(async (url: string) => { + const u = String(url); + if (u.includes('action=load')) return jsonResp({ success: true, project: null, rev: null }); + if (u.includes('action=save')) return save.shift() ?? jsonResp({ success: true }); + if (u.includes('action=publish')) return publish.shift() ?? jsonResp({ success: true }); + throw new Error(`unexpected fetch ${u}`); + }); +} + +async function mount() { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render( + + + + + + + + {}} showGuides onToggleGuides={() => {}} /> + + + + , + ); + }); + await flush(); +} + +let addSpy: ReturnType; +let removeSpy: ReturnType; +const beforeUnloadAdds = () => addSpy.mock.calls.filter(([t]: unknown[]) => t === 'beforeunload').length; +const beforeUnloadRemoves = () => removeSpy.mock.calls.filter(([t]: unknown[]) => t === 'beforeunload').length; + +beforeEach(() => { + vi.useFakeTimers(); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'error').mockImplementation(() => {}); + addSpy = vi.spyOn(window, 'addEventListener'); + removeSpy = vi.spyOn(window, 'removeEventListener'); +}); + +afterEach(() => { + act(() => { root.unmount(); }); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('dirty lifecycle and status pill', () => { + test('loaded site is clean; an edit makes it dirty; only a successful save clears it', async () => { + respondWith({ save: [jsonResp({ success: false, error: 'x', message: 'nope' }, 500), jsonResp({ success: true })] }); + await mount(); + expect(guard.dirty).toBe(false); + expect(pill()).toBe('saved'); + + edit(); + expect(guard.dirty).toBe(true); + expect(pill()).toBe('unsaved'); + + await clickSave(); + expect(guard.dirty).toBe(true); + expect(pill()).toBe('failed'); + + await clickSave(); + expect(guard.dirty).toBe(false); + expect(pill()).toBe('saved'); + }); + + test('page/design state changes after load mark dirty; the load itself does not', async () => { + respondWith({}); + await mount(); + expect(guard.dirty).toBe(false); + act(() => { design.updateDesign({ headCode: '' }); }); + expect(guard.dirty).toBe(true); + }); + + test('canvas edits before the load finishes are ignored', async () => { + fetchMock.mockImplementation(() => new Promise(() => {})); + await mount(); + edit(); + expect(guard.dirty).toBe(false); + }); + + test('an edit made while a save is in flight keeps the editor dirty', async () => { + let finishSave!: (r: unknown) => void; + fetchMock.mockImplementation((url: string) => + String(url).includes('action=load') + ? Promise.resolve(jsonResp({ success: true, project: null, rev: null })) + : new Promise((res) => { finishSave = res; })); + await mount(); + edit(); + await act(async () => { q('button[aria-label="Save Draft"]')!.click(); }); + await flush(); + expect(pill()).toBe('saving'); + edit(); + await act(async () => { finishSave(jsonResp({ success: true })); }); + await flush(); + expect(guard.dirty).toBe(true); + expect(pill()).toBe('unsaved'); + }); + + test('Published persists with no timer, and the next edit turns it into Unsaved changes', async () => { + respondWith({}); + await mount(); + edit(); + await clickPublish(); + expect(pill()).toBe('published'); + await act(async () => { vi.advanceTimersByTime(10_000); }); + expect(pill()).toBe('published'); + edit(); + expect(pill()).toBe('unsaved'); + }); + + test('Saved persists with no timer', async () => { + respondWith({}); + await mount(); + edit(); + await clickSave(); + await act(async () => { vi.advanceTimersByTime(10_000); }); + expect(pill()).toBe('saved'); + }); +}); + +describe('Draft badge', () => { + test('a no-op autosave does not flip the badge to Draft; a save after real changes does', async () => { + respondWith({}); + await mount(); + await act(async () => { vi.advanceTimersByTime(30_000); }); + await flush(); + expect(calls('save')).toHaveLength(1); + expect(q('.publish-badge.draft')).toBeNull(); + + edit(); + await act(async () => { vi.advanceTimersByTime(30_000); }); + await flush(); + expect(q('.publish-badge.draft')).not.toBeNull(); + }); +}); + +describe('beforeunload', () => { + test('is registered only while dirty (or saving) and removed once saved', async () => { + respondWith({}); + await mount(); + expect(beforeUnloadAdds()).toBe(0); + + edit(); + expect(beforeUnloadAdds()).toBe(1); + const handler = addSpy.mock.calls.find(([t]: unknown[]) => t === 'beforeunload')![1] as EventListener; + const ev = new Event('beforeunload', { cancelable: true }); + handler(ev); + expect(ev.defaultPrevented).toBe(true); + + await clickSave(); + expect(beforeUnloadRemoves()).toBe(1); + }); + + test('suppressBeforeUnload lets a deliberate reload through without a prompt', async () => { + respondWith({}); + await mount(); + edit(); + const handler = addSpy.mock.calls.find(([t]: unknown[]) => t === 'beforeunload')![1] as EventListener; + act(() => { guard.suppressBeforeUnload(); }); + const ev = new Event('beforeunload', { cancelable: true }); + handler(ev); + expect(ev.defaultPrevented).toBe(false); + }); +}); + +describe('failed save blocks publish', () => { + test('no publish request is made and the save error is shown', async () => { + respondWith({ save: [jsonResp({ success: false, error: 'disk_full', message: 'Disk quota exceeded.' }, 500)] }); + await mount(); + edit(); + await clickPublish(); + + expect(calls('publish')).toHaveLength(0); + expect(q('[data-testid="error-notice"]')!.textContent).toContain('Disk quota exceeded.'); + expect(pill()).toBe('failed'); + }); + + test('a thrown save (network down) also blocks publish', async () => { + fetchMock.mockImplementation(async (url: string) => { + if (String(url).includes('action=load')) return jsonResp({ success: true, project: null, rev: null }); + throw new TypeError('Failed to fetch'); + }); + await mount(); + await clickPublish(); + expect(calls('publish')).toHaveLength(0); + expect(q('[data-testid="error-notice"]')!.textContent).toContain('Failed to fetch'); + }); +}); + +describe('error notice', () => { + test('persists past any timer until dismissed, and shows the server message', async () => { + // Every save fails, including the 30s autosaves that run during the wait. + fetchMock.mockImplementation(async (url: string) => + String(url).includes('action=load') + ? jsonResp({ success: true, project: null, rev: null }) + : jsonResp({ success: false, error: 'bad', message: 'Quota exceeded.' }, 500)); + await mount(); + edit(); + await clickSave(); + expect(q('[data-testid="error-notice"]')!.textContent).toContain('Quota exceeded.'); + + await act(async () => { vi.advanceTimersByTime(120_000); }); + expect(q('[data-testid="error-notice"]')).not.toBeNull(); + + act(() => { q('[data-testid="error-notice-dismiss"]')!.click(); }); + expect(q('[data-testid="error-notice"]')).toBeNull(); + }); + + test('a non-JSON 502 yields a readable error, not a parse exception', async () => { + respondWith({ save: [htmlResp(502)] }); + await mount(); + edit(); + await clickSave(); + expect(q('[data-testid="error-notice"]')!.textContent).toBe('Save failed: server returned HTTP 502'); + expect(pill()).toBe('failed'); + }); + + test('a non-JSON 502 on publish is readable too', async () => { + respondWith({ publish: [htmlResp(502)] }); + await mount(); + await clickPublish(); + expect(q('[data-testid="error-notice"]')!.textContent).toBe('Publish failed: server returned HTTP 502'); + expect(pill()).not.toBe('published'); + }); + + test('a later successful save clears the notice', async () => { + respondWith({ save: [htmlResp(502), jsonResp({ success: true })] }); + await mount(); + edit(); + await clickSave(); + await clickSave(); + expect(q('[data-testid="error-notice"]')).toBeNull(); + }); +}); + +describe('server refusal still goes to the dedicated banner', () => { + test('empty_overwrite_refused shows the banner, not the error notice', async () => { + respondWith({ save: [jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'r' }, 409)] }); + await mount(); + edit(); + await clickSave(); + expect(q('[data-testid="empty-save-banner"]')).not.toBeNull(); + expect(q('[data-testid="error-notice"]')).toBeNull(); + expect(pill()).toBe('unsaved'); + }); +}); diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts index 9bc459a..b830408 100644 --- a/craft/src/hooks/useWhpApi.ts +++ b/craft/src/hooks/useWhpApi.ts @@ -204,6 +204,41 @@ export class UnreadableSiteError extends Error { } } +/** What save()/publish() resolve to: the server's JSON, or a synthesized failure. */ +export interface ApiResult { + success: boolean; + error?: string; + message?: string; + warnings?: string[]; + [key: string]: unknown; +} + +/** + * Reads a save/publish response without ever throwing on the body: a non-2xx + * status or a non-JSON body (a 502 HTML page, a PHP fatal) becomes a + * `success:false` result carrying a readable message. + */ +async function readApiResult(resp: Response, action: 'Save' | 'Publish'): Promise { + let body: unknown = null; + try { + body = await resp.json(); + } catch { + body = null; + } + const obj = body && typeof body === 'object' ? (body as ApiResult) : null; + if (resp.ok && obj) return obj; + if (obj) { + return { ...obj, success: false, message: obj.message || `${action} failed: server returned HTTP ${resp.status}` }; + } + return { + success: false, + error: resp.ok ? 'invalid_response' : `http_${resp.status}`, + message: resp.ok + ? `${action} failed: the server sent an unreadable response` + : `${action} failed: server returned HTTP ${resp.status}`, + }; +} + export interface SaveOptions { forceEmpty?: boolean; } @@ -225,71 +260,97 @@ export function useWhpApi() { } = usePages(); const { design, updateDesign } = useSiteDesign(); - const { loadState, forceEmptyNextSave } = useSaveGuard(); + const { loadState, forceEmptyNextSave, beginSave, endSave } = useSaveGuard(); - const save = useCallback(async (opts?: SaveOptions) => { + const save = useCallback(async (opts?: SaveOptions): Promise => { if (!isWHP || !whpConfig) return null; // Never save before a successful load: the canvas may be blank only // because the load failed, and saving it would destroy the real site. if (loadState !== 'loaded') return null; - // Serialize whatever is currently on the live canvas (a page, the - // header, or the footer — depending on activePageId/isEditingHeader/ - // isEditingFooter). - const liveCraftState = query.serialize(); + // The ticket pins "what changed since" to this moment: edits made while + // the request is in flight keep the editor dirty after it succeeds. + const ticket = beginSave(); + let succeeded = false; + try { + // Serialize whatever is currently on the live canvas (a page, the + // header, or the footer — depending on activePageId/isEditingHeader/ + // isEditingFooter). + const liveCraftState = query.serialize(); - const forceEmpty = !!opts?.forceEmpty || forceEmptyNextSave.current; + const forceEmpty = !!opts?.forceEmpty || forceEmptyNextSave.current; - const payload = buildSavePayload({ - siteId: whpConfig.siteId, - siteName: whpConfig.siteName, - liveCraftState, - pages, - headerPage, - footerPage, - activePageId, - isEditingHeader, - isEditingFooter, - headCode: design.headCode, - design, - }); + const payload = buildSavePayload({ + siteId: whpConfig.siteId, + siteName: whpConfig.siteName, + liveCraftState, + pages, + headerPage, + footerPage, + activePageId, + isEditingHeader, + isEditingFooter, + headCode: design.headCode, + design, + }); - const resp = await fetch(`${whpConfig.apiUrl}?action=save`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': whpConfig.csrfToken, - }, - body: JSON.stringify(forceEmpty ? { ...payload, force_empty: true } : payload), - }); + const resp = await fetch(`${whpConfig.apiUrl}?action=save`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': whpConfig.csrfToken, + }, + body: JSON.stringify(forceEmpty ? { ...payload, force_empty: true } : payload), + }); - // 409 empty_overwrite_refused is an expected outcome the UI handles, so - // return it as a structured result instead of throwing. - if (resp.status === 409) { - const body = await resp.json().catch(() => null); - if (body?.error === 'empty_overwrite_refused') { - return { - success: false, - error: 'empty_overwrite_refused', - message: body.message || 'This would replace your saved site with an empty page.', - }; + // 409 empty_overwrite_refused is an expected outcome the UI handles, so + // return it as a structured result instead of throwing. + if (resp.status === 409) { + const body = await resp.json().catch(() => null); + if (body?.error === 'empty_overwrite_refused') { + return { + success: false, + error: 'empty_overwrite_refused', + message: body.message || 'This would replace your saved site with an empty page.', + }; + } + return body ?? { success: false, error: 'conflict' }; } - return body ?? { success: false, error: 'conflict' }; + + 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; + succeeded = true; + } + return result; + } finally { + endSave(ticket, succeeded); } + }, [isWHP, whpConfig, loadState, forceEmptyNextSave, beginSave, endSave, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]); - const result = await resp.json(); - // The one-shot force_empty is spent only once a save actually went through. - if (forceEmpty && result?.success) forceEmptyNextSave.current = false; - return result; - }, [isWHP, whpConfig, loadState, forceEmptyNextSave, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]); - - const publish = useCallback(async () => { + const publish = useCallback(async (): Promise => { if (!isWHP || !whpConfig) return null; if (loadState !== 'loaded') return null; // First save to ensure staging is up to date - const saved = await save(); - if (saved?.error === 'empty_overwrite_refused') return saved; + let saved: ApiResult | null; + try { + saved = await save(); + } catch (e) { + saved = { success: false, error: 'save_failed', message: `Save failed: ${e instanceof Error ? e.message : String(e)}` }; + } + // Publishing pushes the staged copy live, so it must be the copy we just + // saved: a failed save (including a refusal) means no publish request. + if (!saved?.success) { + return { + ...saved, + success: false, + failedStep: 'save', + error: saved?.error ?? 'save_failed', + message: saved?.message || 'Publish cancelled: the save before publishing did not complete.', + } as ApiResult; + } // Then publish from staging to live const resp = await fetch( @@ -303,7 +364,7 @@ export function useWhpApi() { body: JSON.stringify({ site_id: whpConfig.siteId }), }, ); - return resp.json(); + return readApiResult(resp, 'Publish'); }, [isWHP, whpConfig, loadState, save]); const load = useCallback(async () => { diff --git a/craft/src/panels/topbar/ErrorNotice.tsx b/craft/src/panels/topbar/ErrorNotice.tsx new file mode 100644 index 0000000..02d872c --- /dev/null +++ b/craft/src/panels/topbar/ErrorNotice.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +export interface ErrorNoticeProps { + /** The error to show (the server's `message` when it sent one); null hides the notice. */ + message: string | null; + onDismiss: () => void; +} + +/** The editor's error toast. It never times out: a failed save is something + * the customer must read, so it stays until dismissed. */ +export const ErrorNotice: React.FC = ({ message, onDismiss }) => { + if (!message) return null; + + return ( +
+