Merge polish/save-pipeline: load gate, honest save state, idle autosave, conflict handling (Refs #28 #30 #31 #33)
This commit is contained in:
commit
eb134a4743
25 files changed
+2166
-196
No files matched your search
@@ -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<typeof useSaveGuard> };
|
||||
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(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SaveGuardProvider>
|
||||
<SaveAwareEditor>
|
||||
<Frame><Element is={Container} canvas style={{}} /></Frame>
|
||||
<Capture />
|
||||
</SaveAwareEditor>
|
||||
</SaveGuardProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
+24
-10
@@ -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 <SitesmithModal target={target} onClose={close} />;
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<Editor resolver={componentResolver} enabled={true} onRender={RenderNode} onNodesChange={markDirty}>
|
||||
{children}
|
||||
</Editor>
|
||||
);
|
||||
};
|
||||
|
||||
export const App: React.FC<AppProps> = ({ whpConfig }) => {
|
||||
return (
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SiteDesignProvider>
|
||||
<Editor resolver={componentResolver} enabled={true} onRender={RenderNode}>
|
||||
<PageProvider>
|
||||
<SitesmithProvider>
|
||||
<EditorShell />
|
||||
<SitesmithModalMount />
|
||||
</SitesmithProvider>
|
||||
</PageProvider>
|
||||
</Editor>
|
||||
</SiteDesignProvider>
|
||||
<SaveGuardProvider>
|
||||
<SiteDesignProvider>
|
||||
<SaveAwareEditor>
|
||||
<PageProvider>
|
||||
<SitesmithProvider>
|
||||
<EditorShell />
|
||||
<SitesmithModalMount />
|
||||
</SitesmithProvider>
|
||||
</PageProvider>
|
||||
</SaveAwareEditor>
|
||||
</SiteDesignProvider>
|
||||
</SaveGuardProvider>
|
||||
</EditorConfigProvider>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
* `<Editor onNodesChange>` 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;
|
||||
};
|
||||
@@ -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 = () => {
|
||||
<MobileChromeProvider>
|
||||
<LayerFocusProvider>
|
||||
<div className="editor-app">
|
||||
<LoadGate />
|
||||
<DirtyTracker />
|
||||
<TopBar
|
||||
device={device}
|
||||
onDeviceChange={setDevice}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* Two-tab conflict handling: rev tracking, base_rev on every save, and the
|
||||
* 409 conflict modal (Reload latest / Keep mine). Drives the real LoadGate +
|
||||
* TopBar + SiteDesignPanel against a mocked `fetch` 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<string, unknown>) => {
|
||||
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<typeof vi.fn>;
|
||||
|
||||
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<typeof useSaveGuard>;
|
||||
const GuardProbe: React.FC = () => { guard = useSaveGuard(); return null; };
|
||||
|
||||
function mount() {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SaveGuardProvider reloadPage={reloadMock}>
|
||||
<SiteDesignProvider>
|
||||
<PageProvider>
|
||||
<LoadGate />
|
||||
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
|
||||
<SiteDesignPanel />
|
||||
<GuardProbe />
|
||||
</PageProvider>
|
||||
</SiteDesignProvider>
|
||||
</SaveGuardProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) => {
|
||||
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<typeof vi.fn>;
|
||||
|
||||
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<typeof useSaveGuard>;
|
||||
const GuardProbe: React.FC = () => { guard = useSaveGuard(); return null; };
|
||||
|
||||
function mount() {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SaveGuardProvider>
|
||||
<SiteDesignProvider>
|
||||
<PageProvider>
|
||||
<LoadGate />
|
||||
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
|
||||
<SiteDesignPanel />
|
||||
<GuardProbe />
|
||||
</PageProvider>
|
||||
</SiteDesignProvider>
|
||||
</SaveGuardProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="load-gate" role={loadState === 'failed' ? 'alert' : 'status'} data-testid="load-gate">
|
||||
{loadState === 'loading' ? (
|
||||
<p><i className="fa fa-spinner fa-spin" aria-hidden="true" /> Loading your site…</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
{unreadable
|
||||
? "We couldn't read part of your saved site. Your saved work has not been changed. Contact support if this keeps happening."
|
||||
: "We couldn't load your site. Your saved work has not been changed."}
|
||||
</p>
|
||||
<button type="button" className="topbar-btn primary" onClick={runLoad} data-testid="load-retry">
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,434 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* Honest save state: dirty tracking, the status pill, the beforeunload
|
||||
* warning, the persistent error toast and "a failed save blocks publish".
|
||||
* Drives the real LoadGate + DirtyTracker + TopBar + SaveGuardProvider
|
||||
* against a mocked `fetch`; only `@craftjs/core`'s useEditor and sibling
|
||||
* chrome are stubbed (canvas edits are simulated with the same markDirty
|
||||
* App.tsx hands to Craft's onNodesChange).
|
||||
*/
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: (collector?: (state: unknown, query: unknown) => Record<string, unknown>) => {
|
||||
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<typeof vi.fn>;
|
||||
let guard: ReturnType<typeof useSaveGuard>;
|
||||
let design: ReturnType<typeof useSiteDesign>;
|
||||
|
||||
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(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SaveGuardProvider>
|
||||
<SiteDesignProvider>
|
||||
<PageProvider>
|
||||
<LoadGate />
|
||||
<DirtyTracker />
|
||||
<Probe />
|
||||
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
|
||||
</PageProvider>
|
||||
</SiteDesignProvider>
|
||||
</SaveGuardProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
}
|
||||
|
||||
let addSpy: ReturnType<typeof vi.spyOn>;
|
||||
let removeSpy: ReturnType<typeof vi.spyOn>;
|
||||
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: '<!-- x -->' }); });
|
||||
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('autosave only when dirty', () => {
|
||||
test('an idle 30s tick makes no request; an edit makes exactly one on the next tick', async () => {
|
||||
respondWith({});
|
||||
await mount();
|
||||
await act(async () => { vi.advanceTimersByTime(90_000); });
|
||||
await flush();
|
||||
expect(calls('save')).toHaveLength(0);
|
||||
|
||||
edit();
|
||||
await act(async () => { vi.advanceTimersByTime(30_000); });
|
||||
await flush();
|
||||
expect(calls('save')).toHaveLength(1);
|
||||
|
||||
// Saved and clean again: later ticks stay quiet.
|
||||
await act(async () => { vi.advanceTimersByTime(60_000); });
|
||||
await flush();
|
||||
expect(calls('save')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('the Save button still saves a clean editor', async () => {
|
||||
respondWith({});
|
||||
await mount();
|
||||
await clickSave();
|
||||
expect(calls('save')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Draft badge', () => {
|
||||
test('a manual save with nothing changed does not flip the badge to Draft; a save after real changes does', async () => {
|
||||
respondWith({});
|
||||
await mount();
|
||||
await clickSave();
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish racing an edit', () => {
|
||||
test('an edit during an in-flight publish leaves the site a draft, not Published', async () => {
|
||||
let finishPublish!: (r: unknown) => void;
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes('action=load')) return Promise.resolve(jsonResp({ success: true, project: null, rev: null }));
|
||||
if (u.includes('action=publish')) return new Promise((res) => { finishPublish = res; });
|
||||
return Promise.resolve(jsonResp({ success: true }));
|
||||
});
|
||||
await mount();
|
||||
edit();
|
||||
await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); });
|
||||
await flush();
|
||||
expect(calls('publish')).toHaveLength(1);
|
||||
|
||||
edit(); // typed while the publish request is out
|
||||
await act(async () => { finishPublish(jsonResp({ success: true })); });
|
||||
await flush();
|
||||
expect(pill()).toBe('unsaved');
|
||||
|
||||
await clickSave();
|
||||
expect(pill()).toBe('saved');
|
||||
expect(q('.publish-badge.draft')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('autosave does not start while a publish is running', async () => {
|
||||
let finishPublish!: (r: unknown) => void;
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes('action=load')) return Promise.resolve(jsonResp({ success: true, project: null, rev: null }));
|
||||
if (u.includes('action=publish')) return new Promise((res) => { finishPublish = res; });
|
||||
return Promise.resolve(jsonResp({ success: true }));
|
||||
});
|
||||
await mount();
|
||||
edit();
|
||||
await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); });
|
||||
await flush();
|
||||
const savesBefore = calls('save').length; // the publish's own save
|
||||
await act(async () => { vi.advanceTimersByTime(30_000); });
|
||||
await flush();
|
||||
expect(calls('save')).toHaveLength(savesBefore);
|
||||
await act(async () => { finishPublish(jsonResp({ success: true })); });
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
describe('populated project load', () => {
|
||||
test('pages, header/footer and design restored by load() leave the editor clean', async () => {
|
||||
const state = JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: {}, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
|
||||
});
|
||||
fetchMock.mockImplementation(async () => jsonResp({
|
||||
success: true,
|
||||
rev: 'r1',
|
||||
project: {
|
||||
design: { headCode: '<!-- loaded -->' },
|
||||
header_craft_state: state,
|
||||
footer_craft_state: state,
|
||||
pages_craft_state: [
|
||||
{ id: 'home', name: 'Home', slug: 'index', craftState: state },
|
||||
{ id: 'page_2', name: 'About', slug: 'about', craftState: state },
|
||||
],
|
||||
},
|
||||
}));
|
||||
await mount();
|
||||
await flush();
|
||||
expect(guard.loadState).toBe('loaded');
|
||||
expect(pill()).toBe('saved');
|
||||
expect(guard.dirty).toBe(false);
|
||||
expect(addSpy.mock.calls.filter(([t]: unknown[]) => t === 'beforeunload')).toHaveLength(0);
|
||||
|
||||
// ...and a real edit afterwards still registers.
|
||||
edit();
|
||||
expect(pill()).toBe('unsaved');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../utils/html-export', () => ({
|
||||
exportBodyHtml: vi.fn((state: string) => {
|
||||
// Like the real exportBodyHtml: failures are swallowed into an empty result.
|
||||
if (state.startsWith('BAD')) return { html: '', css: '' };
|
||||
return { html: `<p>${state}</p>`, css: `/*${state}*/` };
|
||||
}),
|
||||
}));
|
||||
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
import { buildSavePayload, clearExportCache } from './useWhpApi';
|
||||
import { PageData } from '../types';
|
||||
import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
|
||||
|
||||
const spy = vi.mocked(exportBodyHtml);
|
||||
const page = (id: string, craftState: string | null): PageData => ({ id, name: id, slug: id, craftState });
|
||||
const header = page('__header__', 'H1');
|
||||
const footer = page('__footer__', 'F1');
|
||||
|
||||
function build(pages: PageData[], live: string, activePageId = pages[0].id, h = header, f = footer) {
|
||||
return buildSavePayload({
|
||||
siteId: 1, siteName: 's', liveCraftState: live, pages, headerPage: h, footerPage: f,
|
||||
activePageId, isEditingHeader: false, isEditingFooter: false,
|
||||
headCode: DEFAULT_SITE_DESIGN.headCode, design: DEFAULT_SITE_DESIGN,
|
||||
});
|
||||
}
|
||||
const exported = () => spy.mock.calls.map(([s]) => s);
|
||||
|
||||
beforeEach(() => {
|
||||
clearExportCache();
|
||||
spy.mockClear();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('buildSavePayload export memoisation', () => {
|
||||
test('an identical second build only re-exports the live canvas page', () => {
|
||||
const pages = [page('home', 'HOME'), page('a', 'A'), page('b', 'B')];
|
||||
build(pages, 'LIVE1');
|
||||
expect(exported().sort()).toEqual(['A', 'B', 'F1', 'H1', 'LIVE1']);
|
||||
spy.mockClear();
|
||||
const p = build(pages, 'LIVE2');
|
||||
expect(exported()).toEqual(['LIVE2']);
|
||||
expect(p.pages.map((x) => x.html)).toEqual(['<p>LIVE2</p>', '<p>A</p>', '<p>B</p>']);
|
||||
});
|
||||
|
||||
test('only the page whose state changed is re-exported', () => {
|
||||
const pages = [page('home', 'HOME'), page('a', 'A'), page('b', 'B')];
|
||||
build(pages, 'LIVE');
|
||||
spy.mockClear();
|
||||
const p = build([pages[0], page('a', 'A2'), pages[2]], 'LIVE');
|
||||
expect(exported().sort()).toEqual(['A2', 'LIVE']);
|
||||
expect(p.pages[1].html).toBe('<p>A2</p>');
|
||||
expect(p.pages[2].html).toBe('<p>B</p>');
|
||||
});
|
||||
|
||||
test('header and footer are memoised the same way', () => {
|
||||
const pages = [page('home', 'HOME')];
|
||||
build(pages, 'LIVE');
|
||||
spy.mockClear();
|
||||
const p = build(pages, 'LIVE', 'home', page('__header__', 'H2'));
|
||||
expect(exported().sort()).toEqual(['H2', 'LIVE']);
|
||||
expect(p.header_html).toBe('<p>H2</p>');
|
||||
expect(p.footer_html).toBe('<p>F1</p>');
|
||||
});
|
||||
|
||||
test('a failed export is not cached and is retried on the next build', () => {
|
||||
const pages = [page('home', 'HOME'), page('x', 'BAD1')];
|
||||
const first = build(pages, 'LIVE');
|
||||
expect(first.pages[1].html).toBe('');
|
||||
spy.mockClear();
|
||||
build(pages, 'LIVE');
|
||||
expect(exported()).toContain('BAD1');
|
||||
});
|
||||
|
||||
test('a deleted page is evicted, so re-adding its state exports again', () => {
|
||||
const pages = [page('home', 'HOME'), page('a', 'A')];
|
||||
build(pages, 'LIVE');
|
||||
build([pages[0]], 'LIVE');
|
||||
spy.mockClear();
|
||||
build(pages, 'LIVE');
|
||||
expect(exported()).toContain('A');
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,7 @@ describe('useWhpApi load() restores PageData.seo (PKG-H §5)', () => {
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
project: {
|
||||
@@ -131,6 +132,7 @@ describe('useWhpApi load() restores PageData.seo (PKG-H §5)', () => {
|
||||
|
||||
test('a legacy project with no seo on any page loads without adding seo fields', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
project: {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { act } from 'react-dom/test-utils';
|
||||
import { EditorConfigProvider } from '../state/EditorConfigContext';
|
||||
import { EMPTY_CANVAS, PageProvider, usePages } from '../state/PageContext';
|
||||
import { SiteDesignProvider } from '../state/SiteDesignContext';
|
||||
import { useWhpApi } from './useWhpApi';
|
||||
import { useWhpApi, UnreadableSiteError } from './useWhpApi';
|
||||
import { WhpConfig } from '../types';
|
||||
|
||||
/**
|
||||
@@ -121,6 +121,7 @@ describe('useWhpApi load() repairs an orphaned node on the FIRST page before des
|
||||
|
||||
test('initial load with an orphaned first-page craftState reattaches it and warns', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
project: {
|
||||
@@ -168,9 +169,10 @@ describe('useWhpApi load() repairs an orphaned node on the FIRST page before des
|
||||
* load, which decides whether the user sees a working editor at all, both
|
||||
* failed harder (silently leaving the Frame undeserialized) and reported
|
||||
* quieter than every subsequent page switch. This pins the aligned
|
||||
* behaviour.
|
||||
* behaviour, except that the initial load now FAILS instead of falling back
|
||||
* to EMPTY_CANVAS (a blank canvas could be autosaved over the real page).
|
||||
*/
|
||||
describe('useWhpApi load() falls back to EMPTY_CANVAS when the first page state cannot be deserialized', () => {
|
||||
describe('useWhpApi load() fails (no EMPTY_CANVAS fallback) when the first page state cannot be deserialized', () => {
|
||||
beforeEach(() => {
|
||||
deserializeMock.mockClear();
|
||||
});
|
||||
@@ -179,8 +181,9 @@ describe('useWhpApi load() falls back to EMPTY_CANVAS when the first page state
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('a deserialize failure on the first page logs console.error and retries with EMPTY_CANVAS', async () => {
|
||||
test('a deserialize failure on the first page logs console.error and fails the load (no EMPTY_CANVAS fallback)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
project: {
|
||||
@@ -202,13 +205,16 @@ describe('useWhpApi load() falls back to EMPTY_CANVAS when the first page state
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const harness = render();
|
||||
|
||||
let caught: unknown;
|
||||
await act(async () => {
|
||||
await harness.get().load();
|
||||
await harness.get().load().catch((e: unknown) => { caught = e; });
|
||||
});
|
||||
|
||||
// First call (the broken state) threw; the second call is the fallback.
|
||||
expect(deserializeMock).toHaveBeenCalledTimes(2);
|
||||
expect(deserializeMock.mock.calls[1][0]).toBe(EMPTY_CANVAS);
|
||||
// The broken state threw and the load FAILS: no blank-canvas fallback
|
||||
// that autosave could later write over the customer's real page.
|
||||
expect(caught).toBeInstanceOf(UnreadableSiteError);
|
||||
expect(deserializeMock).toHaveBeenCalledTimes(1);
|
||||
expect(deserializeMock).not.toHaveBeenCalledWith(EMPTY_CANVAS);
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith('Failed to load page state:', expect.any(Error));
|
||||
|
||||
|
||||
+226
-49
@@ -1,11 +1,26 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { useEditorConfig } from '../state/EditorConfigContext';
|
||||
import { usePages, EMPTY_CANVAS } from '../state/PageContext';
|
||||
import { usePages } from '../state/PageContext';
|
||||
import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext';
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
import { repairOrphanNodes } from '../utils/orphan-repair';
|
||||
import { PageData } from '../types';
|
||||
import { useSaveGuard } from '../state/SaveGuardContext';
|
||||
|
||||
/**
|
||||
* Per-state export memo. A craft-state string fully determines its exported
|
||||
* HTML/CSS, so unchanged pages skip the export on every autosave. Only
|
||||
* stored (non-live) states are cached, failures never are, and each build
|
||||
* evicts every entry it did not use, so deleted/edited pages cannot pile up.
|
||||
*/
|
||||
type ExportResult = ReturnType<typeof exportBodyHtml>;
|
||||
const exportCache = new Map<string, ExportResult>();
|
||||
|
||||
/** Test hook: forget every memoised export. */
|
||||
export function clearExportCache() {
|
||||
exportCache.clear();
|
||||
}
|
||||
|
||||
export interface BuildSavePayloadInput {
|
||||
siteId: number;
|
||||
@@ -57,6 +72,24 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
|
||||
const isPageActive = !isEditingHeader && !isEditingFooter;
|
||||
|
||||
// Stored states touched by this build; the cache is pruned to this set.
|
||||
const usedStates = new Set<string>();
|
||||
/** Export a stored (not live) state, reusing the memo. Throws like exportBodyHtml. */
|
||||
const exportStored = (state: string): ExportResult => {
|
||||
usedStates.add(state);
|
||||
const hit = exportCache.get(state);
|
||||
if (hit) return hit;
|
||||
const result = exportBodyHtml(state);
|
||||
// exportBodyHtml swallows its own errors and returns empty html; a
|
||||
// non-empty state yielding '' is a failed export, so retry it next build.
|
||||
if (result.html === '' && state !== '') {
|
||||
console.error('HTML export produced no output; not caching, will retry next save.');
|
||||
} else {
|
||||
exportCache.set(state, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// I-1 (data-loss): `activePageId` can go dangling -- e.g. the original
|
||||
// Home page (id 'home') is deleted, its replacement gets a fresh id
|
||||
// (`page_<ts>`), and a reload re-initializes `activePageId` back to the
|
||||
@@ -78,7 +111,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
let headerHtml = '';
|
||||
try {
|
||||
if (headerCraftState) {
|
||||
headerHtml = exportBodyHtml(headerCraftState).html;
|
||||
headerHtml = (isEditingHeader ? exportBodyHtml(headerCraftState) : exportStored(headerCraftState)).html;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Header HTML export failed:', e);
|
||||
@@ -87,7 +120,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
let footerHtml = '';
|
||||
try {
|
||||
if (footerCraftState) {
|
||||
footerHtml = exportBodyHtml(footerCraftState).html;
|
||||
footerHtml = (isEditingFooter ? exportBodyHtml(footerCraftState) : exportStored(footerCraftState)).html;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Footer HTML export failed:', e);
|
||||
@@ -116,7 +149,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
currentCraftState = landingPage?.craftState ?? null;
|
||||
try {
|
||||
if (currentCraftState) {
|
||||
const result = exportBodyHtml(currentCraftState);
|
||||
const result = exportStored(currentCraftState);
|
||||
currentHtml = result.html;
|
||||
css = result.css;
|
||||
}
|
||||
@@ -140,7 +173,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
pageHtml = currentHtml;
|
||||
} else if (page.craftState) {
|
||||
try {
|
||||
pageHtml = exportBodyHtml(page.craftState).html;
|
||||
pageHtml = exportStored(page.craftState).html;
|
||||
} catch (e) {
|
||||
console.error(`HTML export failed for page ${page.name}:`, e);
|
||||
}
|
||||
@@ -178,6 +211,10 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
seo: page.seo,
|
||||
}));
|
||||
|
||||
for (const key of exportCache.keys()) {
|
||||
if (!usedStates.has(key)) exportCache.delete(key);
|
||||
}
|
||||
|
||||
return {
|
||||
site_id: siteId,
|
||||
name: siteName,
|
||||
@@ -195,6 +232,55 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
};
|
||||
}
|
||||
|
||||
/** A saved page's state could not be read; load() fails rather than opening a blank canvas. */
|
||||
export class UnreadableSiteError extends Error {
|
||||
constructor() {
|
||||
super('Saved page state could not be deserialized');
|
||||
this.name = 'UnreadableSiteError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 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<ApiResult> {
|
||||
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;
|
||||
/** "Keep mine": bypass the another-tab conflict check. */
|
||||
forceOverwrite?: boolean;
|
||||
}
|
||||
|
||||
export function useWhpApi() {
|
||||
const { query, actions } = useEditor();
|
||||
const { whpConfig, isWHP } = useEditorConfig();
|
||||
@@ -212,44 +298,114 @@ export function useWhpApi() {
|
||||
} = usePages();
|
||||
const { design, updateDesign } = useSiteDesign();
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const {
|
||||
loadState, forceEmptyNextSave, beginSave, endSave,
|
||||
getRev, setRev, setConflict, forceOverwriteNextSave,
|
||||
} = useSaveGuard();
|
||||
|
||||
const save = useCallback(async (opts?: SaveOptions): Promise<ApiResult | null> => {
|
||||
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 payload = buildSavePayload({
|
||||
siteId: whpConfig.siteId,
|
||||
siteName: whpConfig.siteName,
|
||||
liveCraftState,
|
||||
pages,
|
||||
headerPage,
|
||||
footerPage,
|
||||
activePageId,
|
||||
isEditingHeader,
|
||||
isEditingFooter,
|
||||
headCode: design.headCode,
|
||||
design,
|
||||
});
|
||||
const baseRev = getRev();
|
||||
const forceEmpty = !!opts?.forceEmpty || forceEmptyNextSave.current;
|
||||
const forceOverwrite = !!opts?.forceOverwrite || forceOverwriteNextSave.current;
|
||||
|
||||
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': whpConfig.csrfToken,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return resp.json();
|
||||
}, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]);
|
||||
const payload = buildSavePayload({
|
||||
siteId: whpConfig.siteId,
|
||||
siteName: whpConfig.siteName,
|
||||
liveCraftState,
|
||||
pages,
|
||||
headerPage,
|
||||
footerPage,
|
||||
activePageId,
|
||||
isEditingHeader,
|
||||
isEditingFooter,
|
||||
headCode: design.headCode,
|
||||
design,
|
||||
});
|
||||
|
||||
const publish = useCallback(async () => {
|
||||
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': whpConfig.csrfToken,
|
||||
},
|
||||
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
|
||||
// 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.',
|
||||
};
|
||||
}
|
||||
// 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, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]);
|
||||
|
||||
const publish = useCallback(async (): Promise<ApiResult | null> => {
|
||||
if (!isWHP || !whpConfig) return null;
|
||||
if (loadState !== 'loaded') return null;
|
||||
|
||||
// First save to ensure staging is up to date
|
||||
await save();
|
||||
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(
|
||||
@@ -263,8 +419,8 @@ export function useWhpApi() {
|
||||
body: JSON.stringify({ site_id: whpConfig.siteId }),
|
||||
},
|
||||
);
|
||||
return resp.json();
|
||||
}, [isWHP, whpConfig, save]);
|
||||
return readApiResult(resp, 'Publish');
|
||||
}, [isWHP, whpConfig, loadState, save]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!isWHP || !whpConfig) return null;
|
||||
@@ -272,9 +428,23 @@ export function useWhpApi() {
|
||||
const resp = await fetch(
|
||||
`${whpConfig.apiUrl}?action=load&site_id=${whpConfig.siteId}`,
|
||||
);
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(`Load failed: HTTP ${resp.status}`);
|
||||
let data;
|
||||
try {
|
||||
data = await resp.json();
|
||||
} catch {
|
||||
throw new Error('Load failed: response was not valid JSON');
|
||||
}
|
||||
if (!data || data.success !== true) {
|
||||
throw new Error(`Load failed: ${data?.message || data?.error || 'server reported failure'}`);
|
||||
}
|
||||
|
||||
if (data.success && data.project) {
|
||||
// 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;
|
||||
|
||||
// Restore site design tokens (colors/fonts/headCode) so the editor
|
||||
@@ -302,6 +472,19 @@ export function useWhpApi() {
|
||||
|
||||
// Restore pages and load the first page into the canvas
|
||||
if (proj.pages_craft_state && Array.isArray(proj.pages_craft_state) && proj.pages_craft_state.length > 0) {
|
||||
// Validate every page up front so one unreadable page fails the whole
|
||||
// load instead of surfacing later, on a page switch, as a blank canvas.
|
||||
for (const p of proj.pages_craft_state) {
|
||||
if (!p.craftState) continue;
|
||||
try {
|
||||
const parsed = typeof p.craftState === 'string' ? JSON.parse(p.craftState) : p.craftState;
|
||||
if (!parsed || typeof parsed !== 'object' || !parsed.ROOT) throw new Error('missing ROOT');
|
||||
} catch (e) {
|
||||
console.error('Failed to load page state:', e);
|
||||
throw new UnreadableSiteError();
|
||||
}
|
||||
}
|
||||
|
||||
setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null; seo?: PageData['seo'] }) => ({
|
||||
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null, seo: p.seo,
|
||||
})));
|
||||
@@ -332,16 +515,10 @@ export function useWhpApi() {
|
||||
}
|
||||
actions.deserialize(state);
|
||||
} catch (e) {
|
||||
// I5: this is the initial load that decides whether the user
|
||||
// sees a working editor at all -- align with `loadState`'s
|
||||
// behaviour (console.error + a known-safe fallback) instead of
|
||||
// warning quietly and leaving the Frame on whatever it last had.
|
||||
// Never fall back to a blank canvas: autosave would then replace
|
||||
// the customer's real (if unreadable) page with it. Fail the load.
|
||||
console.error('Failed to load page state:', e);
|
||||
try {
|
||||
actions.deserialize(EMPTY_CANVAS);
|
||||
} catch (_e2) {
|
||||
// give up
|
||||
}
|
||||
throw new UnreadableSiteError();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,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) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||
import { useSaveGuard } from '../../state/SaveGuardContext';
|
||||
import { SerializedTreeNode } from '../../types/sitesmith';
|
||||
import { FONT_FAMILIES } from '../../constants/presets';
|
||||
import { AssetPicker } from '../../ui/AssetPicker';
|
||||
@@ -224,6 +225,7 @@ export const SiteDesignPanel: React.FC = () => {
|
||||
const { design, updateDesign, resetToDefaults } = useSiteDesign();
|
||||
const { replaceAllPages, setHeader, setFooter } = usePages();
|
||||
const { whpConfig } = useEditorConfig();
|
||||
const { armForceEmptyNextSave } = useSaveGuard();
|
||||
// Standalone mode (no WHP_CONFIG) has no site domain to confirm against --
|
||||
// an empty typed input would then trivially "match" an empty siteDomain,
|
||||
// arming the destructive confirm button with no guard at all. The entry
|
||||
@@ -240,6 +242,9 @@ export const SiteDesignPanel: React.FC = () => {
|
||||
// future refactor that drops it).
|
||||
if (!siteResetConfirmMatches(siteResetTyped, siteDomain)) return;
|
||||
|
||||
// The user explicitly confirmed wiping the site, so the next save must
|
||||
// be allowed to replace the saved site with an empty one.
|
||||
armForceEmptyNextSave();
|
||||
replaceAllPages([{ name: 'Home', tree: BLANK_PAGE_TREE }]);
|
||||
setHeader(BLANK_HEADER_TREE);
|
||||
setFooter(BLANK_FOOTER_TREE);
|
||||
|
||||
@@ -2,9 +2,9 @@ import React from 'react';
|
||||
import { useSitesmith } from '../../hooks/useSitesmith';
|
||||
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||
|
||||
interface Props { onClick: () => void; }
|
||||
interface Props { onClick: () => void; disabled?: boolean; }
|
||||
|
||||
export const SitesmithButton: React.FC<Props> = ({ onClick }) => {
|
||||
export const SitesmithButton: React.FC<Props> = ({ onClick, disabled }) => {
|
||||
const cfg = useEditorConfig();
|
||||
const siteId = cfg.whpConfig?.siteId ?? 0;
|
||||
const { summary } = useSitesmith(siteId);
|
||||
@@ -14,6 +14,7 @@ export const SitesmithButton: React.FC<Props> = ({ onClick }) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="topbar-btn sitesmith-btn"
|
||||
title={locked ? 'Sitesmith — paid addon (click to learn more)' : 'Sitesmith AI Builder'}
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface ConflictModalProps {
|
||||
/** 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<ConflictModalProps> = ({ busy, error, onReload, onKeepMine }) => (
|
||||
<div className="conflict-backdrop" data-testid="conflict-modal">
|
||||
<div className="conflict-modal" role="alertdialog" aria-modal="true" aria-labelledby="conflict-modal-title">
|
||||
<h3 id="conflict-modal-title">Site changed elsewhere</h3>
|
||||
<p>This site was changed in another tab or device since you opened it.</p>
|
||||
<p>Reload latest discards your changes here and loads the newer copy. Keep mine overwrites the newer copy with what you have now.</p>
|
||||
{error && <p className="conflict-modal-error" role="alert" data-testid="conflict-error">{error}</p>}
|
||||
<div className="conflict-modal-actions">
|
||||
<button type="button" onClick={onReload} disabled={busy} data-testid="conflict-reload">Reload latest</button>
|
||||
<button type="button" onClick={onKeepMine} disabled={busy} data-testid="conflict-keep-mine">Keep mine</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface EmptySaveBannerProps {
|
||||
onSaveAnyway: () => void;
|
||||
onUndo: () => void;
|
||||
}
|
||||
|
||||
/** Persistent banner shown when the server refused a save that would replace
|
||||
* the saved site with an empty page. Autosave stays paused while it's up. */
|
||||
export const EmptySaveBanner: React.FC<EmptySaveBannerProps> = ({ onSaveAnyway, onUndo }) => (
|
||||
<div className="empty-save-banner" role="alert" data-testid="empty-save-banner">
|
||||
<i className="fa fa-exclamation-triangle" aria-hidden="true" />
|
||||
<span className="empty-save-banner-text">This would replace your saved site with an empty page.</span>
|
||||
<button type="button" onClick={onSaveAnyway} data-testid="empty-save-anyway">Save anyway</button>
|
||||
<button type="button" onClick={onUndo} data-testid="empty-save-undo">Undo</button>
|
||||
</div>
|
||||
);
|
||||
@@ -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<ErrorNoticeProps> = ({ message, onDismiss }) => {
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div className="error-notice" role="alert" data-testid="error-notice">
|
||||
<i className="fa fa-exclamation-triangle" aria-hidden="true" />
|
||||
<span className="error-notice-text">{message}</span>
|
||||
<button type="button" onClick={onDismiss} aria-label="Dismiss" data-testid="error-notice-dismiss">
|
||||
<i className="fa fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
export type SaveStatus = 'unsaved' | 'saving' | 'saved' | 'published' | 'failed';
|
||||
|
||||
const LABELS: Record<SaveStatus, string> = {
|
||||
unsaved: 'Unsaved changes',
|
||||
saving: 'Saving…',
|
||||
saved: 'Saved',
|
||||
published: 'Published',
|
||||
failed: 'Save failed',
|
||||
};
|
||||
|
||||
const ICONS: Record<SaveStatus, string> = {
|
||||
unsaved: 'fa-circle',
|
||||
saving: 'fa-spinner fa-spin',
|
||||
saved: 'fa-check',
|
||||
published: 'fa-check-circle',
|
||||
failed: 'fa-exclamation-triangle',
|
||||
};
|
||||
|
||||
/** Persistent save state. It changes only when the state does; nothing resets it on a timer. */
|
||||
export const SaveStatusPill: React.FC<{ status: SaveStatus }> = ({ status }) => (
|
||||
<span className={`save-pill ${status}`} role="status" data-testid="save-status-pill" data-status={status}>
|
||||
<i className={`fa ${ICONS[status]}`} aria-hidden="true" /> {LABELS[status]}
|
||||
</span>
|
||||
);
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import { useSaveGuard } from '../../state/SaveGuardContext';
|
||||
import {
|
||||
allTemplates,
|
||||
TemplateDefinition,
|
||||
@@ -48,6 +49,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
|
||||
const { actions, query } = useEditor();
|
||||
const { pages, addPage, switchPage, deletePage, editHeader, editFooter } = usePages();
|
||||
const { updateDesign } = useSiteDesign();
|
||||
const { armForceEmptyNextSave } = useSaveGuard();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>('all');
|
||||
const [confirmTemplate, setConfirmTemplate] = useState<TemplateDefinition | null>(null);
|
||||
@@ -231,13 +233,19 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
|
||||
if (keepId) {
|
||||
await applyHeaderFooter(tpl, keepId);
|
||||
}
|
||||
|
||||
// A blank template replaces the whole site with an empty one; the user
|
||||
// confirmed that, so the next save must not be refused as accidental.
|
||||
// Armed only after the apply succeeded -- a failed apply must not
|
||||
// leave a force_empty pending for an unrelated later save.
|
||||
if (tpl.pages.every((p) => !p.content.components?.length)) armForceEmptyNextSave();
|
||||
} catch (e) {
|
||||
console.error('Failed to load template:', e);
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
onClose();
|
||||
}
|
||||
}, [confirmTemplate, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose]);
|
||||
}, [confirmTemplate, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose, armForceEmptyNextSave]);
|
||||
|
||||
// Mobile-A2: portaled to `document.body` -- TopBar.tsx mounts this modal
|
||||
// as a child of `.topbar`, which (as a flex item with its own z-index)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* Autosave must not depend on isDirty() alone: with a context that reports
|
||||
* dirty but a load that failed, the load gate has to hold on its own.
|
||||
* (A real provider can't produce that state, since markDirty is ignored
|
||||
* until loaded, so the guard is stubbed.)
|
||||
*/
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: (collector?: (state: unknown, query: unknown) => Record<string, unknown>) => {
|
||||
const query = { serialize: () => '{}', history: { canUndo: () => false, canRedo: () => false } };
|
||||
const actions = { history: { undo: vi.fn(), redo: vi.fn() }, deserialize: vi.fn() };
|
||||
return { actions, query, ...(collector ? collector({}, query) : {}) };
|
||||
},
|
||||
}));
|
||||
vi.mock('./TemplateModal', () => ({ TemplateModal: () => null }));
|
||||
vi.mock('./HeadCodeModal', () => ({ HeadCodeModal: () => null }));
|
||||
vi.mock('../sitesmith/SitesmithButton', () => ({ SitesmithButton: () => null }));
|
||||
|
||||
// save() is stubbed (the real one also refuses before load) so this test
|
||||
// exercises TopBar's own gate, not the API hook's.
|
||||
const saveMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../hooks/useWhpApi', () => ({
|
||||
useWhpApi: () => ({
|
||||
save: saveMock, publish: vi.fn(), load: vi.fn(), uploadAsset: vi.fn(), isWHP: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const guardState = vi.hoisted(() => ({ loadState: 'failed' as 'failed' | 'loaded' }));
|
||||
vi.mock('../../state/SaveGuardContext', () => ({
|
||||
useSaveGuard: () => ({
|
||||
loadState: guardState.loadState,
|
||||
setLoadState: () => {},
|
||||
emptyRefusal: null,
|
||||
setEmptyRefusal: () => {},
|
||||
forceEmptyNextSave: { current: false },
|
||||
armForceEmptyNextSave: () => {},
|
||||
dirty: true,
|
||||
isDirty: () => true,
|
||||
getVersion: () => 1,
|
||||
markDirty: () => {},
|
||||
beginSave: () => ({ version: 1 }),
|
||||
endSave: () => {},
|
||||
saving: false,
|
||||
suppressBeforeUnload: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { TopBar } from './TopBar';
|
||||
import { EditorConfigProvider } from '../../state/EditorConfigContext';
|
||||
import { WhpConfig } from '../../types';
|
||||
|
||||
const whpConfig: WhpConfig = {
|
||||
user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1,
|
||||
siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
|
||||
};
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
const saves = () => saveMock.mock.calls;
|
||||
|
||||
function mount() {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
saveMock.mockReset();
|
||||
saveMock.mockResolvedValue({ success: true });
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('autosave gate', () => {
|
||||
test('a dirty editor whose load failed makes no save call', async () => {
|
||||
guardState.loadState = 'failed';
|
||||
mount();
|
||||
await act(async () => { vi.advanceTimersByTime(90_000); });
|
||||
expect(saves()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('control: the same dirty editor, once loaded, does autosave', async () => {
|
||||
guardState.loadState = 'loaded';
|
||||
mount();
|
||||
await act(async () => { vi.advanceTimersByTime(30_000); });
|
||||
expect(saves()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -145,12 +145,12 @@ describe('TopBar publish-warnings wiring', () => {
|
||||
|
||||
await clickPublish();
|
||||
|
||||
expect(container.querySelector('.publish-badge.published')).not.toBeNull();
|
||||
expect(container.querySelector('.save-indicator.error')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="save-status-pill"]')!.getAttribute('data-status')).toBe('published');
|
||||
expect(container.querySelector('[data-testid="error-notice"]')).toBeNull();
|
||||
expect(container.textContent).toContain('Submissions will not be delivered until an administrator enables it.');
|
||||
});
|
||||
|
||||
test('warnings survive the 3-second "Published" flash timer', async () => {
|
||||
test('warnings and the Published pill both outlast the old 3-second flash', async () => {
|
||||
vi.useFakeTimers();
|
||||
publishMock.mockResolvedValue({ success: true, warnings: ['Sticks around after the flash.'] });
|
||||
render();
|
||||
@@ -161,18 +161,16 @@ describe('TopBar publish-warnings wiring', () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Sanity: both the flash and the warning are up before the timer fires.
|
||||
expect(container.querySelector('.publish-badge.published')).not.toBeNull();
|
||||
const pillStatus = () => container.querySelector('[data-testid="save-status-pill"]')!.getAttribute('data-status');
|
||||
expect(pillStatus()).toBe('published');
|
||||
expect(container.textContent).toContain('Sticks around after the flash.');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
// The 3s timer resets publishStatus -> the "Published" flash is gone...
|
||||
expect(container.querySelector('.publish-badge.published')).toBeNull();
|
||||
// ...but publishWarnings lives in its own state and must NOT have been
|
||||
// cleared by that same timer.
|
||||
// Published persists until the next edit; no timer resets it.
|
||||
expect(pillStatus()).toBe('published');
|
||||
expect(container.textContent).toContain('Sticks around after the flash.');
|
||||
});
|
||||
|
||||
|
||||
+162
-115
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||
import { useWhpApi } from '../../hooks/useWhpApi';
|
||||
import { useWhpApi, SaveOptions } from '../../hooks/useWhpApi';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
@@ -12,6 +12,11 @@ import { HeadCodeModal } from './HeadCodeModal';
|
||||
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';
|
||||
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
||||
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||
|
||||
@@ -29,12 +34,20 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
canUndo: query.history.canUndo(),
|
||||
canRedo: query.history.canRedo(),
|
||||
}));
|
||||
const { save, publish, load } = useWhpApi();
|
||||
const { save, publish } = useWhpApi();
|
||||
const {
|
||||
loadState, emptyRefusal, setEmptyRefusal, dirty, isDirty, getVersion,
|
||||
conflict, setConflict, armForceOverwriteNextSave, disarmForceOverwriteNextSave, reloadLatest,
|
||||
} = useSaveGuard();
|
||||
const canSave = loadState === 'loaded';
|
||||
const { headerPage, footerPage, pages, activePageId } = usePages();
|
||||
const { design } = useSiteDesign();
|
||||
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveFailed, setSaveFailed] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [published, setPublished] = useState(false);
|
||||
const [errorNotice, setErrorNotice] = useState<string | null>(null);
|
||||
const [publishWarnings, setPublishWarnings] = useState<string[]>([]);
|
||||
const [isDraft, setIsDraft] = useState(false);
|
||||
// Mobile-A2: lifted from private useState into MobileChromeContext so
|
||||
@@ -44,98 +57,144 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const { open: openSitesmith } = useSitesmithModal();
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasLoadedRef = useRef(false);
|
||||
const savingRef = useRef(false);
|
||||
const publishingRef = useRef(false);
|
||||
// Read at tick time by autosaveDue, never a stale closure.
|
||||
const conflictRef = useRef<string | null>(null);
|
||||
conflictRef.current = conflict;
|
||||
|
||||
// Load saved state on mount
|
||||
// An edit after a publish means the live site no longer matches the editor.
|
||||
useEffect(() => {
|
||||
if (!isWHP || hasLoadedRef.current) return;
|
||||
hasLoadedRef.current = true;
|
||||
if (dirty) setPublished(false);
|
||||
}, [dirty]);
|
||||
|
||||
load().catch((e) => {
|
||||
console.warn('Failed to load project from WHP API:', e);
|
||||
});
|
||||
}, [isWHP, load]);
|
||||
const status: SaveStatus = saving || publishing ? 'saving'
|
||||
: saveFailed ? 'failed'
|
||||
: dirty ? 'unsaved'
|
||||
: published ? 'published'
|
||||
: 'saved';
|
||||
|
||||
// Auto-save every 30 seconds
|
||||
useEffect(() => {
|
||||
if (!isWHP) return;
|
||||
const messageOf = (e: unknown, what: string) =>
|
||||
`${what} failed: ${e instanceof Error && e.message ? e.message : 'could not reach the server'}`;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
save()
|
||||
.then((result) => {
|
||||
if (result?.success) {
|
||||
setSaveStatus('saved');
|
||||
setIsDraft(true);
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 2000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent fail for auto-save
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isWHP, save]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaveStatus('saving');
|
||||
/** Shared by the Save button and autosave. */
|
||||
const runSave = useCallback(async (opts?: SaveOptions) => {
|
||||
if (!canSave || savingRef.current) return;
|
||||
// Draft means "saved changes that aren't live yet": a save with nothing
|
||||
// new in it must not flip the badge.
|
||||
const hadChanges = isDirty();
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await save();
|
||||
if (result?.success) {
|
||||
setSaveStatus('saved');
|
||||
setIsDraft(true);
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 2500);
|
||||
} else {
|
||||
setSaveStatus('error');
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
const result = await save(opts);
|
||||
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);
|
||||
if (hadChanges) setIsDraft(true);
|
||||
} else if (result) {
|
||||
setSaveFailed(true);
|
||||
setErrorNotice(result.message || 'Save failed.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e);
|
||||
setSaveStatus('error');
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
setSaveFailed(true);
|
||||
setErrorNotice(messageOf(e, 'Save'));
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
}, [save]);
|
||||
}, [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 || 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.
|
||||
// Also paused while the conflict modal is up: it is the authority, the
|
||||
// effect guard above only avoids a pointless timer.
|
||||
const autosaveDue = () => isDirty() && !publishingRef.current && !conflictRef.current;
|
||||
const interval = setInterval(() => { if (autosaveDue()) void runSave(); }, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isWHP, canSave, emptyRefusal, conflict, runSave, isDirty]);
|
||||
|
||||
const handleSave = runSave;
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
setPublishStatus('publishing');
|
||||
if (!canSave) return;
|
||||
// Anything edited after this point is not in what we are about to publish.
|
||||
const versionAtStart = getVersion();
|
||||
publishingRef.current = true;
|
||||
setPublishing(true);
|
||||
setPublishWarnings([]);
|
||||
try {
|
||||
const result = await publish();
|
||||
if (result?.success) {
|
||||
setPublishStatus('published');
|
||||
setIsDraft(false);
|
||||
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;
|
||||
setPublished(unchanged);
|
||||
setSaveFailed(false);
|
||||
setErrorNotice(null);
|
||||
// Edited mid-publish: the live site lacks that edit, so it is still a draft.
|
||||
setIsDraft(!unchanged);
|
||||
// The site published; these are fixable problems, not failures. Held
|
||||
// independently of publishStatus so the 3s "Published" flash doesn't
|
||||
// take the warning down with it.
|
||||
// independently of the status pill so a later edit doesn't take the
|
||||
// warning down with it.
|
||||
setPublishWarnings(Array.isArray(result.warnings) ? result.warnings : []);
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
||||
} else {
|
||||
setPublishStatus('error');
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
||||
} else if (result) {
|
||||
// The save before the publish failed: that is a save failure, and
|
||||
// nothing went live.
|
||||
if (result.failedStep === 'save') setSaveFailed(true);
|
||||
setErrorNotice(result.message || 'Publish failed.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Publish failed:', e);
|
||||
setPublishStatus('error');
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
||||
setErrorNotice(messageOf(e, 'Publish'));
|
||||
} finally {
|
||||
publishingRef.current = false;
|
||||
setPublishing(false);
|
||||
}
|
||||
}, [publish]);
|
||||
}, [publish, canSave, getVersion, setEmptyRefusal]);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
// "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 ? (
|
||||
<ConflictModal
|
||||
busy={saving}
|
||||
error={errorNotice}
|
||||
onReload={reloadLatest}
|
||||
onKeepMine={() => { void handleKeepMine(); }}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const emptyBanner = emptyRefusal ? (
|
||||
<EmptySaveBanner
|
||||
onSaveAnyway={() => { void handleSave({ forceEmpty: true }); }}
|
||||
onUndo={() => {
|
||||
actions.history.undo();
|
||||
setEmptyRefusal(null);
|
||||
// Backing out ends the Keep mine decision; a later autosave must be conflict-checked again.
|
||||
disarmForceOverwriteNextSave();
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
// Extracted so both the desktop inline Preview button and the mobile
|
||||
// overflow-menu "Preview" item can trigger the exact same flow.
|
||||
@@ -230,6 +289,9 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
return (
|
||||
<nav className="topbar topbar-mobile">
|
||||
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
|
||||
{emptyBanner}
|
||||
{conflictModal}
|
||||
<ErrorNotice message={errorNotice} onDismiss={() => setErrorNotice(null)} />
|
||||
<div className="topbar-left">
|
||||
{isWHP && (
|
||||
<a
|
||||
@@ -244,10 +306,11 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
</div>
|
||||
|
||||
<div className="topbar-right">
|
||||
{isWHP && <SaveStatusPill status={status} />}
|
||||
<button
|
||||
className="topbar-btn icon-only"
|
||||
onClick={() => actions.history.undo()}
|
||||
disabled={!canUndo}
|
||||
disabled={!canUndo || !canSave}
|
||||
aria-label="Undo"
|
||||
data-tooltip="Undo"
|
||||
>
|
||||
@@ -256,7 +319,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
<button
|
||||
className="topbar-btn icon-only"
|
||||
onClick={() => actions.history.redo()}
|
||||
disabled={!canRedo}
|
||||
disabled={!canRedo || !canSave}
|
||||
aria-label="Redo"
|
||||
data-tooltip="Redo"
|
||||
>
|
||||
@@ -265,22 +328,22 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
<span className="topbar-divider" />
|
||||
<button
|
||||
className="topbar-btn icon-only primary"
|
||||
onClick={handleSave}
|
||||
disabled={saveStatus === 'saving'}
|
||||
onClick={() => { void handleSave(); }}
|
||||
disabled={saving || !canSave}
|
||||
aria-label="Save Draft"
|
||||
data-tooltip="Save"
|
||||
>
|
||||
<i className={`fa ${saveStatus === 'saving' ? 'fa-spinner fa-spin' : 'fa-save'}`} />
|
||||
<i className={`fa ${saving ? 'fa-spinner fa-spin' : 'fa-save'}`} />
|
||||
</button>
|
||||
{isWHP && (
|
||||
<button
|
||||
className="topbar-btn icon-only publish"
|
||||
onClick={handlePublish}
|
||||
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
|
||||
disabled={publishing || saving || !canSave}
|
||||
aria-label="Publish to live site"
|
||||
data-tooltip="Publish"
|
||||
>
|
||||
<i className={`fa ${publishStatus === 'publishing' ? 'fa-spinner fa-spin' : 'fa-globe'}`} />
|
||||
<i className={`fa ${publishing ? 'fa-spinner fa-spin' : 'fa-globe'}`} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -307,7 +370,8 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
onOpenHeadCode={() => setHeadCodeModalOpen(true)}
|
||||
onOpenReportIssue={() => setReportOpen(true)}
|
||||
onPreview={handlePreview}
|
||||
sitesmithNode={<SitesmithButton onClick={() => openSitesmith()} />}
|
||||
sitesmithNode={<SitesmithButton onClick={() => openSitesmith()} disabled={!canSave} />}
|
||||
editingDisabled={!canSave}
|
||||
/>
|
||||
|
||||
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
||||
@@ -320,6 +384,9 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
return (
|
||||
<nav className="topbar">
|
||||
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
|
||||
{emptyBanner}
|
||||
{conflictModal}
|
||||
<ErrorNotice message={errorNotice} onDismiss={() => setErrorNotice(null)} />
|
||||
<div className="topbar-left">
|
||||
{isWHP && (
|
||||
<a href={whpConfig!.backUrl} className="topbar-btn back-btn" aria-label="Back to Panel">
|
||||
@@ -360,19 +427,20 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
</div>
|
||||
|
||||
<div className="topbar-right">
|
||||
<button className="topbar-btn" onClick={() => actions.history.undo()} disabled={!canUndo} title="Undo" aria-label="Undo">
|
||||
<button className="topbar-btn" onClick={() => actions.history.undo()} disabled={!canUndo || !canSave} title="Undo" aria-label="Undo">
|
||||
<i className="fa fa-undo" />
|
||||
</button>
|
||||
<button className="topbar-btn" onClick={() => actions.history.redo()} disabled={!canRedo} title="Redo" aria-label="Redo">
|
||||
<button className="topbar-btn" onClick={() => actions.history.redo()} disabled={!canRedo || !canSave} title="Redo" aria-label="Redo">
|
||||
<i className="fa fa-repeat" />
|
||||
</button>
|
||||
<span className="topbar-divider" />
|
||||
<button className="topbar-btn" title="Templates" aria-label="Templates" onClick={() => setTemplateModalOpen(true)}>
|
||||
<button className="topbar-btn" disabled={!canSave} title="Templates" aria-label="Templates" onClick={() => setTemplateModalOpen(true)}>
|
||||
<i className="fa fa-th-large" /> Templates
|
||||
</button>
|
||||
<button
|
||||
className="topbar-btn icon-only"
|
||||
aria-label="Custom Head Code"
|
||||
disabled={!canSave}
|
||||
data-tooltip="Code"
|
||||
onClick={() => setHeadCodeModalOpen(true)}
|
||||
>
|
||||
@@ -391,45 +459,24 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
<i className="fa fa-bug" />
|
||||
</button>
|
||||
|
||||
{/* Draft/Published status badge */}
|
||||
{isWHP && isDraft && publishStatus !== 'published' && (
|
||||
{/* Draft badge: saved changes that aren't live yet */}
|
||||
{isWHP && isDraft && !published && (
|
||||
<span className="publish-badge draft">
|
||||
<i className="fa fa-pencil" /> Draft
|
||||
</span>
|
||||
)}
|
||||
{publishStatus === 'published' && (
|
||||
<span className="publish-badge published">
|
||||
<i className="fa fa-check-circle" /> Published
|
||||
</span>
|
||||
)}
|
||||
{isWHP && <SaveStatusPill status={status} />}
|
||||
|
||||
{/* Save status indicator */}
|
||||
{saveStatus === 'saved' && (
|
||||
<span className="save-indicator saved">
|
||||
<i className="fa fa-check" /> Saved!
|
||||
</span>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<span className="save-indicator error">
|
||||
<i className="fa fa-exclamation-triangle" /> Save Error
|
||||
</span>
|
||||
)}
|
||||
{publishStatus === 'error' && (
|
||||
<span className="save-indicator error">
|
||||
<i className="fa fa-exclamation-triangle" /> Publish Error
|
||||
</span>
|
||||
)}
|
||||
|
||||
<SitesmithButton onClick={() => openSitesmith()} />
|
||||
<SitesmithButton onClick={() => openSitesmith()} disabled={!canSave} />
|
||||
|
||||
<button
|
||||
className="topbar-btn primary"
|
||||
onClick={handleSave}
|
||||
disabled={saveStatus === 'saving'}
|
||||
onClick={() => { void handleSave(); }}
|
||||
disabled={saving || !canSave}
|
||||
title="Save Draft"
|
||||
aria-label="Save Draft"
|
||||
>
|
||||
{saveStatus === 'saving' ? (
|
||||
{saving ? (
|
||||
<><i className="fa fa-spinner fa-spin" /> Saving...</>
|
||||
) : (
|
||||
<><i className="fa fa-save" /> Save</>
|
||||
@@ -440,11 +487,11 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
<button
|
||||
className="topbar-btn publish"
|
||||
onClick={handlePublish}
|
||||
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
|
||||
disabled={publishing || saving || !canSave}
|
||||
title="Publish to live site"
|
||||
aria-label="Publish to live site"
|
||||
>
|
||||
{publishStatus === 'publishing' ? (
|
||||
{publishing ? (
|
||||
<><i className="fa fa-spinner fa-spin" /> Publishing...</>
|
||||
) : (
|
||||
<><i className="fa fa-globe" /> Publish</>
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface TopBarOverflowMenuProps {
|
||||
* re-implemented here so the mobile menu reuses the exact same
|
||||
* lock/cap-aware button the desktop topbar shows. */
|
||||
sitesmithNode: React.ReactNode;
|
||||
/** Canvas-changing items (Templates, Head Code) are disabled until the site has loaded. */
|
||||
editingDisabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,6 +39,7 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
|
||||
onOpenReportIssue,
|
||||
onPreview,
|
||||
sitesmithNode,
|
||||
editingDisabled,
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -84,13 +87,13 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenTemplates)}>
|
||||
<button type="button" className="topbar-overflow-item" role="menuitem" disabled={editingDisabled} onClick={runAndClose(onOpenTemplates)}>
|
||||
<i className="fa fa-th-large" aria-hidden="true" /> Templates
|
||||
</button>
|
||||
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onPreview)}>
|
||||
<i className="fa fa-eye" aria-hidden="true" /> Preview
|
||||
</button>
|
||||
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenHeadCode)}>
|
||||
<button type="button" className="topbar-overflow-item" role="menuitem" disabled={editingDisabled} onClick={runAndClose(onOpenHeadCode)}>
|
||||
<i className="fa fa-code" aria-hidden="true" /> Head Code
|
||||
</button>
|
||||
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenReportIssue)}>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react';
|
||||
import { useEditorConfig } from './EditorConfigContext';
|
||||
|
||||
export type LoadState = 'loading' | 'loaded' | 'failed';
|
||||
|
||||
interface SaveGuardContextValue {
|
||||
/** Nothing may be saved until this is 'loaded' -- a failed load leaves the
|
||||
* canvas blank, and saving that would overwrite the customer's site. */
|
||||
loadState: LoadState;
|
||||
setLoadState: (s: LoadState) => void;
|
||||
/** Server refused a save that would replace a non-empty site with an empty
|
||||
* one (409 empty_overwrite_refused). Holds the message while the banner is up. */
|
||||
emptyRefusal: string | null;
|
||||
setEmptyRefusal: (message: string | null) => void;
|
||||
/** One-shot: the next save sends force_empty (consumed by the next successful save). */
|
||||
forceEmptyNextSave: React.MutableRefObject<boolean>;
|
||||
armForceEmptyNextSave: () => void;
|
||||
/** True when the editor holds changes (canvas, pages, design, header/footer)
|
||||
* made after the last successful save or load. Cleared only by a save. */
|
||||
dirty: boolean;
|
||||
/** Synchronous read of `dirty`, for handlers that must not see a stale render. */
|
||||
isDirty: () => boolean;
|
||||
/** Record a change. Ignored until the site has loaded (load itself rewrites state). */
|
||||
markDirty: () => void;
|
||||
/** Monotonic change counter: compare two reads to learn whether anything changed in between. */
|
||||
getVersion: () => number;
|
||||
/** Call right before serializing for a save; pass the ticket to `endSave`. */
|
||||
beginSave: () => SaveTicket;
|
||||
/** Finish a save. Clears dirty only if it succeeded AND nothing changed since `beginSave`. */
|
||||
endSave: (ticket: SaveTicket, succeeded: boolean) => void;
|
||||
/** True while at least one save request is in flight. */
|
||||
saving: boolean;
|
||||
/** Skip the unsaved-changes prompt for the next unload (e.g. a deliberate "Reload latest"). */
|
||||
suppressBeforeUnload: () => void;
|
||||
/** Server revision the editor last loaded or saved: a string, null (no project on the
|
||||
* server yet), or undefined (unknown -- an older backend sent none, so saves skip the check). */
|
||||
getRev: () => string | null | undefined;
|
||||
setRev: (rev: string | null) => void;
|
||||
/** Server message while the "changed in another tab" modal is up; autosave stays paused. */
|
||||
conflict: string | null;
|
||||
setConflict: (message: string | null) => void;
|
||||
/** One-shot: the next save sends force_overwrite (armed by "Keep mine", spent by a save that goes through). */
|
||||
forceOverwriteNextSave: React.MutableRefObject<boolean>;
|
||||
armForceOverwriteNextSave: () => void;
|
||||
/** Cancel an armed overwrite (the customer backed out of the follow-up empty-save prompt). */
|
||||
disarmForceOverwriteNextSave: () => void;
|
||||
/** "Reload latest": skip the unsaved-changes prompt, then reload the whole editor. */
|
||||
reloadLatest: () => void;
|
||||
}
|
||||
|
||||
/** Identifies the editor state a save was built from. */
|
||||
export interface SaveTicket {
|
||||
version: number;
|
||||
}
|
||||
|
||||
// Default (no provider: standalone mode and isolated unit tests) is
|
||||
// permissive -- the gate only applies inside <SaveGuardProvider> in WHP mode.
|
||||
const defaultValue: SaveGuardContextValue = {
|
||||
loadState: 'loaded',
|
||||
setLoadState: () => {},
|
||||
emptyRefusal: null,
|
||||
setEmptyRefusal: () => {},
|
||||
forceEmptyNextSave: { current: false },
|
||||
armForceEmptyNextSave: () => {},
|
||||
dirty: false,
|
||||
isDirty: () => false,
|
||||
getVersion: () => 0,
|
||||
markDirty: () => {},
|
||||
beginSave: () => ({ version: 0 }),
|
||||
endSave: () => {},
|
||||
saving: false,
|
||||
suppressBeforeUnload: () => {},
|
||||
getRev: () => undefined,
|
||||
setRev: () => {},
|
||||
conflict: null,
|
||||
setConflict: () => {},
|
||||
forceOverwriteNextSave: { current: false },
|
||||
armForceOverwriteNextSave: () => {},
|
||||
disarmForceOverwriteNextSave: () => {},
|
||||
reloadLatest: () => {},
|
||||
};
|
||||
|
||||
const SaveGuardContext = createContext<SaveGuardContextValue>(defaultValue);
|
||||
|
||||
export const useSaveGuard = () => useContext(SaveGuardContext);
|
||||
|
||||
export const SaveGuardProvider: React.FC<{ children: ReactNode; /** Injectable for tests; defaults to a full page reload. */ reloadPage?: () => void }> = ({ children, reloadPage }) => {
|
||||
const { isWHP } = useEditorConfig();
|
||||
const [loadState, setLoadState] = useState<LoadState>(isWHP ? 'loading' : 'loaded');
|
||||
const [emptyRefusal, setEmptyRefusal] = useState<string | null>(null);
|
||||
const forceEmptyNextSave = useRef(false);
|
||||
const [conflict, setConflict] = useState<string | null>(null);
|
||||
const forceOverwriteNextSave = useRef(false);
|
||||
const revRef = useRef<string | null | undefined>(undefined);
|
||||
const getRev = useCallback(() => revRef.current, []);
|
||||
const setRev = useCallback((rev: string | null) => { revRef.current = rev; }, []);
|
||||
|
||||
// Dirty tracking. `versionRef` counts changes so a save that raced with an
|
||||
// edit cannot mark that edit clean.
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [savesInFlight, setSavesInFlight] = useState(0);
|
||||
const dirtyRef = useRef(false);
|
||||
const versionRef = useRef(0);
|
||||
const loadedRef = useRef(loadState === 'loaded');
|
||||
const suppressRef = useRef(false);
|
||||
|
||||
// A (re)load replaces the whole editor state: it is the new clean baseline.
|
||||
useEffect(() => {
|
||||
loadedRef.current = loadState === 'loaded';
|
||||
if (loadState === 'loaded') {
|
||||
dirtyRef.current = false;
|
||||
setDirty(false);
|
||||
}
|
||||
}, [loadState]);
|
||||
|
||||
const markDirty = useCallback(() => {
|
||||
if (!loadedRef.current) return;
|
||||
versionRef.current += 1;
|
||||
dirtyRef.current = true;
|
||||
setDirty(true);
|
||||
}, []);
|
||||
const isDirty = useCallback(() => dirtyRef.current, []);
|
||||
const getVersion = useCallback(() => versionRef.current, []);
|
||||
const beginSave = useCallback((): SaveTicket => {
|
||||
setSavesInFlight((n) => n + 1);
|
||||
return { version: versionRef.current };
|
||||
}, []);
|
||||
const endSave = useCallback((ticket: SaveTicket, succeeded: boolean) => {
|
||||
setSavesInFlight((n) => Math.max(0, n - 1));
|
||||
if (succeeded && ticket.version === versionRef.current) {
|
||||
dirtyRef.current = false;
|
||||
setDirty(false);
|
||||
}
|
||||
}, []);
|
||||
const suppressBeforeUnload = useCallback(() => { suppressRef.current = true; }, []);
|
||||
|
||||
const reloadLatest = useCallback(() => {
|
||||
suppressRef.current = true;
|
||||
(reloadPage ?? (() => window.location.reload()))();
|
||||
}, [reloadPage]);
|
||||
|
||||
const saving = savesInFlight > 0;
|
||||
const needsPrompt = isWHP && (dirty || saving);
|
||||
useEffect(() => {
|
||||
if (!needsPrompt) return;
|
||||
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (suppressRef.current) return;
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', onBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
}, [needsPrompt]);
|
||||
|
||||
const value = useMemo<SaveGuardContextValue>(() => ({
|
||||
loadState,
|
||||
setLoadState,
|
||||
emptyRefusal,
|
||||
setEmptyRefusal,
|
||||
forceEmptyNextSave,
|
||||
armForceEmptyNextSave: () => { forceEmptyNextSave.current = true; },
|
||||
dirty,
|
||||
isDirty,
|
||||
getVersion,
|
||||
markDirty,
|
||||
beginSave,
|
||||
endSave,
|
||||
saving,
|
||||
suppressBeforeUnload,
|
||||
getRev,
|
||||
setRev,
|
||||
conflict,
|
||||
setConflict,
|
||||
forceOverwriteNextSave,
|
||||
armForceOverwriteNextSave: () => { forceOverwriteNextSave.current = true; },
|
||||
disarmForceOverwriteNextSave: () => { forceOverwriteNextSave.current = false; },
|
||||
reloadLatest,
|
||||
}), [loadState, emptyRefusal, dirty, saving, isDirty, getVersion, markDirty, beginSave, endSave, suppressBeforeUnload, getRev, setRev, conflict, reloadLatest]);
|
||||
|
||||
return <SaveGuardContext.Provider value={value}>{children}</SaveGuardContext.Provider>;
|
||||
};
|
||||
@@ -1929,3 +1929,127 @@ body {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Blocking overlay while the initial project load runs / has failed. Covers
|
||||
everything below the topbar so nothing is editable behind it. */
|
||||
.load-gate {
|
||||
position: fixed;
|
||||
top: var(--topbar-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
color: #e2e8f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.load-gate p { margin: 0; }
|
||||
|
||||
.empty-save-banner {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 41;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #450a0a;
|
||||
border-bottom: 1px solid #b91c1c;
|
||||
color: #fecaca;
|
||||
font-size: 12px;
|
||||
}
|
||||
.empty-save-banner-text { flex: 1; }
|
||||
.empty-save-banner button {
|
||||
background: none;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 4px;
|
||||
color: #fecaca;
|
||||
cursor: pointer;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
/* Persistent save-state pill in the topbar */
|
||||
.save-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 9px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.save-pill.unsaved { background: rgba(245, 158, 11, 0.15); color: #f59e0b; border-color: rgba(245, 158, 11, 0.3); }
|
||||
.save-pill.saving { background: rgba(59, 130, 246, 0.15); color: #60a5fa; border-color: rgba(59, 130, 246, 0.3); }
|
||||
.save-pill.saved { background: rgba(16, 185, 129, 0.12); color: #10b981; border-color: rgba(16, 185, 129, 0.25); }
|
||||
.save-pill.published { background: rgba(16, 185, 129, 0.2); color: #34d399; border-color: rgba(16, 185, 129, 0.4); }
|
||||
.save-pill.failed { background: rgba(239, 68, 68, 0.15); color: #f87171; border-color: rgba(239, 68, 68, 0.4); }
|
||||
.topbar-mobile .save-pill { padding: 3px 6px; font-size: 10px; }
|
||||
|
||||
/* Error toast: fixed, stays until dismissed */
|
||||
.error-notice {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
max-width: min(420px, calc(100vw - 32px));
|
||||
padding: 10px 12px;
|
||||
background: #450a0a;
|
||||
border: 1px solid #b91c1c;
|
||||
border-radius: 6px;
|
||||
color: #fecaca;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.error-notice-text { flex: 1; word-break: break-word; }
|
||||
.error-notice button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fecaca;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.conflict-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
}
|
||||
/* Another-tab conflict modal */
|
||||
.conflict-modal {
|
||||
max-width: 420px;
|
||||
margin: auto;
|
||||
padding: 20px;
|
||||
background: #1e293b;
|
||||
border: 1px solid #475569;
|
||||
border-radius: 8px;
|
||||
color: #e2e8f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.conflict-modal h3 { margin: 0 0 8px; font-size: 16px; }
|
||||
.conflict-modal p { margin: 0 0 8px; }
|
||||
.conflict-modal-error { color: #fecaca; }
|
||||
.conflict-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
|
||||
.conflict-modal-actions button {
|
||||
background: none;
|
||||
border: 1px solid #94a3b8;
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
.conflict-modal-actions button:disabled { opacity: 0.5; cursor: default; }
|
||||
Reference in new issue
Block a user