From 1a01834068666ead5db6053e225c64db993df727 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 9 Aug 2026 10:17:53 -0700 Subject: [PATCH] feat(site-builder): add domain-confirmed Reset Entire Site Adds a danger-zone escape hatch below SiteDesignPanel's existing "Reset to Defaults": blanks every page down to one empty Home, blanks the header and footer, and resets all design tokens. Guarded by typing the exact site domain to arm the confirm button. The brief's setHeader('')/setFooter('') calls were wrong -- both take a SerializedTreeNode (same tree shape as replaceAllPages), not a craft-state string; passing '' would have hit treeToCraftState's sanitizeAiTree and silently fallen back to a generic div-shaped empty canvas instead of a proper header/footer. Built BLANK_HEADER_TREE/BLANK_FOOTER_TREE (tagged header/footer) alongside the brief's page tree, and dropped the brief's literal's extraneous flat-state fields (isCanvas/displayName/custom/hidden/ linkedNodes) that made it need an `as any` cast -- sanitizeAiTree/ flattenTreeForCraft only ever read type/props/nodes. In standalone mode (no WHP_CONFIG) siteDomain is '', so the entire danger zone -- not just the button -- is hidden behind `siteDomain &&`, closing off the empty-string-trivially-matches guard bypass. Dialog copy states the reset is undoable... is NOT undoable, and that auto-save (confirmed exactly 30s via TopBar.tsx's setInterval) turns the blank canvas into the saved draft shortly after, so no false safety-net claim is made. Verified load-bearing: reverted the implementation via git stash and confirmed 3 of 4 new tests fail (the 4th, an absence-only standalone-mode check, passed vacuously on first draft -- rewritten into a same-test contrast against the non-standalone case, which does fail on revert). Split into a second mock-free integration test file (SiteDesignPanel.reset.integration.test.tsx) after vi.mock's file-scoped hoisting made a same-file vi.doUnmock silently keep using the mocks -- it exercises the real PageProvider/SiteDesignProvider/treeToCraftState pipeline end to end via the real editorHarness. Co-Authored-By: Claude Opus 5 (1M context) --- ...SiteDesignPanel.reset.integration.test.tsx | 97 ++++++++++++ .../right/SiteDesignPanel.reset.test.tsx | 138 ++++++++++++++++++ craft/src/panels/right/SiteDesignPanel.tsx | 133 +++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 craft/src/panels/right/SiteDesignPanel.reset.integration.test.tsx create mode 100644 craft/src/panels/right/SiteDesignPanel.reset.test.tsx diff --git a/craft/src/panels/right/SiteDesignPanel.reset.integration.test.tsx b/craft/src/panels/right/SiteDesignPanel.reset.integration.test.tsx new file mode 100644 index 0000000..ce31c00 --- /dev/null +++ b/craft/src/panels/right/SiteDesignPanel.reset.integration.test.tsx @@ -0,0 +1,97 @@ +import { describe, test, expect } from 'vitest'; +import React from 'react'; +import { renderEditorHarness } from '../../test-utils/editorHarness'; +import { EditorConfigProvider } from '../../state/EditorConfigContext'; +import { PageProvider, usePages } from '../../state/PageContext'; +import { SiteDesignProvider, useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext'; +import { SiteDesignPanel } from './SiteDesignPanel'; + +/** React tracks a controlled ``'s value via a wrapped native setter -- + * a plain `input.value = x` assignment doesn't go through it, so React never + * sees the change and skips onChange. */ +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!; + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); +} + +/** + * Real-provider integration test -- NO mocks. Exercises the actual + * `replaceAllPages`/`setHeader`/`setFooter`/`resetToDefaults` implementations, + * which route the component's blank trees through the real + * `treeToCraftState` -> `sanitizeAiTree`/`flattenTreeForCraft` pipeline (see + * `state/PageContext.tsx` and `utils/craft-tree.ts`). This is the check the + * task brief calls out specifically: a tree shape that fails sanitisation + * would silently fall back to an empty canvas (masking a bug) rather than + * throwing, so asserting only on mock call arguments (as + * `SiteDesignPanel.reset.test.tsx` does) would not catch a bad tree shape -- + * the mocked assertions there only prove the component PASSED a + * `{type:{resolvedName:'Container'}, nodes:[]}`-shaped object; they can't + * prove that object is actually valid input to the real pipeline. + */ +describe('Reset Entire Site -- real PageContext/SiteDesignContext integration', () => { + test('confirming produces a real, valid single blank Home page + header + footer + default design', () => { + let pageCtx: ReturnType | null = null; + let designCtx: ReturnType | null = null; + const Probe: React.FC = () => { + pageCtx = usePages(); + designCtx = useSiteDesign(); + return null; + }; + + const harness = renderEditorHarness(); + harness.mountChild( + + + + + + + + , + ); + + // Dirty the design tokens first so resetToDefaults has something to undo. + harness.act(() => { designCtx!.updateDesign({ primaryColor: '#000000' }); }); + expect(designCtx!.design.primaryColor).toBe('#000000'); + + harness.act(() => { + (harness.container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); + }); + const input = harness.container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement; + harness.act(() => { setInputValue(input, 'example.com'); }); + harness.act(() => { + (harness.container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).click(); + }); + + // Exactly one page, "Home", landing-page slug -- and its stored + // craftState is real, parseable Craft.js state with a Container ROOT + // and no children (i.e. sanitizeAiTree did NOT reject the tree and fall + // back to a silent empty canvas for the wrong reason -- it's genuinely + // ROOT with zero nodes because that's what we asked for). + expect(pageCtx!.pages).toHaveLength(1); + expect(pageCtx!.pages[0].name).toBe('Home'); + expect(pageCtx!.pages[0].slug).toBe('index'); + const homeState = JSON.parse(pageCtx!.pages[0].craftState!); + expect(homeState.ROOT.type.resolvedName).toBe('Container'); + expect(homeState.ROOT.nodes).toEqual([]); + + const headerState = JSON.parse(pageCtx!.headerPage.craftState!); + expect(headerState.ROOT.type.resolvedName).toBe('Container'); + expect(headerState.ROOT.nodes).toEqual([]); + expect(headerState.ROOT.props.tag).toBe('header'); + + const footerState = JSON.parse(pageCtx!.footerPage.craftState!); + expect(footerState.ROOT.type.resolvedName).toBe('Container'); + expect(footerState.ROOT.nodes).toEqual([]); + expect(footerState.ROOT.props.tag).toBe('footer'); + + // Design tokens are back to the defaults. + expect(designCtx!.design).toEqual(DEFAULT_SITE_DESIGN); + + harness.unmount(); + }); +}); diff --git a/craft/src/panels/right/SiteDesignPanel.reset.test.tsx b/craft/src/panels/right/SiteDesignPanel.reset.test.tsx new file mode 100644 index 0000000..1e7aec5 --- /dev/null +++ b/craft/src/panels/right/SiteDesignPanel.reset.test.tsx @@ -0,0 +1,138 @@ +import { describe, test, expect, vi } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +/* ------------------------------------------------------------------------- + * Mocked-hook tests -- pure UI-logic coverage: the domain-match guard, that + * confirming calls the right context functions with tree-shaped arguments, + * and that standalone mode (empty siteDomain) hides the entry point + * entirely. The real `replaceAllPages`/`setHeader`/`setFooter`/ + * `resetToDefaults` implementations (and whether the blank trees this + * component builds actually survive `treeToCraftState`/`sanitizeAiTree`) are + * covered separately, WITHOUT mocks, in + * `SiteDesignPanel.reset.integration.test.tsx` -- `vi.mock` is hoisted and + * file-scoped, so it can't be selectively "undone" partway through one file + * for a real-provider test. + * ---------------------------------------------------------------------- */ + +const replaceAllPages = vi.fn(); +const setHeader = vi.fn(); +const setFooter = vi.fn(); +const resetToDefaults = vi.fn(); +let mockSiteDomain = 'example.com'; + +vi.mock('../../state/PageContext', () => ({ + usePages: () => ({ replaceAllPages, setHeader, setFooter, pages: [], siteDesign: {} }), +})); +vi.mock('../../state/SiteDesignContext', () => ({ + useSiteDesign: () => ({ design: {}, updateDesign: vi.fn(), resetToDefaults }), + DEFAULT_SITE_DESIGN: {}, +})); +vi.mock('../../state/EditorConfigContext', () => ({ + useEditorConfig: () => ({ whpConfig: mockSiteDomain ? { siteDomain: mockSiteDomain } : null, isWHP: !!mockSiteDomain }), +})); + +import { SiteDesignPanel } from './SiteDesignPanel'; + +let container: HTMLDivElement; +let root: Root; + +function render() { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render(); + }); +} + +function unmount() { + act(() => { root.unmount(); }); + container.remove(); +} + +/** React tracks a controlled ``'s value via a wrapped native setter -- + * a plain `input.value = x` assignment doesn't go through it, so React never + * sees the change and skips onChange. Using the real native setter (same + * pattern as `MediaStylePanel.slides.test.tsx`) makes the subsequent + * `input` event register as a genuine value change. */ +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!; + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); +} + +describe('Reset Entire Site -- guard + wiring (mocked hooks)', () => { + test('the confirm button is disabled until the domain is typed exactly', () => { + mockSiteDomain = 'example.com'; + render(); + act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); }); + + const confirm = container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement; + expect(confirm.disabled).toBe(true); + + const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement; + act(() => { setInputValue(input, 'example.co'); }); + expect((container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).disabled).toBe(true); + + act(() => { setInputValue(input, 'example.com'); }); + expect((container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).disabled).toBe(false); + + unmount(); + }); + + test('confirming blanks pages, header, footer and design tokens', () => { + mockSiteDomain = 'example.com'; + replaceAllPages.mockClear(); + setHeader.mockClear(); + setFooter.mockClear(); + resetToDefaults.mockClear(); + + render(); + act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); }); + const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement; + act(() => { setInputValue(input, 'example.com'); }); + act(() => { (container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).click(); }); + + expect(replaceAllPages).toHaveBeenCalledTimes(1); + const pagesArg = replaceAllPages.mock.calls[0][0]; + expect(pagesArg).toHaveLength(1); + expect(pagesArg[0].name).toBe('Home'); + // The replacement is a SerializedTreeNode (recursive tree), not a flat + // craft-state entry: a real Container root with no children. + expect(pagesArg[0].tree.type.resolvedName).toBe('Container'); + expect(pagesArg[0].tree.nodes).toEqual([]); + + expect(setHeader).toHaveBeenCalledTimes(1); + expect(setHeader.mock.calls[0][0].type.resolvedName).toBe('Container'); + expect(setFooter).toHaveBeenCalledTimes(1); + expect(setFooter.mock.calls[0][0].type.resolvedName).toBe('Container'); + expect(resetToDefaults).toHaveBeenCalledTimes(1); + + // The panel closes its own dialog back up after confirming. + expect(container.querySelector('[data-action="confirm-site-reset"]')).toBeNull(); + expect(container.querySelector('[data-action="open-site-reset"]')).toBeTruthy(); + + unmount(); + }); + + test('standalone mode (no WHP_CONFIG, siteDomain "") hides the entry point entirely, ' + + 'while non-standalone mode shows it -- an empty typed input must never trivially ' + + 'satisfy the guard. Asserted as a contrast within one test (not "absence" alone) ' + + 'so this fails if the whole feature -- not just the guard -- were ever removed.', () => { + mockSiteDomain = ''; + render(); + expect(container.querySelector('[data-action="open-site-reset"]')).toBeNull(); + expect(container.querySelector('[data-testid="site-reset-domain"]')).toBeNull(); + expect(container.textContent).not.toContain('Danger zone'); + expect(container.textContent).not.toContain('Reset Entire Site'); + unmount(); + + mockSiteDomain = 'example.com'; + render(); + expect(container.querySelector('[data-action="open-site-reset"]')).toBeTruthy(); + expect(container.textContent).toContain('Danger zone'); + unmount(); + }); +}); diff --git a/craft/src/panels/right/SiteDesignPanel.tsx b/craft/src/panels/right/SiteDesignPanel.tsx index 158c247..922cf28 100644 --- a/craft/src/panels/right/SiteDesignPanel.tsx +++ b/craft/src/panels/right/SiteDesignPanel.tsx @@ -1,7 +1,11 @@ import React, { useState } from 'react'; import { useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext'; +import { usePages } from '../../state/PageContext'; +import { useEditorConfig } from '../../state/EditorConfigContext'; +import { SerializedTreeNode } from '../../types/sitesmith'; import { FONT_FAMILIES } from '../../constants/presets'; import { AssetPicker } from '../../ui/AssetPicker'; +import { inputStyle } from './styles/shared'; type DesignTab = 'basic' | 'advanced'; @@ -156,12 +160,63 @@ const NavStyleField: React.FC = ({ value, onChange }) => ( ); +/* ---------- Reset Entire Site (danger zone) ---------- */ + +// Blank replacement trees for the "Reset Entire Site" escape hatch. Shaped +// as a `SerializedTreeNode` (type + props + nodes), NOT a flat Craft.js +// state entry -- this is what `replaceAllPages`/`setHeader`/`setFooter` +// feed into `treeToCraftState` -> `sanitizeAiTree`/`flattenTreeForCraft` +// (see `state/PageContext.tsx`/`utils/craft-tree.ts`). A single `Container` +// root with no children mirrors the (unexported) EMPTY_CANVAS/EMPTY_HEADER/ +// EMPTY_FOOTER constants already used elsewhere for "blank". +const BLANK_PAGE_TREE: SerializedTreeNode = { + type: { resolvedName: 'Container' }, + props: { style: { minHeight: '100vh', backgroundColor: '#ffffff' }, tag: 'div' }, + nodes: [], +}; + +const BLANK_HEADER_TREE: SerializedTreeNode = { + type: { resolvedName: 'Container' }, + props: { + style: { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' }, + tag: 'header', + }, + nodes: [], +}; + +const BLANK_FOOTER_TREE: SerializedTreeNode = { + type: { resolvedName: 'Container' }, + props: { + style: { minHeight: '60px', backgroundColor: '#0f172a', color: '#94a3b8', padding: '40px 24px', textAlign: 'center' }, + tag: 'footer', + }, + nodes: [], +}; + /* ---------- Main SiteDesignPanel ---------- */ export const SiteDesignPanel: React.FC = () => { const { design, updateDesign, resetToDefaults } = useSiteDesign(); + const { replaceAllPages, setHeader, setFooter } = usePages(); + const { whpConfig } = useEditorConfig(); + // Standalone mode (no WHP_CONFIG) has no site domain to confirm against -- + // an empty typed input would then trivially "match" an empty siteDomain, + // arming the destructive confirm button with no guard at all. The entry + // point itself is hidden in that case (see the `siteDomain &&` guard below). + const siteDomain = whpConfig?.siteDomain ?? ''; + const [siteResetOpen, setSiteResetOpen] = useState(false); + const [siteResetTyped, setSiteResetTyped] = useState(''); const [tab, setTab] = useState('basic'); + const handleResetSite = (): void => { + replaceAllPages([{ name: 'Home', tree: BLANK_PAGE_TREE }]); + setHeader(BLANK_HEADER_TREE); + setFooter(BLANK_FOOTER_TREE); + resetToDefaults(); + setSiteResetOpen(false); + setSiteResetTyped(''); + }; + return (
{/* Header */} @@ -371,6 +426,84 @@ export const SiteDesignPanel: React.FC = () => {

+ {/* Reset Entire Site (danger zone) -- hidden entirely in standalone + mode, where siteDomain is '' and typing nothing would trivially + satisfy an empty-string match. */} + {siteDomain && ( +
+
+ Danger zone +
+ + {!siteResetOpen ? ( + + ) : ( + <> +

+ This blanks every page, the header, the footer and all + design tokens, leaving one empty Home page. Uploaded images are kept. + This cannot be undone. Your published site stays as it + is until you publish again — but the editor auto-saves roughly every 30 + seconds, so the blank version becomes your saved draft shortly after. +

+

+ Type {siteDomain} to confirm: +

+ setSiteResetTyped(e.target.value)} + placeholder={siteDomain} + style={inputStyle} + /> +
+ + +
+ + )} +
+ )} + ); };