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} + /> +
+ + +
+ + )} +
+ )} + ); };