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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user