From bfcf6278e9f920b1d9761167358639db6e31cb65 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 9 Aug 2026 10:02:59 -0700 Subject: [PATCH] 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) --- .../src/panels/left/PagesPanel.reset.test.tsx | 195 ++++++++++++++++++ craft/src/panels/left/PagesPanel.tsx | 87 +++++++- 2 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 craft/src/panels/left/PagesPanel.reset.test.tsx diff --git a/craft/src/panels/left/PagesPanel.reset.test.tsx b/craft/src/panels/left/PagesPanel.reset.test.tsx new file mode 100644 index 0000000..f7e5e30 --- /dev/null +++ b/craft/src/panels/left/PagesPanel.reset.test.tsx @@ -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): Promise { + 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( + + + , + ); + + // 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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 | null = null; + const Probe: React.FC = () => { + ctx = usePages(); + return null; + }; + + const harness = renderEditorHarness({ initialState: withHeading }); + harness.mountChild( + + + + , + ); + + // 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(); + }); +}); diff --git a/craft/src/panels/left/PagesPanel.tsx b/craft/src/panels/left/PagesPanel.tsx index 586c79f..f0ecd46 100644 --- a/craft/src/panels/left/PagesPanel.tsx +++ b/craft/src/panels/left/PagesPanel.tsx @@ -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(null); + const [resetConfirmId, setResetConfirmId] = useState(null); const [seoSettingsPageId, setSeoSettingsPageId] = useState(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 ``, 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 = () => { + ) : resetConfirmId === page.id ? ( + /* Reset-to-blank confirmation -- mirrors the delete confirmation + * above, since both are destructive per-page actions. */ +
+
+ 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. +
+
+ + +
+
) : ( /* Normal page item */
{ >