fix(site-builder): honest save state (dirty tracking, status pill, beforeunload, error notice)

Track dirty from Craft node changes and page/design/header/footer state, cleared
only by a successful save. Persistent Unsaved/Saving/Saved/Published/Save failed
pill, beforeunload prompt while dirty or saving, one persistent error notice,
resp.ok/non-JSON handling for save and publish, and publish refuses to run
after a failed save. Draft badge no longer flips on a no-op autosave.

Refs #30

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 10:07:03 -07:00
1 parent a406ff8548
commit 9418444916
12 files changed
+842 -170

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);
});
});
+14 -3
View File
@@ -6,7 +6,7 @@ import { componentResolver } from './components/resolver';
import { WhpConfig } from './types';
import { EditorConfigProvider } from './state/EditorConfigContext';
import { SiteDesignProvider } from './state/SiteDesignContext';
import { SaveGuardProvider } from './state/SaveGuardContext';
import { SaveGuardProvider, useSaveGuard } from './state/SaveGuardContext';
import { PageProvider } from './state/PageContext';
import { SitesmithProvider, useSitesmithModal } from './state/SitesmithContext';
import { SitesmithModal } from './panels/sitesmith/SitesmithModal';
@@ -21,19 +21,30 @@ const SitesmithModalMount: React.FC = () => {
return <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}>
<SaveGuardProvider>
<SiteDesignProvider>
<Editor resolver={componentResolver} enabled={true} onRender={RenderNode}>
<SaveAwareEditor>
<PageProvider>
<SitesmithProvider>
<EditorShell />
<SitesmithModalMount />
</SitesmithProvider>
</PageProvider>
</Editor>
</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;
};
+2
View File
@@ -13,6 +13,7 @@ import { useIsMobile } from '../hooks/useIsMobile';
import { MobileChromeProvider } from '../state/MobileChromeContext';
import { LayerFocusProvider } from '../panels/left/LayerFocusContext';
import { LoadGate } from './LoadGate';
import { DirtyTracker } from './DirtyTracker';
import { DeviceMode } from '../types';
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
@@ -88,6 +89,7 @@ export const EditorShell: React.FC = () => {
<LayerFocusProvider>
<div className="editor-app">
<LoadGate />
<DirtyTracker />
<TopBar
device={device}
onDeviceChange={setDevice}
+331
View File
@@ -0,0 +1,331 @@
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('Draft badge', () => {
test('a no-op autosave does not flip the badge to Draft; a save after real changes does', async () => {
respondWith({});
await mount();
await act(async () => { vi.advanceTimersByTime(30_000); });
await flush();
expect(calls('save')).toHaveLength(1);
expect(q('.publish-badge.draft')).toBeNull();
edit();
await act(async () => { vi.advanceTimersByTime(30_000); });
await flush();
expect(q('.publish-badge.draft')).not.toBeNull();
});
});
describe('beforeunload', () => {
test('is registered only while dirty (or saving) and removed once saved', async () => {
respondWith({});
await mount();
expect(beforeUnloadAdds()).toBe(0);
edit();
expect(beforeUnloadAdds()).toBe(1);
const handler = addSpy.mock.calls.find(([t]: unknown[]) => t === 'beforeunload')![1] as EventListener;
const ev = new Event('beforeunload', { cancelable: true });
handler(ev);
expect(ev.defaultPrevented).toBe(true);
await clickSave();
expect(beforeUnloadRemoves()).toBe(1);
});
test('suppressBeforeUnload lets a deliberate reload through without a prompt', async () => {
respondWith({});
await mount();
edit();
const handler = addSpy.mock.calls.find(([t]: unknown[]) => t === 'beforeunload')![1] as EventListener;
act(() => { guard.suppressBeforeUnload(); });
const ev = new Event('beforeunload', { cancelable: true });
handler(ev);
expect(ev.defaultPrevented).toBe(false);
});
});
describe('failed save blocks publish', () => {
test('no publish request is made and the save error is shown', async () => {
respondWith({ save: [jsonResp({ success: false, error: 'disk_full', message: 'Disk quota exceeded.' }, 500)] });
await mount();
edit();
await clickPublish();
expect(calls('publish')).toHaveLength(0);
expect(q('[data-testid="error-notice"]')!.textContent).toContain('Disk quota exceeded.');
expect(pill()).toBe('failed');
});
test('a thrown save (network down) also blocks publish', async () => {
fetchMock.mockImplementation(async (url: string) => {
if (String(url).includes('action=load')) return jsonResp({ success: true, project: null, rev: null });
throw new TypeError('Failed to fetch');
});
await mount();
await clickPublish();
expect(calls('publish')).toHaveLength(0);
expect(q('[data-testid="error-notice"]')!.textContent).toContain('Failed to fetch');
});
});
describe('error notice', () => {
test('persists past any timer until dismissed, and shows the server message', async () => {
// Every save fails, including the 30s autosaves that run during the wait.
fetchMock.mockImplementation(async (url: string) =>
String(url).includes('action=load')
? jsonResp({ success: true, project: null, rev: null })
: jsonResp({ success: false, error: 'bad', message: 'Quota exceeded.' }, 500));
await mount();
edit();
await clickSave();
expect(q('[data-testid="error-notice"]')!.textContent).toContain('Quota exceeded.');
await act(async () => { vi.advanceTimersByTime(120_000); });
expect(q('[data-testid="error-notice"]')).not.toBeNull();
act(() => { q('[data-testid="error-notice-dismiss"]')!.click(); });
expect(q('[data-testid="error-notice"]')).toBeNull();
});
test('a non-JSON 502 yields a readable error, not a parse exception', async () => {
respondWith({ save: [htmlResp(502)] });
await mount();
edit();
await clickSave();
expect(q('[data-testid="error-notice"]')!.textContent).toBe('Save failed: server returned HTTP 502');
expect(pill()).toBe('failed');
});
test('a non-JSON 502 on publish is readable too', async () => {
respondWith({ publish: [htmlResp(502)] });
await mount();
await clickPublish();
expect(q('[data-testid="error-notice"]')!.textContent).toBe('Publish failed: server returned HTTP 502');
expect(pill()).not.toBe('published');
});
test('a later successful save clears the notice', async () => {
respondWith({ save: [htmlResp(502), jsonResp({ success: true })] });
await mount();
edit();
await clickSave();
await clickSave();
expect(q('[data-testid="error-notice"]')).toBeNull();
});
});
describe('server refusal still goes to the dedicated banner', () => {
test('empty_overwrite_refused shows the banner, not the error notice', async () => {
respondWith({ save: [jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'r' }, 409)] });
await mount();
edit();
await clickSave();
expect(q('[data-testid="empty-save-banner"]')).not.toBeNull();
expect(q('[data-testid="error-notice"]')).toBeNull();
expect(pill()).toBe('unsaved');
});
});
+110 -49
View File
@@ -204,6 +204,41 @@ export class UnreadableSiteError extends Error {
}
}
/** What save()/publish() resolve to: the server's JSON, or a synthesized failure. */
export interface ApiResult {
success: boolean;
error?: string;
message?: string;
warnings?: string[];
[key: string]: unknown;
}
/**
* Reads a save/publish response without ever throwing on the body: a non-2xx
* status or a non-JSON body (a 502 HTML page, a PHP fatal) becomes a
* `success:false` result carrying a readable message.
*/
async function readApiResult(resp: Response, action: 'Save' | 'Publish'): Promise<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;
}
@@ -225,71 +260,97 @@ export function useWhpApi() {
} = usePages();
const { design, updateDesign } = useSiteDesign();
const { loadState, forceEmptyNextSave } = useSaveGuard();
const { loadState, forceEmptyNextSave, beginSave, endSave } = useSaveGuard();
const save = useCallback(async (opts?: SaveOptions) => {
const save = useCallback(async (opts?: SaveOptions): Promise<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 forceEmpty = !!opts?.forceEmpty || forceEmptyNextSave.current;
const forceEmpty = !!opts?.forceEmpty || forceEmptyNextSave.current;
const payload = buildSavePayload({
siteId: whpConfig.siteId,
siteName: whpConfig.siteName,
liveCraftState,
pages,
headerPage,
footerPage,
activePageId,
isEditingHeader,
isEditingFooter,
headCode: design.headCode,
design,
});
const payload = buildSavePayload({
siteId: whpConfig.siteId,
siteName: whpConfig.siteName,
liveCraftState,
pages,
headerPage,
footerPage,
activePageId,
isEditingHeader,
isEditingFooter,
headCode: design.headCode,
design,
});
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify(forceEmpty ? { ...payload, force_empty: true } : payload),
});
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify(forceEmpty ? { ...payload, force_empty: true } : payload),
});
// 409 empty_overwrite_refused is an expected outcome the UI handles, so
// return it as a structured result instead of throwing.
if (resp.status === 409) {
const body = await resp.json().catch(() => null);
if (body?.error === 'empty_overwrite_refused') {
return {
success: false,
error: 'empty_overwrite_refused',
message: body.message || 'This would replace your saved site with an empty page.',
};
// 409 empty_overwrite_refused is an expected outcome the UI handles, so
// return it as a structured result instead of throwing.
if (resp.status === 409) {
const body = await resp.json().catch(() => null);
if (body?.error === 'empty_overwrite_refused') {
return {
success: false,
error: 'empty_overwrite_refused',
message: body.message || 'This would replace your saved site with an empty page.',
};
}
return body ?? { success: false, error: 'conflict' };
}
return body ?? { success: false, error: 'conflict' };
const result = await readApiResult(resp, 'Save');
// The one-shot force_empty is spent only once a save actually went through.
if (result.success) {
if (forceEmpty) forceEmptyNextSave.current = false;
succeeded = true;
}
return result;
} finally {
endSave(ticket, succeeded);
}
}, [isWHP, whpConfig, loadState, forceEmptyNextSave, beginSave, endSave, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]);
const result = await resp.json();
// The one-shot force_empty is spent only once a save actually went through.
if (forceEmpty && result?.success) forceEmptyNextSave.current = false;
return result;
}, [isWHP, whpConfig, loadState, forceEmptyNextSave, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]);
const publish = useCallback(async () => {
const publish = useCallback(async (): Promise<ApiResult | null> => {
if (!isWHP || !whpConfig) return null;
if (loadState !== 'loaded') return null;
// First save to ensure staging is up to date
const saved = await save();
if (saved?.error === 'empty_overwrite_refused') return saved;
let saved: ApiResult | null;
try {
saved = await save();
} catch (e) {
saved = { success: false, error: 'save_failed', message: `Save failed: ${e instanceof Error ? e.message : String(e)}` };
}
// Publishing pushes the staged copy live, so it must be the copy we just
// saved: a failed save (including a refusal) means no publish request.
if (!saved?.success) {
return {
...saved,
success: false,
failedStep: 'save',
error: saved?.error ?? 'save_failed',
message: saved?.message || 'Publish cancelled: the save before publishing did not complete.',
} as ApiResult;
}
// Then publish from staging to live
const resp = await fetch(
@@ -303,7 +364,7 @@ export function useWhpApi() {
body: JSON.stringify({ site_id: whpConfig.siteId }),
},
);
return resp.json();
return readApiResult(resp, 'Publish');
}, [isWHP, whpConfig, loadState, save]);
const load = useCallback(async () => {
+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>
);
+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.');
});
+88 -107
View File
@@ -13,6 +13,8 @@ import { ReportIssueModal } from './ReportIssueModal';
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
import { PublishWarnings } from './PublishWarnings';
import { EmptySaveBanner } from './EmptySaveBanner';
import { ErrorNotice } from './ErrorNotice';
import { SaveStatusPill, SaveStatus } from './SaveStatusPill';
import { useSaveGuard } from '../../state/SaveGuardContext';
import { SitesmithButton } from '../sitesmith/SitesmithButton';
import { useSitesmithModal } from '../../state/SitesmithContext';
@@ -32,13 +34,16 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
canRedo: query.history.canRedo(),
}));
const { save, publish } = useWhpApi();
const { loadState, emptyRefusal, setEmptyRefusal } = useSaveGuard();
const { loadState, emptyRefusal, setEmptyRefusal, dirty, isDirty } = 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
@@ -48,89 +53,91 @@ 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 savingRef = useRef(false);
// An edit after a publish means the live site no longer matches the editor.
useEffect(() => {
if (dirty) setPublished(false);
}, [dirty]);
const status: SaveStatus = saving || publishing ? 'saving'
: saveFailed ? 'failed'
: dirty ? 'unsaved'
: published ? 'published'
: 'saved';
const messageOf = (e: unknown, what: string) =>
`${what} failed: ${e instanceof Error && e.message ? e.message : 'could not reach the server'}`;
/** 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(opts);
if (result?.error === 'empty_overwrite_refused') {
setEmptyRefusal(result.message ?? null);
} else if (result?.success) {
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);
setSaveFailed(true);
setErrorNotice(messageOf(e, 'Save'));
} finally {
savingRef.current = false;
setSaving(false);
}
}, [save, canSave, isDirty, setEmptyRefusal]);
// Auto-save every 30 seconds
useEffect(() => {
// 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?.error === 'empty_overwrite_refused') {
setEmptyRefusal(result.message);
} else 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);
const interval = setInterval(() => { void runSave(); }, 30000);
return () => clearInterval(interval);
}, [isWHP, canSave, emptyRefusal, setEmptyRefusal, save]);
}, [isWHP, canSave, emptyRefusal, runSave]);
const handleSave = useCallback(async (opts?: SaveOptions) => {
if (!canSave) return;
setSaveStatus('saving');
try {
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);
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 2500);
} else {
setSaveStatus('error');
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
}
} catch (e) {
console.error('Save failed:', e);
setSaveStatus('error');
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
}
}, [save, canSave, setEmptyRefusal]);
const handleSave = runSave;
const handlePublish = useCallback(async () => {
if (!canSave) return;
setPublishStatus('publishing');
setPublishing(true);
setPublishWarnings([]);
try {
const result = await publish();
if (result?.error === 'empty_overwrite_refused') {
setEmptyRefusal(result.message);
setPublishStatus('idle');
setEmptyRefusal(result.message ?? null);
} else if (result?.success) {
setPublishStatus('published');
setPublished(true);
setSaveFailed(false);
setErrorNotice(null);
setIsDraft(false);
// 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 {
setPublishing(false);
}
}, [publish, canSave, setEmptyRefusal]);
@@ -141,14 +148,6 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
/>
) : null;
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
};
}, []);
// Extracted so both the desktop inline Preview button and the mobile
// overflow-menu "Preview" item can trigger the exact same flow.
const handlePreview = useCallback(() => {
@@ -243,6 +242,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
<nav className="topbar topbar-mobile">
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
{emptyBanner}
<ErrorNotice message={errorNotice} onDismiss={() => setErrorNotice(null)} />
<div className="topbar-left">
{isWHP && (
<a
@@ -257,6 +257,7 @@ 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()}
@@ -278,22 +279,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' || !canSave}
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' || !canSave}
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
@@ -335,6 +336,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
<nav className="topbar">
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
{emptyBanner}
<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">
@@ -407,45 +409,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>
)}
{/* 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>
)}
{isWHP && <SaveStatusPill status={status} />}
<SitesmithButton onClick={() => openSitesmith()} disabled={!canSave} />
<button
className="topbar-btn primary"
onClick={() => handleSave()}
disabled={saveStatus === 'saving' || !canSave}
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</>
@@ -456,11 +437,11 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
<button
className="topbar-btn publish"
onClick={handlePublish}
disabled={publishStatus === 'publishing' || saveStatus === 'saving' || !canSave}
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</>
+87 -2
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useMemo, useRef, useState, ReactNode } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react';
import { useEditorConfig } from './EditorConfigContext';
export type LoadState = 'loading' | 'loaded' | 'failed';
@@ -15,6 +15,26 @@ interface SaveGuardContextValue {
/** 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;
/** 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;
}
/** Identifies the editor state a save was built from. */
export interface SaveTicket {
version: number;
}
// Default (no provider: standalone mode and isolated unit tests) is
@@ -26,6 +46,13 @@ const defaultValue: SaveGuardContextValue = {
setEmptyRefusal: () => {},
forceEmptyNextSave: { current: false },
armForceEmptyNextSave: () => {},
dirty: false,
isDirty: () => false,
markDirty: () => {},
beginSave: () => ({ version: 0 }),
endSave: () => {},
saving: false,
suppressBeforeUnload: () => {},
};
const SaveGuardContext = createContext<SaveGuardContextValue>(defaultValue);
@@ -38,6 +65,57 @@ export const SaveGuardProvider: React.FC<{ children: ReactNode }> = ({ children
const [emptyRefusal, setEmptyRefusal] = useState<string | null>(null);
const forceEmptyNextSave = useRef(false);
// 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 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 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,
@@ -45,7 +123,14 @@ export const SaveGuardProvider: React.FC<{ children: ReactNode }> = ({ children
setEmptyRefusal,
forceEmptyNextSave,
armForceEmptyNextSave: () => { forceEmptyNextSave.current = true; },
}), [loadState, emptyRefusal]);
dirty,
isDirty,
markDirty,
beginSave,
endSave,
saving,
suppressBeforeUnload,
}), [loadState, emptyRefusal, dirty, saving, isDirty, markDirty, beginSave, endSave, suppressBeforeUnload]);
return <SaveGuardContext.Provider value={value}>{children}</SaveGuardContext.Provider>;
};
+46
View File
@@ -1974,3 +1974,49 @@ body {
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;
}