feat(site-builder): add Reset Page to the Pages panel

Adds a "Reset to blank" control to each page row in PagesPanel, behind an
inline confirmation matching the existing delete-confirmation UI. Blanks
the target page's canvas to EMPTY_CANVAS (Task 8) via actions.deserialize.

Since deserialize() acts on the live Frame, resetting a page that isn't
on screen switches to it first (switchPage), then defers the blank via
its own setTimeout(0) -- same-delay setTimeout callbacks fire in
registration order, so switchPage's own deferred load (also setTimeout(0),
registered first) always resolves before the blank does. Verified this
ordering empirically by injecting the reversed-order regression and
confirming the new ordering-sensitive integration test catches it.

Ran the brief's undo-characterization test first: actions.deserialize() IS
recorded in this @craftjs/core version's undo stack, so the confirmation
dialog's "Ctrl+Z undoes this." claim is accurate and was kept.

The reset button lives only in the per-page action row (not the separate
Header/Footer zone-row block), verified structurally and by test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 10:02:59 -07:00
co-authored by Claude Opus 5
parent e5fd74d63d
commit bfcf6278e9
2 changed files with 281 additions and 1 deletions
@@ -0,0 +1,195 @@
import { describe, test, expect } from 'vitest';
import React from 'react';
import { renderEditorHarness } from '../../test-utils/editorHarness';
import { EMPTY_CANVAS, PageProvider, usePages } from '../../state/PageContext';
import { PagesPanel } from './PagesPanel';
const withHeading = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' }, isCanvas: true,
props: { style: {}, tag: 'div' }, displayName: 'Container',
custom: {}, hidden: false, nodes: ['h1'], linkedNodes: {}, parent: null,
},
h1: {
type: { resolvedName: 'Heading' }, isCanvas: false,
props: { text: 'Keep me', level: 2 }, displayName: 'Heading',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
},
});
/** Same real-timer-flush pattern as `PageContext.orphan-repair-wiring.test.tsx`
* -- `PageContext.loadState`/this feature's own reset handler both schedule
* their `actions.deserialize()` calls via `setTimeout(..., 0)` rather than
* applying them synchronously, so tests that exercise either path need to
* let a real macrotask turn run before asserting on the live Frame. */
async function flushTimers(harness: ReturnType<typeof renderEditorHarness>): Promise<void> {
await harness.act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
}
function clickByLabel(container: HTMLElement, label: string): HTMLButtonElement {
const btn = container.querySelector(`[aria-label="${label}"]`) as HTMLButtonElement | null;
if (!btn) throw new Error(`No button found with aria-label "${label}"`);
return btn;
}
function clickByText(container: HTMLElement, text: string): HTMLButtonElement {
const btn = Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === text,
) as HTMLButtonElement | undefined;
if (!btn) throw new Error(`No button found with text "${text}"`);
return btn;
}
describe('resetting a page to EMPTY_CANVAS (Craft.js characterisation)', () => {
test('clears the canvas', () => {
const harness = renderEditorHarness({ initialState: withHeading });
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual(['h1']);
harness.act(() => { harness.actions.deserialize(EMPTY_CANVAS); });
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
harness.unmount();
});
test('the reset is undoable through Craft history', () => {
const harness = renderEditorHarness({ initialState: withHeading });
harness.act(() => { harness.actions.deserialize(EMPTY_CANVAS); });
harness.act(() => { harness.actions.history.undo(); });
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual(['h1']);
harness.unmount();
});
});
describe('PagesPanel Reset Page control', () => {
test('a Reset button is rendered for every page row, and the dialog names the page', () => {
const harness = renderEditorHarness();
harness.mountChild(
<PageProvider>
<PagesPanel />
</PageProvider>,
);
// Fresh PageProvider starts with exactly one page: "Home".
const erasers = harness.container.querySelectorAll('.fa-eraser');
expect(erasers.length).toBe(1);
expect(clickByLabel(harness.container, 'Reset Home to blank')).toBeTruthy();
harness.act(() => {
clickByLabel(harness.container, 'Reset Home to blank').click();
});
expect(harness.container.textContent).toContain('Clear every element from "Home"?');
expect(harness.container.textContent).toContain('Ctrl+Z undoes this.');
harness.unmount();
});
test('the Header and Footer zone rows never grow a Reset control', () => {
const harness = renderEditorHarness();
harness.mountChild(
<PageProvider>
<PagesPanel />
</PageProvider>,
);
const zoneRows = harness.container.querySelectorAll('.zone-row');
expect(zoneRows.length).toBe(2); // Header, Footer
zoneRows.forEach((row) => {
expect(row.querySelector('.fa-eraser')).toBeNull();
});
// No aria-label anywhere in the panel offers to reset the header/footer.
expect(harness.container.querySelector('[aria-label="Reset Header to blank"]')).toBeNull();
expect(harness.container.querySelector('[aria-label="Reset Footer to blank"]')).toBeNull();
harness.unmount();
});
test('clicking Reset then Cancel leaves the active page untouched', () => {
const harness = renderEditorHarness({ initialState: withHeading });
harness.mountChild(
<PageProvider>
<PagesPanel />
</PageProvider>,
);
harness.act(() => {
clickByLabel(harness.container, 'Reset Home to blank').click();
});
harness.act(() => {
clickByText(harness.container, 'Cancel').click();
});
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual(['h1']);
harness.unmount();
});
test('clicking Reset then confirming blanks the ACTIVE page', async () => {
const harness = renderEditorHarness({ initialState: withHeading });
harness.mountChild(
<PageProvider>
<PagesPanel />
</PageProvider>,
);
harness.act(() => {
clickByLabel(harness.container, 'Reset Home to blank').click();
});
harness.act(() => {
clickByText(harness.container, 'Reset page').click();
});
await flushTimers(harness);
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
harness.unmount();
});
test('resetting a page that is NOT on screen switches to it first, then blanks it -- ' +
'the switch-in load must resolve BEFORE the blank, or the blank would just get ' +
'overwritten by the page\'s real content', async () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Probe: React.FC = () => {
ctx = usePages();
return null;
};
const harness = renderEditorHarness({ initialState: withHeading });
harness.mountChild(
<PageProvider>
<Probe />
<PagesPanel />
</PageProvider>,
);
// addPage() saves the live Frame (withHeading) into Home's slot, adds
// "About", and switches the live Frame to About's (empty) canvas.
harness.act(() => { ctx!.addPage('About', 'about'); });
await flushTimers(harness);
expect(ctx!.activePageId).not.toBe('home');
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]); // About is blank
// Now reset Home while About is the active/on-screen page.
harness.act(() => {
clickByLabel(harness.container, 'Reset Home to blank').click();
});
harness.act(() => {
clickByText(harness.container, 'Reset page').click();
});
await flushTimers(harness);
// The live Frame now shows Home (switchPage ran) and it's blank (reset ran
// after the switch-in load, not before it).
expect(ctx!.activePageId).toBe('home');
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
// And the blank state actually persisted into Home's stored slot, not
// just transiently on the Frame: switch away and back, still blank.
const aboutId = ctx!.pages.find((p) => p.id !== 'home')!.id;
harness.act(() => { ctx!.switchPage(aboutId); });
await flushTimers(harness);
harness.act(() => { ctx!.switchPage('home'); });
await flushTimers(harness);
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
harness.unmount();
});
});
+86 -1
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { usePages } from '../../state/PageContext';
import { useEditor } from '@craftjs/core';
import { usePages, EMPTY_CANVAS } from '../../state/PageContext';
import { clickableProps } from '../../utils/a11y';
import { PageSettingsModal } from './PageSettingsModal';
@@ -20,6 +21,7 @@ export const PagesPanel: React.FC = () => {
setLandingPage,
updatePageSeo,
} = usePages();
const { actions: editorActions } = useEditor();
const [isAdding, setIsAdding] = useState(false);
const [newName, setNewName] = useState('');
const [newSlug, setNewSlug] = useState('');
@@ -27,6 +29,7 @@ export const PagesPanel: React.FC = () => {
const [editName, setEditName] = useState('');
const [editSlug, setEditSlug] = useState('');
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [resetConfirmId, setResetConfirmId] = useState<string | null>(null);
const [seoSettingsPageId, setSeoSettingsPageId] = useState<string | null>(null);
const seoSettingsPage = pages.find((p) => p.id === seoSettingsPageId) || null;
@@ -49,6 +52,26 @@ export const PagesPanel: React.FC = () => {
setDeleteConfirmId(null);
};
/**
* Blanks `pageId`'s canvas to `EMPTY_CANVAS`. `actions.deserialize()` acts
* on the LIVE `<Frame>`, so resetting a page that isn't currently on
* screen requires switching to it first. `switchPage` itself defers its
* own `actions.deserialize(targetState)` via `setTimeout(..., 0)` (see
* `PageContext.loadState`) rather than applying it synchronously -- so the
* blanking `setTimeout` scheduled here must run AFTER that one, or it
* would blank the frame and then have `switchPage`'s own deferred load
* immediately overwrite the blank with the target page's real content.
* Since `switchPage(pageId)` runs synchronously above (registering its
* internal setTimeout first) before this function schedules its own,
* same-delay `setTimeout` callbacks fire in registration order -- the
* switch's load always resolves before this reset does.
*/
const handleResetPage = (pageId: string): void => {
if (pageId !== activePageId) switchPage(pageId);
setTimeout(() => editorActions.deserialize(EMPTY_CANVAS), 0);
setResetConfirmId(null);
};
const startEditing = (page: { id: string; name: string; slug: string }) => {
setEditingId(page.id);
setEditName(page.name);
@@ -301,6 +324,60 @@ export const PagesPanel: React.FC = () => {
</button>
</div>
</div>
) : resetConfirmId === page.id ? (
/* Reset-to-blank confirmation -- mirrors the delete confirmation
* above, since both are destructive per-page actions. */
<div
style={{
padding: 10,
background: 'var(--color-bg-elevated)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-danger)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text)' }}>
Clear every element from "{page.name}"? Your header, footer and other
pages are untouched, and the published site doesn't change until you
publish again. Ctrl+Z undoes this.
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => handleResetPage(page.id)}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: '#fff',
background: 'var(--color-danger)',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Reset page
</button>
<button
onClick={() => setResetConfirmId(null)}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-muted)',
background: 'var(--color-bg-base)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>
) : (
/* Normal page item */
<div
@@ -437,6 +514,14 @@ export const PagesPanel: React.FC = () => {
>
<i className="fa fa-pencil" aria-hidden="true" />
</button>
<button
onClick={() => setResetConfirmId(page.id)}
data-tooltip="Reset to blank"
aria-label={`Reset ${page.name} to blank`}
style={pageActionBtnStyle({ danger: true })}
>
<i className="fa fa-eraser" aria-hidden="true" />
</button>
{pages.length > 1 && !isLanding && (
<button
onClick={() => setDeleteConfirmId(page.id)}