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:
1 parent
a406ff8548
commit
9418444916
12 files changed
+842
-170
No files matched your search
@@ -0,0 +1,75 @@
|
||||
import { describe, test, expect, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Frame, Element, useEditor } from '@craftjs/core';
|
||||
import { SaveAwareEditor } from './App';
|
||||
import { Container } from './components/layout/Container';
|
||||
import { ensureJsdomShims } from './test-utils/editorHarness';
|
||||
import { EditorConfigProvider } from './state/EditorConfigContext';
|
||||
import { SaveGuardProvider, useSaveGuard } from './state/SaveGuardContext';
|
||||
import { WhpConfig } from './types';
|
||||
|
||||
/**
|
||||
* Against a REAL Craft editor: onNodesChange (what App wires to markDirty)
|
||||
* must fire for content edits and must NOT fire for selection or for the
|
||||
* load's own deserialize -- otherwise a freshly opened site would warn
|
||||
* "unsaved changes" on close.
|
||||
*/
|
||||
const whpConfig: WhpConfig = {
|
||||
user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1,
|
||||
siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
|
||||
};
|
||||
|
||||
let api: { actions: any; query: any; guard: ReturnType<typeof useSaveGuard> };
|
||||
const Capture: React.FC = () => {
|
||||
const { actions, query } = useEditor();
|
||||
const guard = useSaveGuard();
|
||||
api = { actions, query, guard };
|
||||
return null;
|
||||
};
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const STATE = JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: {}, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
|
||||
});
|
||||
|
||||
describe('canvas dirty tracking on a real Craft editor', () => {
|
||||
test('load deserialize and selection stay clean; a prop edit makes it dirty', async () => {
|
||||
ensureJsdomShims();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SaveGuardProvider>
|
||||
<SaveAwareEditor>
|
||||
<Frame><Element is={Container} canvas style={{}} /></Frame>
|
||||
<Capture />
|
||||
</SaveAwareEditor>
|
||||
</SaveGuardProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// What LoadGate does: deserialize while loading, then flip to loaded.
|
||||
act(() => { api.actions.deserialize(STATE); });
|
||||
act(() => { api.guard.setLoadState('loaded'); });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(api.guard.dirty).toBe(false);
|
||||
|
||||
act(() => { api.actions.selectNode('ROOT'); });
|
||||
expect(api.guard.dirty).toBe(false);
|
||||
|
||||
act(() => { api.actions.setProp('ROOT', (p: any) => { p.tag = 'section'; }); });
|
||||
expect(api.guard.dirty).toBe(true);
|
||||
});
|
||||
});
|
||||
+14
-3
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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.');
|
||||
});
|
||||
|
||||
|
||||
@@ -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</>
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user