Merge polish/save-pipeline: load gate, honest save state, idle autosave, conflict handling (Refs #28 #30 #31 #33)

This commit is contained in:
shadowdao committed 2026-10-07 10:21:51 -07:00
commit eb134a4743
25 files changed
+2166 -196

No files matched your search

+75
View File
@@ -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
View File
@@ -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>
);
};
+33
View File
@@ -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;
};
+4
View File
@@ -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}
+262
View File
@@ -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();
});
});
+56
View File
@@ -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>
);
};
+434
View File
@@ -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');
});
});
+2
View File
@@ -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
View File
@@ -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={{
+28
View File
@@ -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>
);
+23
View File
@@ -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>
);
+9 -1
View File
@@ -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);
});
});
+7 -9
View File
@@ -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
View File
@@ -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)}>
+181
View File
@@ -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>;
};
+124
View File
@@ -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; }