feat(site-builder): detect two-tab save conflicts; Reload latest / Keep mine modal
Refs #31 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a20c9bae28
commit
a6db5438b2
6 files changed
+398
-13
No files matched your search
@@ -0,0 +1,232 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
/**
|
||||
* Data-safety: a failed initial load must never let autosave overwrite the
|
||||
* saved site with a blank canvas. Drives the real LoadGate + TopBar +
|
||||
* SiteDesignPanel against a mocked `fetch` (the backend contract) and fake
|
||||
* timers; only `@craftjs/core`'s useEditor and sibling chrome are stubbed.
|
||||
*/
|
||||
const undoMock = vi.fn();
|
||||
const deserializeMock = vi.fn();
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: (collector?: (state: unknown, query: unknown) => Record<string, unknown>) => {
|
||||
const query = {
|
||||
serialize: () => '{}',
|
||||
history: { canUndo: () => false, canRedo: () => false },
|
||||
};
|
||||
const actions = { history: { undo: undoMock, redo: vi.fn() }, deserialize: deserializeMock };
|
||||
const collected = collector ? collector({}, query) : {};
|
||||
return { actions, query, ...collected };
|
||||
},
|
||||
}));
|
||||
vi.mock('../panels/topbar/TemplateModal', () => ({ TemplateModal: () => null }));
|
||||
vi.mock('../panels/topbar/HeadCodeModal', () => ({ HeadCodeModal: () => null }));
|
||||
vi.mock('../panels/sitesmith/SitesmithButton', () => ({ SitesmithButton: () => null }));
|
||||
|
||||
import { LoadGate } from './LoadGate';
|
||||
import { TopBar } from '../panels/topbar/TopBar';
|
||||
import { SiteDesignPanel } from '../panels/right/SiteDesignPanel';
|
||||
import { EditorConfigProvider } from '../state/EditorConfigContext';
|
||||
import { SaveGuardProvider, useSaveGuard } from '../state/SaveGuardContext';
|
||||
import { PageProvider } from '../state/PageContext';
|
||||
import { SiteDesignProvider } from '../state/SiteDesignContext';
|
||||
import { WhpConfig } from '../types';
|
||||
|
||||
const whpConfig: WhpConfig = {
|
||||
user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1,
|
||||
siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
|
||||
};
|
||||
|
||||
const jsonResp = (body: unknown, status = 200) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
const reloadMock = vi.fn();
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
const saveCalls = () => fetchMock.mock.calls.filter(([u]) => String(u).includes('action=save'));
|
||||
const saveBody = (i: number) => JSON.parse(saveCalls()[i][1].body);
|
||||
const q = (sel: string) => container.querySelector(sel) as HTMLElement | null;
|
||||
const flush = () => act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
let guard: ReturnType<typeof useSaveGuard>;
|
||||
const GuardProbe: React.FC = () => { guard = useSaveGuard(); return null; };
|
||||
|
||||
function mount() {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SaveGuardProvider reloadPage={reloadMock}>
|
||||
<SiteDesignProvider>
|
||||
<PageProvider>
|
||||
<LoadGate />
|
||||
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
|
||||
<SiteDesignPanel />
|
||||
<GuardProbe />
|
||||
</PageProvider>
|
||||
</SiteDesignProvider>
|
||||
</SaveGuardProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
undoMock.mockClear();
|
||||
deserializeMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
|
||||
beforeEach(() => { reloadMock.mockReset(); });
|
||||
|
||||
const loadedThen = (rev: string | null, saveResponses: unknown[]) => {
|
||||
const saves = [...saveResponses];
|
||||
fetchMock.mockImplementation(async (url: string) =>
|
||||
String(url).includes('action=load')
|
||||
? jsonResp({ success: true, project: null, rev })
|
||||
: (saves.shift() ?? jsonResp({ success: true })));
|
||||
};
|
||||
const clickSave = async () => {
|
||||
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
|
||||
await flush();
|
||||
};
|
||||
const conflict409 = jsonResp({ success: false, error: 'conflict', message: 'm', current_rev: 'r9' }, 409);
|
||||
|
||||
describe('revision tracking', () => {
|
||||
test('base_rev from load is sent, and rev updates after each successful save', async () => {
|
||||
loadedThen('r1', [jsonResp({ success: true, rev: 'r2' }), jsonResp({ success: true, rev: 'r3' })]);
|
||||
mount();
|
||||
await flush();
|
||||
await clickSave();
|
||||
await clickSave();
|
||||
expect(saveBody(0).base_rev).toBe('r1');
|
||||
expect(saveBody(1).base_rev).toBe('r2');
|
||||
expect(saveBody(0)._rev).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a project-less site sends base_rev: null (key present)', async () => {
|
||||
loadedThen(null, []);
|
||||
mount();
|
||||
await flush();
|
||||
await clickSave();
|
||||
expect('base_rev' in saveBody(0)).toBe(true);
|
||||
expect(saveBody(0).base_rev).toBeNull();
|
||||
});
|
||||
|
||||
test('autosave sends base_rev too', async () => {
|
||||
loadedThen('r1', []);
|
||||
mount();
|
||||
await flush();
|
||||
act(() => { guard.markDirty(); });
|
||||
await act(async () => { vi.advanceTimersByTime(30000); });
|
||||
expect(saveBody(0).base_rev).toBe('r1');
|
||||
});
|
||||
|
||||
test('a load without rev (older backend) sends no base_rev', async () => {
|
||||
fetchMock.mockImplementation(async (url: string) =>
|
||||
jsonResp(String(url).includes('action=load') ? { success: true, project: null } : { success: true }));
|
||||
mount();
|
||||
await flush();
|
||||
await clickSave();
|
||||
expect('base_rev' in saveBody(0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conflict modal', () => {
|
||||
test('a 409 conflict opens the modal (not the toast) and pauses autosave', async () => {
|
||||
loadedThen('r1', [conflict409]);
|
||||
mount();
|
||||
await flush();
|
||||
await clickSave();
|
||||
expect(q('[data-testid="conflict-modal"]')!.textContent).toContain(
|
||||
'This site was changed in another tab or device since you opened it.',
|
||||
);
|
||||
expect(q('[data-testid="error-notice"]')).toBeNull();
|
||||
act(() => { guard.markDirty(); });
|
||||
await act(async () => { vi.advanceTimersByTime(90000); });
|
||||
expect(saveCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Keep mine re-saves with force_overwrite, stores the new rev and closes the modal', async () => {
|
||||
loadedThen('r1', [conflict409, jsonResp({ success: true, rev: 'r10' }), jsonResp({ success: true })]);
|
||||
mount();
|
||||
await flush();
|
||||
await clickSave();
|
||||
await act(async () => { q('[data-testid="conflict-keep-mine"]')!.click(); });
|
||||
await flush();
|
||||
expect(saveBody(0).force_overwrite).toBeUndefined();
|
||||
expect(saveBody(1).force_overwrite).toBe(true);
|
||||
expect(q('[data-testid="conflict-modal"]')).toBeNull();
|
||||
await clickSave();
|
||||
expect(saveBody(2).base_rev).toBe('r10');
|
||||
expect(saveBody(2).force_overwrite).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Reload latest suppresses the unload prompt, reloads, and sends no save', async () => {
|
||||
loadedThen('r1', [conflict409]);
|
||||
mount();
|
||||
await flush();
|
||||
await clickSave();
|
||||
const prevented: boolean[] = [];
|
||||
act(() => { guard.markDirty(); });
|
||||
act(() => { q('[data-testid="conflict-reload"]')!.click(); });
|
||||
const ev = new Event('beforeunload', { cancelable: true });
|
||||
window.dispatchEvent(ev);
|
||||
prevented.push(ev.defaultPrevented);
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
expect(prevented).toEqual([false]);
|
||||
expect(saveCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a conflict inside publish opens the modal and never publishes', async () => {
|
||||
loadedThen('r1', [conflict409]);
|
||||
mount();
|
||||
await flush();
|
||||
await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); });
|
||||
await flush();
|
||||
expect(q('[data-testid="conflict-modal"]')).not.toBeNull();
|
||||
expect(fetchMock.mock.calls.filter(([u]) => String(u).includes('action=publish'))).toHaveLength(0);
|
||||
expect(q('[data-testid="error-notice"]')).toBeNull();
|
||||
});
|
||||
|
||||
test('Keep mine refused as empty falls to the banner, and Save anyway still overwrites', async () => {
|
||||
loadedThen('r1', [
|
||||
conflict409,
|
||||
jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'e' }, 409),
|
||||
jsonResp({ success: true, rev: 'r5' }),
|
||||
]);
|
||||
mount();
|
||||
await flush();
|
||||
await clickSave();
|
||||
await act(async () => { q('[data-testid="conflict-keep-mine"]')!.click(); });
|
||||
await flush();
|
||||
expect(q('[data-testid="conflict-modal"]')).toBeNull();
|
||||
expect(q('[data-testid="empty-save-banner"]')).not.toBeNull();
|
||||
await act(async () => { q('[data-testid="empty-save-anyway"]')!.click(); });
|
||||
await flush();
|
||||
expect(saveBody(2).force_overwrite).toBe(true);
|
||||
expect(saveBody(2).force_empty).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -277,6 +277,8 @@ async function readApiResult(resp: Response, action: 'Save' | 'Publish'): Promis
|
||||
|
||||
export interface SaveOptions {
|
||||
forceEmpty?: boolean;
|
||||
/** "Keep mine": bypass the another-tab conflict check. */
|
||||
forceOverwrite?: boolean;
|
||||
}
|
||||
|
||||
export function useWhpApi() {
|
||||
@@ -296,7 +298,10 @@ export function useWhpApi() {
|
||||
} = usePages();
|
||||
const { design, updateDesign } = useSiteDesign();
|
||||
|
||||
const { loadState, forceEmptyNextSave, beginSave, endSave } = useSaveGuard();
|
||||
const {
|
||||
loadState, forceEmptyNextSave, beginSave, endSave,
|
||||
getRev, setRev, setConflict, forceOverwriteNextSave,
|
||||
} = useSaveGuard();
|
||||
|
||||
const save = useCallback(async (opts?: SaveOptions): Promise<ApiResult | null> => {
|
||||
if (!isWHP || !whpConfig) return null;
|
||||
@@ -314,7 +319,9 @@ export function useWhpApi() {
|
||||
// isEditingFooter).
|
||||
const liveCraftState = query.serialize();
|
||||
|
||||
const baseRev = getRev();
|
||||
const forceEmpty = !!opts?.forceEmpty || forceEmptyNextSave.current;
|
||||
const forceOverwrite = !!opts?.forceOverwrite || forceOverwriteNextSave.current;
|
||||
|
||||
const payload = buildSavePayload({
|
||||
siteId: whpConfig.siteId,
|
||||
@@ -336,7 +343,14 @@ export function useWhpApi() {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': whpConfig.csrfToken,
|
||||
},
|
||||
body: JSON.stringify(forceEmpty ? { ...payload, force_empty: true } : payload),
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
// Present on every save so the server can detect a newer copy; left
|
||||
// out only when load never reported a rev (an older backend).
|
||||
...(baseRev !== undefined ? { base_rev: baseRev } : {}),
|
||||
...(forceEmpty ? { force_empty: true } : {}),
|
||||
...(forceOverwrite ? { force_overwrite: true } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
// 409 empty_overwrite_refused is an expected outcome the UI handles, so
|
||||
@@ -350,20 +364,25 @@ export function useWhpApi() {
|
||||
message: body.message || 'This would replace your saved site with an empty page.',
|
||||
};
|
||||
}
|
||||
return body ?? { success: false, error: 'conflict' };
|
||||
// Opens the conflict modal (here, so the save inside publish raises it too).
|
||||
const message = body?.message || 'This site was changed in another tab or device since you opened it.';
|
||||
setConflict(message);
|
||||
return { ...(body ?? {}), success: false, error: 'conflict', message };
|
||||
}
|
||||
|
||||
const result = await readApiResult(resp, 'Save');
|
||||
// The one-shot force_empty is spent only once a save actually went through.
|
||||
if (result.success) {
|
||||
if (forceEmpty) forceEmptyNextSave.current = false;
|
||||
if (forceOverwrite) forceOverwriteNextSave.current = false;
|
||||
if (typeof result.rev === 'string') setRev(result.rev);
|
||||
succeeded = true;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
endSave(ticket, succeeded);
|
||||
}
|
||||
}, [isWHP, whpConfig, loadState, forceEmptyNextSave, beginSave, endSave, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]);
|
||||
}, [isWHP, whpConfig, loadState, forceEmptyNextSave, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]);
|
||||
|
||||
const publish = useCallback(async (): Promise<ApiResult | null> => {
|
||||
if (!isWHP || !whpConfig) return null;
|
||||
@@ -420,6 +439,10 @@ export function useWhpApi() {
|
||||
throw new Error(`Load failed: ${data?.message || data?.error || 'server reported failure'}`);
|
||||
}
|
||||
|
||||
// The baseline for every later save's conflict check. proj._rev (inside
|
||||
// the project) is ignored: buildSavePayload never echoes it back.
|
||||
if (typeof data.rev === 'string' || data.rev === null) setRev(data.rev);
|
||||
|
||||
// project: null is a success (brand-new site, nothing saved yet).
|
||||
if (data.project) {
|
||||
const proj = data.project;
|
||||
@@ -514,7 +537,7 @@ export function useWhpApi() {
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter, updateDesign]);
|
||||
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter, updateDesign, setRev]);
|
||||
|
||||
const uploadAsset = useCallback(
|
||||
async (file: File) => {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface ConflictModalProps {
|
||||
/** Why the save was refused (the server's message); a fixed fallback when empty. */
|
||||
message?: string | null;
|
||||
/** True while "Keep mine" is re-saving. */
|
||||
busy: boolean;
|
||||
/** Set when "Keep mine" failed, so the customer sees why the modal is still up. */
|
||||
error?: string | null;
|
||||
onReload: () => void;
|
||||
onKeepMine: () => void;
|
||||
}
|
||||
|
||||
/** Blocking choice shown when the server holds a newer copy of the site than
|
||||
* the one this editor opened (another tab or device saved). Autosave stays
|
||||
* paused while it's up; there is deliberately no dismiss. */
|
||||
export const ConflictModal: React.FC<ConflictModalProps> = ({ busy, error, onReload, onKeepMine }) => (
|
||||
<div className="conflict-backdrop" data-testid="conflict-modal">
|
||||
<div className="conflict-modal" role="alertdialog" aria-modal="true" aria-labelledby="conflict-modal-title">
|
||||
<h3 id="conflict-modal-title">Site changed elsewhere</h3>
|
||||
<p>This site was changed in another tab or device since you opened it.</p>
|
||||
<p>Reload latest discards your changes here and loads the newer copy. Keep mine overwrites the newer copy with what you have now.</p>
|
||||
{error && <p className="conflict-modal-error" role="alert" data-testid="conflict-error">{error}</p>}
|
||||
<div className="conflict-modal-actions">
|
||||
<button type="button" onClick={onReload} disabled={busy} data-testid="conflict-reload">Reload latest</button>
|
||||
<button type="button" onClick={onKeepMine} disabled={busy} data-testid="conflict-keep-mine">Keep mine</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -13,6 +13,7 @@ import { ReportIssueModal } from './ReportIssueModal';
|
||||
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
|
||||
import { PublishWarnings } from './PublishWarnings';
|
||||
import { EmptySaveBanner } from './EmptySaveBanner';
|
||||
import { ConflictModal } from './ConflictModal';
|
||||
import { ErrorNotice } from './ErrorNotice';
|
||||
import { SaveStatusPill, SaveStatus } from './SaveStatusPill';
|
||||
import { useSaveGuard } from '../../state/SaveGuardContext';
|
||||
@@ -34,7 +35,10 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
canRedo: query.history.canRedo(),
|
||||
}));
|
||||
const { save, publish } = useWhpApi();
|
||||
const { loadState, emptyRefusal, setEmptyRefusal, dirty, isDirty, getVersion } = useSaveGuard();
|
||||
const {
|
||||
loadState, emptyRefusal, setEmptyRefusal, dirty, isDirty, getVersion,
|
||||
conflict, setConflict, armForceOverwriteNextSave, reloadLatest,
|
||||
} = useSaveGuard();
|
||||
const canSave = loadState === 'loaded';
|
||||
const { headerPage, footerPage, pages, activePageId } = usePages();
|
||||
const { design } = useSiteDesign();
|
||||
@@ -80,9 +84,13 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await save(opts);
|
||||
if (result?.error === 'empty_overwrite_refused') {
|
||||
if (result?.error === 'conflict') {
|
||||
// The conflict modal (opened by save()) owns this outcome, not the error toast.
|
||||
} else if (result?.error === 'empty_overwrite_refused') {
|
||||
setConflict(null);
|
||||
setEmptyRefusal(result.message ?? null);
|
||||
} else if (result?.success) {
|
||||
setConflict(null);
|
||||
setEmptyRefusal(null);
|
||||
setSaveFailed(false);
|
||||
setErrorNotice(null);
|
||||
@@ -99,18 +107,18 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
}, [save, canSave, isDirty, setEmptyRefusal]);
|
||||
}, [save, canSave, isDirty, setEmptyRefusal, setConflict]);
|
||||
|
||||
// Auto-save every 30 seconds
|
||||
useEffect(() => {
|
||||
// Paused until the site has loaded, and while the empty-save banner is up.
|
||||
if (!isWHP || !canSave || emptyRefusal) return;
|
||||
if (!isWHP || !canSave || emptyRefusal || conflict) return;
|
||||
// A running publish does its own save; a second one would race it.
|
||||
// Read through refs/isDirty() at tick time, never a stale closure.
|
||||
const autosaveDue = () => isDirty() && !publishingRef.current;
|
||||
const interval = setInterval(() => { if (autosaveDue()) void runSave(); }, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isWHP, canSave, emptyRefusal, runSave, isDirty]);
|
||||
}, [isWHP, canSave, emptyRefusal, conflict, runSave, isDirty]);
|
||||
|
||||
const handleSave = runSave;
|
||||
|
||||
@@ -123,7 +131,10 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
setPublishWarnings([]);
|
||||
try {
|
||||
const result = await publish();
|
||||
if (result?.error === 'empty_overwrite_refused') {
|
||||
if (result?.error === 'conflict') {
|
||||
// Nothing was published: the conflict modal (opened by save()) takes over.
|
||||
setSaveFailed(true);
|
||||
} else if (result?.error === 'empty_overwrite_refused') {
|
||||
setEmptyRefusal(result.message ?? null);
|
||||
} else if (result?.success) {
|
||||
const unchanged = getVersion() === versionAtStart;
|
||||
@@ -151,6 +162,24 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
}
|
||||
}, [publish, canSave, getVersion, setEmptyRefusal]);
|
||||
|
||||
// "Keep mine": arm force_overwrite so it survives an empty-overwrite refusal
|
||||
// followed by "Save anyway", then re-save. runSave closes the modal on success.
|
||||
const handleKeepMine = useCallback(async () => {
|
||||
setErrorNotice(null);
|
||||
armForceOverwriteNextSave();
|
||||
await runSave();
|
||||
}, [armForceOverwriteNextSave, runSave]);
|
||||
|
||||
const conflictModal = conflict ? (
|
||||
<ConflictModal
|
||||
message={conflict}
|
||||
busy={saving}
|
||||
error={errorNotice}
|
||||
onReload={reloadLatest}
|
||||
onKeepMine={() => { void handleKeepMine(); }}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const emptyBanner = emptyRefusal ? (
|
||||
<EmptySaveBanner
|
||||
onSaveAnyway={() => { void handleSave({ forceEmpty: true }); }}
|
||||
@@ -252,6 +281,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
<nav className="topbar topbar-mobile">
|
||||
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
|
||||
{emptyBanner}
|
||||
{conflictModal}
|
||||
<ErrorNotice message={errorNotice} onDismiss={() => setErrorNotice(null)} />
|
||||
<div className="topbar-left">
|
||||
{isWHP && (
|
||||
@@ -346,6 +376,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
<nav className="topbar">
|
||||
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
|
||||
{emptyBanner}
|
||||
{conflictModal}
|
||||
<ErrorNotice message={errorNotice} onDismiss={() => setErrorNotice(null)} />
|
||||
<div className="topbar-left">
|
||||
{isWHP && (
|
||||
|
||||
@@ -32,6 +32,18 @@ interface SaveGuardContextValue {
|
||||
saving: boolean;
|
||||
/** Skip the unsaved-changes prompt for the next unload (e.g. a deliberate "Reload latest"). */
|
||||
suppressBeforeUnload: () => void;
|
||||
/** Server revision the editor last loaded or saved: a string, null (no project on the
|
||||
* server yet), or undefined (unknown -- an older backend sent none, so saves skip the check). */
|
||||
getRev: () => string | null | undefined;
|
||||
setRev: (rev: string | null) => void;
|
||||
/** Server message while the "changed in another tab" modal is up; autosave stays paused. */
|
||||
conflict: string | null;
|
||||
setConflict: (message: string | null) => void;
|
||||
/** One-shot: the next save sends force_overwrite (armed by "Keep mine", spent by a save that goes through). */
|
||||
forceOverwriteNextSave: React.MutableRefObject<boolean>;
|
||||
armForceOverwriteNextSave: () => void;
|
||||
/** "Reload latest": skip the unsaved-changes prompt, then reload the whole editor. */
|
||||
reloadLatest: () => void;
|
||||
}
|
||||
|
||||
/** Identifies the editor state a save was built from. */
|
||||
@@ -56,17 +68,29 @@ const defaultValue: SaveGuardContextValue = {
|
||||
endSave: () => {},
|
||||
saving: false,
|
||||
suppressBeforeUnload: () => {},
|
||||
getRev: () => undefined,
|
||||
setRev: () => {},
|
||||
conflict: null,
|
||||
setConflict: () => {},
|
||||
forceOverwriteNextSave: { current: false },
|
||||
armForceOverwriteNextSave: () => {},
|
||||
reloadLatest: () => {},
|
||||
};
|
||||
|
||||
const SaveGuardContext = createContext<SaveGuardContextValue>(defaultValue);
|
||||
|
||||
export const useSaveGuard = () => useContext(SaveGuardContext);
|
||||
|
||||
export const SaveGuardProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
export const SaveGuardProvider: React.FC<{ children: ReactNode; /** Injectable for tests; defaults to a full page reload. */ reloadPage?: () => void }> = ({ children, reloadPage }) => {
|
||||
const { isWHP } = useEditorConfig();
|
||||
const [loadState, setLoadState] = useState<LoadState>(isWHP ? 'loading' : 'loaded');
|
||||
const [emptyRefusal, setEmptyRefusal] = useState<string | null>(null);
|
||||
const forceEmptyNextSave = useRef(false);
|
||||
const [conflict, setConflict] = useState<string | null>(null);
|
||||
const forceOverwriteNextSave = useRef(false);
|
||||
const revRef = useRef<string | null | undefined>(undefined);
|
||||
const getRev = useCallback(() => revRef.current, []);
|
||||
const setRev = useCallback((rev: string | null) => { revRef.current = rev; }, []);
|
||||
|
||||
// Dirty tracking. `versionRef` counts changes so a save that raced with an
|
||||
// edit cannot mark that edit clean.
|
||||
@@ -107,6 +131,11 @@ export const SaveGuardProvider: React.FC<{ children: ReactNode }> = ({ children
|
||||
}, []);
|
||||
const suppressBeforeUnload = useCallback(() => { suppressRef.current = true; }, []);
|
||||
|
||||
const reloadLatest = useCallback(() => {
|
||||
suppressRef.current = true;
|
||||
(reloadPage ?? (() => window.location.reload()))();
|
||||
}, [reloadPage]);
|
||||
|
||||
const saving = savesInFlight > 0;
|
||||
const needsPrompt = isWHP && (dirty || saving);
|
||||
useEffect(() => {
|
||||
@@ -135,7 +164,14 @@ export const SaveGuardProvider: React.FC<{ children: ReactNode }> = ({ children
|
||||
endSave,
|
||||
saving,
|
||||
suppressBeforeUnload,
|
||||
}), [loadState, emptyRefusal, dirty, saving, isDirty, getVersion, markDirty, beginSave, endSave, suppressBeforeUnload]);
|
||||
getRev,
|
||||
setRev,
|
||||
conflict,
|
||||
setConflict,
|
||||
forceOverwriteNextSave,
|
||||
armForceOverwriteNextSave: () => { forceOverwriteNextSave.current = true; },
|
||||
reloadLatest,
|
||||
}), [loadState, emptyRefusal, dirty, saving, isDirty, getVersion, markDirty, beginSave, endSave, suppressBeforeUnload, getRev, setRev, conflict, reloadLatest]);
|
||||
|
||||
return <SaveGuardContext.Provider value={value}>{children}</SaveGuardContext.Provider>;
|
||||
};
|
||||
@@ -2020,3 +2020,36 @@ body {
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.conflict-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
}
|
||||
/* Another-tab conflict modal */
|
||||
.conflict-modal {
|
||||
max-width: 420px;
|
||||
margin: auto;
|
||||
padding: 20px;
|
||||
background: #1e293b;
|
||||
border: 1px solid #475569;
|
||||
border-radius: 8px;
|
||||
color: #e2e8f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.conflict-modal h3 { margin: 0 0 8px; font-size: 16px; }
|
||||
.conflict-modal p { margin: 0 0 8px; }
|
||||
.conflict-modal-error { color: #fecaca; }
|
||||
.conflict-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
|
||||
.conflict-modal-actions button {
|
||||
background: none;
|
||||
border: 1px solid #94a3b8;
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
.conflict-modal-actions button:disabled { opacity: 0.5; cursor: default; }
|
||||
Reference in new issue
Block a user