fix(site-builder): never save before a successful load; guard empty overwrites
Refs #28
This commit is contained in:
1 parent
9ab01021be
commit
66e669fa01
13 files changed
+498
-37
No files matched your search
@@ -6,6 +6,7 @@ import { componentResolver } from './components/resolver';
|
||||
import { WhpConfig } from './types';
|
||||
import { EditorConfigProvider } from './state/EditorConfigContext';
|
||||
import { SiteDesignProvider } from './state/SiteDesignContext';
|
||||
import { SaveGuardProvider } from './state/SaveGuardContext';
|
||||
import { PageProvider } from './state/PageContext';
|
||||
import { SitesmithProvider, useSitesmithModal } from './state/SitesmithContext';
|
||||
import { SitesmithModal } from './panels/sitesmith/SitesmithModal';
|
||||
@@ -23,6 +24,7 @@ const SitesmithModalMount: React.FC = () => {
|
||||
export const App: React.FC<AppProps> = ({ whpConfig }) => {
|
||||
return (
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SaveGuardProvider>
|
||||
<SiteDesignProvider>
|
||||
<Editor resolver={componentResolver} enabled={true} onRender={RenderNode}>
|
||||
<PageProvider>
|
||||
@@ -33,6 +35,7 @@ export const App: React.FC<AppProps> = ({ whpConfig }) => {
|
||||
</PageProvider>
|
||||
</Editor>
|
||||
</SiteDesignProvider>
|
||||
</SaveGuardProvider>
|
||||
</EditorConfigProvider>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { MobileChromeProvider } from '../state/MobileChromeContext';
|
||||
import { LayerFocusProvider } from '../panels/left/LayerFocusContext';
|
||||
import { LoadGate } from './LoadGate';
|
||||
import { DeviceMode } from '../types';
|
||||
|
||||
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
|
||||
@@ -86,6 +87,7 @@ export const EditorShell: React.FC = () => {
|
||||
<MobileChromeProvider>
|
||||
<LayerFocusProvider>
|
||||
<div className="editor-app">
|
||||
<LoadGate />
|
||||
<TopBar
|
||||
device={device}
|
||||
onDeviceChange={setDevice}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
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 } 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(); });
|
||||
|
||||
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 />
|
||||
</PageProvider>
|
||||
</SiteDesignProvider>
|
||||
</SaveGuardProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
undoMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('load gate', () => {
|
||||
test('a failed load blocks autosave and manual save, and shows the failure message', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('network down'));
|
||||
mount();
|
||||
await flush();
|
||||
|
||||
expect(q('[data-testid="load-gate"]')!.textContent).toContain(
|
||||
"We couldn't load your site. Your saved work has not been changed.",
|
||||
);
|
||||
await act(async () => { vi.advanceTimersByTime(30000); });
|
||||
expect(saveCalls()).toHaveLength(0);
|
||||
expect((q('button[aria-label="Save Draft"]') as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((q('button[aria-label="Publish to live site"]') as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
test('non-ok, non-JSON and success:false responses all count as failed', async () => {
|
||||
for (const bad of [
|
||||
jsonResp({ success: true, project: null }, 500),
|
||||
{ ok: true, status: 200, json: async () => { throw new SyntaxError('bad json'); } },
|
||||
jsonResp({ success: false, message: 'nope' }),
|
||||
]) {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(bad);
|
||||
mount();
|
||||
await flush();
|
||||
expect(q('[data-testid="load-retry"]')).not.toBeNull();
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
}
|
||||
mount(); // so afterEach has something to unmount
|
||||
});
|
||||
|
||||
test('shows a loading overlay while the load is in flight', async () => {
|
||||
fetchMock.mockReturnValue(new Promise(() => {}));
|
||||
mount();
|
||||
await flush();
|
||||
expect(q('[data-testid="load-gate"]')!.textContent).toContain('Loading your site…');
|
||||
});
|
||||
|
||||
test('Retry recovers and re-enables saving', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('boom'));
|
||||
fetchMock.mockResolvedValue(jsonResp({ success: true, project: null, rev: null }));
|
||||
mount();
|
||||
await flush();
|
||||
act(() => { q('[data-testid="load-retry"]')!.click(); });
|
||||
await flush();
|
||||
|
||||
expect(q('[data-testid="load-gate"]')).toBeNull();
|
||||
expect((q('button[aria-label="Save Draft"]') as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test('project:null counts as loaded and autosave then runs', async () => {
|
||||
fetchMock.mockImplementation(async (url: string) =>
|
||||
String(url).includes('action=load')
|
||||
? jsonResp({ success: true, project: null, rev: null })
|
||||
: jsonResp({ success: true }));
|
||||
mount();
|
||||
await flush();
|
||||
expect(q('[data-testid="load-gate"]')).toBeNull();
|
||||
await act(async () => { vi.advanceTimersByTime(30000); });
|
||||
expect(saveCalls()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty-overwrite guard', () => {
|
||||
const loadedThen = (saveResponses: unknown[]) => {
|
||||
const saves = [...saveResponses];
|
||||
fetchMock.mockImplementation(async (url: string) =>
|
||||
String(url).includes('action=load')
|
||||
? jsonResp({ success: true, project: null, rev: null })
|
||||
: (saves.shift() ?? jsonResp({ success: true })));
|
||||
};
|
||||
|
||||
test('Reset Entire Site makes the next save send force_empty (once)', async () => {
|
||||
loadedThen([]);
|
||||
mount();
|
||||
await flush();
|
||||
|
||||
act(() => { q('[data-action="open-site-reset"]')!.click(); });
|
||||
const input = q('[data-testid="site-reset-domain"]') as HTMLInputElement;
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
|
||||
act(() => { setter.call(input, 'example.com'); input.dispatchEvent(new Event('input', { bubbles: true })); });
|
||||
act(() => { q('[data-action="confirm-site-reset"]')!.click(); });
|
||||
|
||||
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
|
||||
await flush();
|
||||
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
|
||||
await flush();
|
||||
|
||||
expect(saveBody(0).force_empty).toBe(true);
|
||||
expect(saveBody(1).force_empty).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a 409 shows the banner, pauses autosave, and Save anyway re-sends with force_empty', async () => {
|
||||
loadedThen([
|
||||
jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'refused' }, 409),
|
||||
jsonResp({ success: true }),
|
||||
]);
|
||||
mount();
|
||||
await flush();
|
||||
|
||||
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
|
||||
await flush();
|
||||
expect(q('[data-testid="empty-save-banner"]')!.textContent).toContain(
|
||||
'This would replace your saved site with an empty page.',
|
||||
);
|
||||
expect(saveBody(0).force_empty).toBeUndefined();
|
||||
|
||||
await act(async () => { vi.advanceTimersByTime(60000); });
|
||||
expect(saveCalls()).toHaveLength(1); // autosave paused
|
||||
|
||||
await act(async () => { q('[data-testid="empty-save-anyway"]')!.click(); });
|
||||
await flush();
|
||||
expect(saveCalls()).toHaveLength(2);
|
||||
expect(saveBody(1).force_empty).toBe(true);
|
||||
expect(q('[data-testid="empty-save-banner"]')).toBeNull();
|
||||
});
|
||||
|
||||
test('Undo on the banner runs Craft history undo and dismisses it', async () => {
|
||||
loadedThen([jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'r' }, 409)]);
|
||||
mount();
|
||||
await flush();
|
||||
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
|
||||
await flush();
|
||||
act(() => { q('[data-testid="empty-save-undo"]')!.click(); });
|
||||
expect(undoMock).toHaveBeenCalledTimes(1);
|
||||
expect(q('[data-testid="empty-save-banner"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useEditorConfig } from '../state/EditorConfigContext';
|
||||
import { useSaveGuard } from '../state/SaveGuardContext';
|
||||
import { useWhpApi } from '../hooks/useWhpApi';
|
||||
|
||||
/**
|
||||
* Owns the initial project load (WHP mode only) and blocks the whole editor
|
||||
* until it succeeds. On failure nothing is editable and nothing can save, so
|
||||
* a failed load can never be autosaved over the customer's real site.
|
||||
*/
|
||||
export const LoadGate: React.FC = () => {
|
||||
const { isWHP } = useEditorConfig();
|
||||
const { loadState, setLoadState } = useSaveGuard();
|
||||
const { load } = useWhpApi();
|
||||
const startedRef = useRef(false);
|
||||
|
||||
const runLoad = useCallback(() => {
|
||||
setLoadState('loading');
|
||||
load()
|
||||
.then(() => setLoadState('loaded'))
|
||||
.catch((e) => {
|
||||
console.error('Failed to load project from WHP API:', e);
|
||||
setLoadState('failed');
|
||||
});
|
||||
}, [load, setLoadState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWHP || startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
runLoad();
|
||||
}, [isWHP, runLoad]);
|
||||
|
||||
if (!isWHP || loadState === 'loaded') return null;
|
||||
|
||||
return (
|
||||
<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>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>
|
||||
);
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
@@ -181,6 +182,7 @@ describe('useWhpApi load() falls back to EMPTY_CANVAS when the first page state
|
||||
|
||||
test('a deserialize failure on the first page logs console.error and retries with EMPTY_CANVAS', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
project: {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
|
||||
export interface BuildSavePayloadInput {
|
||||
siteId: number;
|
||||
@@ -195,6 +196,10 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
};
|
||||
}
|
||||
|
||||
export interface SaveOptions {
|
||||
forceEmpty?: boolean;
|
||||
}
|
||||
|
||||
export function useWhpApi() {
|
||||
const { query, actions } = useEditor();
|
||||
const { whpConfig, isWHP } = useEditorConfig();
|
||||
@@ -212,14 +217,21 @@ export function useWhpApi() {
|
||||
} = usePages();
|
||||
const { design, updateDesign } = useSiteDesign();
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const { loadState, forceEmptyNextSave } = useSaveGuard();
|
||||
|
||||
const save = useCallback(async (opts?: SaveOptions) => {
|
||||
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();
|
||||
|
||||
const forceEmpty = !!opts?.forceEmpty || forceEmptyNextSave.current;
|
||||
|
||||
const payload = buildSavePayload({
|
||||
siteId: whpConfig.siteId,
|
||||
siteName: whpConfig.siteName,
|
||||
@@ -240,16 +252,36 @@ export function useWhpApi() {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': whpConfig.csrfToken,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(forceEmpty ? { ...payload, force_empty: true } : payload),
|
||||
});
|
||||
return resp.json();
|
||||
}, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]);
|
||||
|
||||
// 409 empty_overwrite_refused is an expected outcome the UI handles, so
|
||||
// return it as a structured result instead of throwing.
|
||||
if (resp.status === 409) {
|
||||
const body = await resp.json().catch(() => null);
|
||||
if (body?.error === 'empty_overwrite_refused') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'empty_overwrite_refused',
|
||||
message: body.message || 'This would replace your saved site with an empty page.',
|
||||
};
|
||||
}
|
||||
return body ?? { success: false, error: 'conflict' };
|
||||
}
|
||||
|
||||
const result = await resp.json();
|
||||
// The one-shot force_empty is spent only once a save actually went through.
|
||||
if (forceEmpty && result?.success) forceEmptyNextSave.current = false;
|
||||
return result;
|
||||
}, [isWHP, whpConfig, loadState, forceEmptyNextSave, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]);
|
||||
|
||||
const publish = useCallback(async () => {
|
||||
if (!isWHP || !whpConfig) return null;
|
||||
if (loadState !== 'loaded') return null;
|
||||
|
||||
// First save to ensure staging is up to date
|
||||
await save();
|
||||
const saved = await save();
|
||||
if (saved?.error === 'empty_overwrite_refused') return saved;
|
||||
|
||||
// Then publish from staging to live
|
||||
const resp = await fetch(
|
||||
@@ -264,7 +296,7 @@ export function useWhpApi() {
|
||||
},
|
||||
);
|
||||
return resp.json();
|
||||
}, [isWHP, whpConfig, save]);
|
||||
}, [isWHP, whpConfig, loadState, save]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!isWHP || !whpConfig) return null;
|
||||
@@ -272,9 +304,19 @@ 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) {
|
||||
// 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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);
|
||||
@@ -183,6 +185,10 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
|
||||
const tpl = confirmTemplate;
|
||||
setConfirmTemplate(null);
|
||||
|
||||
// A blank template replaces the whole site with an empty one; the user
|
||||
// just confirmed that, so the next save must not be refused as accidental.
|
||||
if (tpl.pages.every((p) => !p.content.components?.length)) armForceEmptyNextSave();
|
||||
|
||||
try {
|
||||
// 1. Optionally apply design tokens
|
||||
if (applyDesign && tpl.design) {
|
||||
@@ -237,7 +243,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
|
||||
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)
|
||||
|
||||
@@ -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,8 @@ import { HeadCodeModal } from './HeadCodeModal';
|
||||
import { ReportIssueModal } from './ReportIssueModal';
|
||||
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
|
||||
import { PublishWarnings } from './PublishWarnings';
|
||||
import { EmptySaveBanner } from './EmptySaveBanner';
|
||||
import { useSaveGuard } from '../../state/SaveGuardContext';
|
||||
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
||||
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||
|
||||
@@ -29,7 +31,9 @@ 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 } = useSaveGuard();
|
||||
const canSave = loadState === 'loaded';
|
||||
const { headerPage, footerPage, pages, activePageId } = usePages();
|
||||
const { design } = useSiteDesign();
|
||||
|
||||
@@ -46,26 +50,18 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
const { open: openSitesmith } = useSitesmithModal();
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasLoadedRef = useRef(false);
|
||||
|
||||
// Load saved state on mount
|
||||
useEffect(() => {
|
||||
if (!isWHP || hasLoadedRef.current) return;
|
||||
hasLoadedRef.current = true;
|
||||
|
||||
load().catch((e) => {
|
||||
console.warn('Failed to load project from WHP API:', e);
|
||||
});
|
||||
}, [isWHP, load]);
|
||||
|
||||
// Auto-save every 30 seconds
|
||||
useEffect(() => {
|
||||
if (!isWHP) return;
|
||||
// Paused until the site has loaded, and while the empty-save banner is up.
|
||||
if (!isWHP || !canSave || emptyRefusal) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
save()
|
||||
.then((result) => {
|
||||
if (result?.success) {
|
||||
if (result?.error === 'empty_overwrite_refused') {
|
||||
setEmptyRefusal(result.message);
|
||||
} else if (result?.success) {
|
||||
setSaveStatus('saved');
|
||||
setIsDraft(true);
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
@@ -78,13 +74,18 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isWHP, save]);
|
||||
}, [isWHP, canSave, emptyRefusal, setEmptyRefusal, save]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const handleSave = useCallback(async (opts?: SaveOptions) => {
|
||||
if (!canSave) return;
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
const result = await save();
|
||||
if (result?.success) {
|
||||
const result = await save(opts);
|
||||
if (result?.error === 'empty_overwrite_refused') {
|
||||
setEmptyRefusal(result.message);
|
||||
setSaveStatus('idle');
|
||||
} else if (result?.success) {
|
||||
setEmptyRefusal(null);
|
||||
setSaveStatus('saved');
|
||||
setIsDraft(true);
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
@@ -100,14 +101,18 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
}
|
||||
}, [save]);
|
||||
}, [save, canSave, setEmptyRefusal]);
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
setPublishStatus('publishing');
|
||||
setPublishWarnings([]);
|
||||
try {
|
||||
const result = await publish();
|
||||
if (result?.success) {
|
||||
if (result?.error === 'empty_overwrite_refused') {
|
||||
setEmptyRefusal(result.message);
|
||||
setPublishStatus('idle');
|
||||
} else if (result?.success) {
|
||||
setPublishStatus('published');
|
||||
setIsDraft(false);
|
||||
// The site published; these are fixable problems, not failures. Held
|
||||
@@ -127,7 +132,14 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
||||
}
|
||||
}, [publish]);
|
||||
}, [publish, canSave, setEmptyRefusal]);
|
||||
|
||||
const emptyBanner = emptyRefusal ? (
|
||||
<EmptySaveBanner
|
||||
onSaveAnyway={() => { void handleSave({ forceEmpty: true }); }}
|
||||
onUndo={() => { actions.history.undo(); setEmptyRefusal(null); }}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
@@ -230,6 +242,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
return (
|
||||
<nav className="topbar topbar-mobile">
|
||||
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
|
||||
{emptyBanner}
|
||||
<div className="topbar-left">
|
||||
{isWHP && (
|
||||
<a
|
||||
@@ -265,8 +278,8 @@ 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={() => handleSave()}
|
||||
disabled={saveStatus === 'saving' || !canSave}
|
||||
aria-label="Save Draft"
|
||||
data-tooltip="Save"
|
||||
>
|
||||
@@ -276,7 +289,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
<button
|
||||
className="topbar-btn icon-only publish"
|
||||
onClick={handlePublish}
|
||||
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
|
||||
disabled={publishStatus === 'publishing' || saveStatus === 'saving' || !canSave}
|
||||
aria-label="Publish to live site"
|
||||
data-tooltip="Publish"
|
||||
>
|
||||
@@ -320,6 +333,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
return (
|
||||
<nav className="topbar">
|
||||
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
|
||||
{emptyBanner}
|
||||
<div className="topbar-left">
|
||||
{isWHP && (
|
||||
<a href={whpConfig!.backUrl} className="topbar-btn back-btn" aria-label="Back to Panel">
|
||||
@@ -424,8 +438,8 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
|
||||
<button
|
||||
className="topbar-btn primary"
|
||||
onClick={handleSave}
|
||||
disabled={saveStatus === 'saving'}
|
||||
onClick={() => handleSave()}
|
||||
disabled={saveStatus === 'saving' || !canSave}
|
||||
title="Save Draft"
|
||||
aria-label="Save Draft"
|
||||
>
|
||||
@@ -440,7 +454,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
<button
|
||||
className="topbar-btn publish"
|
||||
onClick={handlePublish}
|
||||
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
|
||||
disabled={publishStatus === 'publishing' || saveStatus === 'saving' || !canSave}
|
||||
title="Publish to live site"
|
||||
aria-label="Publish to live site"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { createContext, useContext, 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;
|
||||
}
|
||||
|
||||
// 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: () => {},
|
||||
};
|
||||
|
||||
const SaveGuardContext = createContext<SaveGuardContextValue>(defaultValue);
|
||||
|
||||
export const useSaveGuard = () => useContext(SaveGuardContext);
|
||||
|
||||
export const SaveGuardProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const { isWHP } = useEditorConfig();
|
||||
const [loadState, setLoadState] = useState<LoadState>(isWHP ? 'loading' : 'loaded');
|
||||
const [emptyRefusal, setEmptyRefusal] = useState<string | null>(null);
|
||||
const forceEmptyNextSave = useRef(false);
|
||||
|
||||
const value = useMemo<SaveGuardContextValue>(() => ({
|
||||
loadState,
|
||||
setLoadState,
|
||||
emptyRefusal,
|
||||
setEmptyRefusal,
|
||||
forceEmptyNextSave,
|
||||
armForceEmptyNextSave: () => { forceEmptyNextSave.current = true; },
|
||||
}), [loadState, emptyRefusal]);
|
||||
|
||||
return <SaveGuardContext.Provider value={value}>{children}</SaveGuardContext.Provider>;
|
||||
};
|
||||
@@ -1929,3 +1929,48 @@ 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: 48px;
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user