From 4b36ce0d6aa2eab4870bb14020a5aa3b1c8b9af2 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 12:28:38 -0700 Subject: [PATCH] fix(builder): route live header/footer edits correctly on save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing the Header/Footer sets activePageId to '__header__'/'__footer__', which matches no entry in `pages`. save() was serializing the live canvas into the top-level page slots (mislabeled as page content, matching no page) while exporting header/footer from stale stored state — auto-save every 30s silently dropped header/footer edits. Extract buildSavePayload() as a pure, unit-tested helper: header/footer craft state now comes from the live canvas when that zone is being edited (else stored state), and the top-level page fields fall back to the landing page's stored state when a header/footer zone is active, so page content is never clobbered or mislabeled. Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/hooks/useWhpApi.save.test.ts | 90 +++++++++ craft/src/hooks/useWhpApi.ts | 263 +++++++++++++++++-------- 2 files changed, 269 insertions(+), 84 deletions(-) create mode 100644 craft/src/hooks/useWhpApi.save.test.ts diff --git a/craft/src/hooks/useWhpApi.save.test.ts b/craft/src/hooks/useWhpApi.save.test.ts new file mode 100644 index 0000000..d8447af --- /dev/null +++ b/craft/src/hooks/useWhpApi.save.test.ts @@ -0,0 +1,90 @@ +import { describe, test, expect } from 'vitest'; +import { buildSavePayload } from './useWhpApi'; +import { PageData } from '../types'; + +const pageA: PageData = { id: 'home', name: 'Home', slug: 'index', craftState: 'STORED_HOME', headCode: '' }; +const pageB: PageData = { id: 'page_2', name: 'About', slug: 'about', craftState: 'STORED_ABOUT', headCode: '' }; +const headerPage: PageData = { id: '__header__', name: 'Header', slug: '__header__', craftState: 'STALE_HEADER', headCode: '' }; +const footerPage: PageData = { id: '__footer__', name: 'Footer', slug: '__footer__', craftState: 'STALE_FOOTER', headCode: '' }; + +describe('buildSavePayload', () => { + test('editing header: live serialize lands in header_craft_state, not in any page slot', () => { + const payload = buildSavePayload({ + siteId: 1, + siteName: 'Test Site', + liveCraftState: 'LIVE_HEADER', + pages: [pageA, pageB], + headerPage, + footerPage, + activePageId: '__header__', + isEditingHeader: true, + isEditingFooter: false, + }); + + // The fresh live canvas must be reflected in header_craft_state, not the stale stored one. + expect(payload.header_craft_state).toBe('LIVE_HEADER'); + expect(payload.header_craft_state).not.toBe('STALE_HEADER'); + + // Footer must remain untouched (stored state, since we're not editing it). + expect(payload.footer_craft_state).toBe('STALE_FOOTER'); + + // Live header content must never leak into a page slot or the top-level page fields. + expect(payload.craft_state).not.toBe('LIVE_HEADER'); + for (const p of payload.pages_craft_state) { + expect(p.craftState).not.toBe('LIVE_HEADER'); + } + // Top-level page fields should fall back to the landing page's own stored state. + expect(payload.craft_state).toBe('STORED_HOME'); + + // Per-page slots must reflect each page's own stored state, untouched. + expect(payload.pages_craft_state.find((p) => p.id === 'home')?.craftState).toBe('STORED_HOME'); + expect(payload.pages_craft_state.find((p) => p.id === 'page_2')?.craftState).toBe('STORED_ABOUT'); + }); + + test('editing footer: live serialize lands in footer_craft_state, not in any page slot', () => { + const payload = buildSavePayload({ + siteId: 1, + siteName: 'Test Site', + liveCraftState: 'LIVE_FOOTER', + pages: [pageA, pageB], + headerPage, + footerPage, + activePageId: '__footer__', + isEditingHeader: false, + isEditingFooter: true, + }); + + expect(payload.footer_craft_state).toBe('LIVE_FOOTER'); + expect(payload.footer_craft_state).not.toBe('STALE_FOOTER'); + expect(payload.header_craft_state).toBe('STALE_HEADER'); + + expect(payload.craft_state).not.toBe('LIVE_FOOTER'); + for (const p of payload.pages_craft_state) { + expect(p.craftState).not.toBe('LIVE_FOOTER'); + } + }); + + test('editing a real page: behavior unchanged — live serialize goes to that page + top-level, header/footer come from stored state', () => { + const payload = buildSavePayload({ + siteId: 1, + siteName: 'Test Site', + liveCraftState: 'LIVE_PAGE_HOME', + pages: [pageA, pageB], + headerPage, + footerPage, + activePageId: 'home', + isEditingHeader: false, + isEditingFooter: false, + }); + + // Live canvas goes to the active page and top-level slots. + expect(payload.craft_state).toBe('LIVE_PAGE_HOME'); + expect(payload.pages_craft_state.find((p) => p.id === 'home')?.craftState).toBe('LIVE_PAGE_HOME'); + // Other pages keep their stored state. + expect(payload.pages_craft_state.find((p) => p.id === 'page_2')?.craftState).toBe('STORED_ABOUT'); + + // Header/footer come from stored (stale-but-correct, since we're not editing them) state. + expect(payload.header_craft_state).toBe('STALE_HEADER'); + expect(payload.footer_craft_state).toBe('STALE_FOOTER'); + }); +}); diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts index 6492721..c76bf45 100644 --- a/craft/src/hooks/useWhpApi.ts +++ b/craft/src/hooks/useWhpApi.ts @@ -3,21 +3,87 @@ import { useEditor } from '@craftjs/core'; import { useEditorConfig } from '../state/EditorConfigContext'; import { usePages } from '../state/PageContext'; import { exportBodyHtml } from '../utils/html-export'; +import { PageData } from '../types'; -export function useWhpApi() { - const { query, actions } = useEditor(); - const { whpConfig, isWHP } = useEditorConfig(); - const { pages, headerPage, footerPage, activePageId, setHeaderCraftState, setFooterCraftState, setPagesCraftState } = usePages(); +export interface BuildSavePayloadInput { + siteId: number; + siteName: string; + /** query.serialize() of whatever is currently on the live canvas. */ + liveCraftState: string; + pages: PageData[]; + headerPage: PageData; + footerPage: PageData; + activePageId: string; + /** True when the live canvas is showing the header zone (activePageId === '__header__'). */ + isEditingHeader: boolean; + /** True when the live canvas is showing the footer zone (activePageId === '__footer__'). */ + isEditingFooter: boolean; +} - const save = useCallback(async () => { - if (!isWHP || !whpConfig) return null; +/** + * Pure payload builder for the save() API call. Extracted so the + * header/footer-vs-page routing logic can be unit-tested without mounting + * React/Craft.js. + * + * Bug this fixes: when the user is editing the Header or Footer, + * `activePageId` is `'__header__'`/`'__footer__'` — which matches no entry + * in `pages`. The live canvas serialization must be routed into + * `header_craft_state`/`footer_craft_state` in that case, NOT into a page + * slot or the top-level `craft_state`/`html` (which must always represent + * an actual page). Conversely, header/footer must be sourced from the FRESH + * live state when that zone is being edited, not from the stale stored + * `headerPage.craftState`/`footerPage.craftState`. + */ +export function buildSavePayload(input: BuildSavePayloadInput) { + const { + siteId, + siteName, + liveCraftState, + pages, + headerPage, + footerPage, + activePageId, + isEditingHeader, + isEditingFooter, + } = input; - // Serialize the current canvas state (whatever page is active) - const currentCraftState = query.serialize(); + const isPageActive = !isEditingHeader && !isEditingFooter; - // Export body HTML for the current page - let currentHtml = ''; - let css = ''; + // Fresh header/footer state: the live canvas wins when that zone is the + // one currently being edited; otherwise fall back to the last-committed + // stored state (updated on zone switch by PageContext's saveCurrentState). + const headerCraftState = isEditingHeader ? liveCraftState : (headerPage.craftState || null); + const footerCraftState = isEditingFooter ? liveCraftState : (footerPage.craftState || null); + + let headerHtml = ''; + try { + if (headerCraftState) { + headerHtml = exportBodyHtml(headerCraftState).html; + } + } catch (e) { + console.error('Header HTML export failed:', e); + } + + let footerHtml = ''; + try { + if (footerCraftState) { + footerHtml = exportBodyHtml(footerCraftState).html; + } + } catch (e) { + console.error('Footer HTML export failed:', e); + } + + // The top-level `craft_state`/`html` fields (and the matching per-page + // slot below) must always represent an actual PAGE. When editing the + // header/footer, activePageId matches no page — use the landing page's + // own stored state instead of leaking the live header/footer canvas into + // a page slot or mislabeling it as page content. + let currentCraftState: string | null; + let currentHtml = ''; + let css = ''; + + if (isPageActive) { + currentCraftState = liveCraftState; try { const result = exportBodyHtml(currentCraftState); currentHtml = result.html; @@ -25,84 +91,113 @@ export function useWhpApi() { } catch (e) { console.error('HTML export failed, saving state only:', e); } - - // Export header HTML from its craft state - let headerHtml = ''; + } else { + const landingPage = pages[0] ?? null; + currentCraftState = landingPage?.craftState ?? null; try { - if (headerPage.craftState) { - const hResult = exportBodyHtml(headerPage.craftState); - headerHtml = hResult.html; + if (currentCraftState) { + const result = exportBodyHtml(currentCraftState); + currentHtml = result.html; + css = result.css; } } catch (e) { - console.error('Header HTML export failed:', e); + console.error('HTML export failed, saving state only:', e); + } + } + + // Build the pages array with HTML for each page. For the active page (only + // possible when a real page is active), use the freshly exported HTML from + // the canvas; for others, export from their stored craft state. + const pagesPayload = pages.map((page, i) => { + // The first page is ALWAYS the landing page → publishes to index.html + // regardless of the page name/slug. Apache serves '/' from index.html, + // and renaming the first page should not break the root URL. + const filename = i === 0 ? 'index.html' : page.slug + '.html'; + let pageHtml = ''; + + if (isPageActive && page.id === activePageId) { + // Active page: use the current canvas HTML (already exported above) + pageHtml = currentHtml; + } else if (page.craftState) { + try { + pageHtml = exportBodyHtml(page.craftState).html; + } catch (e) { + console.error(`HTML export failed for page ${page.name}:`, e); + } } - // Export footer HTML from its craft state - let footerHtml = ''; - try { - if (footerPage.craftState) { - const fResult = exportBodyHtml(footerPage.craftState); - footerHtml = fResult.html; - } - } catch (e) { - console.error('Footer HTML export failed:', e); - } - - // Build the pages array with HTML for each page - // For the active page, use the freshly exported HTML from the canvas; - // for others, export from their stored craft state - const pagesPayload = pages.map((page, i) => { - // The first page is ALWAYS the landing page → publishes to index.html - // regardless of the page name/slug. Apache serves '/' from index.html, - // and renaming the first page should not break the root URL. - const filename = i === 0 ? 'index.html' : page.slug + '.html'; - let pageHtml = ''; - - if (page.id === activePageId) { - // Active page: use the current canvas HTML (already exported above) - pageHtml = currentHtml; - } else if (page.craftState) { - try { - const pResult = exportBodyHtml(page.craftState); - pageHtml = pResult.html; - } catch (e) { - console.error(`HTML export failed for page ${page.name}:`, e); - } - } - - return { - filename, - title: page.name, - html: pageHtml, - }; - }); - - // Build pages_craft_state array: for each page, store its craft state - // For the currently active page, always use the fresh canvas state (currentCraftState) - // since page.craftState may be stale (not updated until page switch) - const pagesGrapesjs = pages.map((page, i) => ({ - id: page.id, - name: page.name, - // Pin the landing page's slug to 'index' on the wire too, so that on - // reload the editor's clean-URL routing (.htaccess rewrite of /name → - // name.html) lines up with the file we just wrote (index.html). - slug: i === 0 ? 'index' : page.slug, - craftState: page.id === activePageId ? currentCraftState : (page.craftState || null), - })); - - const payload = { - site_id: whpConfig.siteId, - name: whpConfig.siteName, - html: currentHtml, - css, - pages: pagesPayload, - header_html: headerHtml, - footer_html: footerHtml, - craft_state: currentCraftState, - header_craft_state: headerPage.craftState || null, - footer_craft_state: footerPage.craftState || null, - pages_craft_state: pagesGrapesjs, + return { + filename, + title: page.name, + html: pageHtml, }; + }); + + // Build pages_craft_state array: for each page, store its craft state. + // For the currently active page (only when a real page is active), always + // use the fresh canvas state since page.craftState may be stale (not + // updated until page switch). When editing header/footer, activePageId + // matches no page, so every page correctly falls back to its own stored + // state below. + const pagesGrapesjs = pages.map((page, i) => ({ + id: page.id, + name: page.name, + // Pin the landing page's slug to 'index' on the wire too, so that on + // reload the editor's clean-URL routing (.htaccess rewrite of /name → + // name.html) lines up with the file we just wrote (index.html). + slug: i === 0 ? 'index' : page.slug, + craftState: (isPageActive && page.id === activePageId) ? liveCraftState : (page.craftState || null), + })); + + return { + site_id: siteId, + name: siteName, + html: currentHtml, + css, + pages: pagesPayload, + header_html: headerHtml, + footer_html: footerHtml, + craft_state: currentCraftState, + header_craft_state: headerCraftState, + footer_craft_state: footerCraftState, + pages_craft_state: pagesGrapesjs, + }; +} + +export function useWhpApi() { + const { query, actions } = useEditor(); + const { whpConfig, isWHP } = useEditorConfig(); + const { + pages, + headerPage, + footerPage, + activePageId, + isEditingHeader, + isEditingFooter, + setHeaderCraftState, + setFooterCraftState, + setPagesCraftState, + } = usePages(); + + const save = useCallback(async () => { + if (!isWHP || !whpConfig) return null; + + // Serialize whatever is currently on the live canvas (a page, the + // header, or the footer — depending on activePageId/isEditingHeader/ + // isEditingFooter). + const liveCraftState = query.serialize(); + + const payload = buildSavePayload({ + siteId: whpConfig.siteId, + siteName: whpConfig.siteName, + liveCraftState, + pages, + headerPage, + footerPage, + activePageId, + isEditingHeader, + isEditingFooter, + }); const resp = await fetch(`${whpConfig.apiUrl}?action=save`, { method: 'POST', @@ -113,7 +208,7 @@ export function useWhpApi() { body: JSON.stringify(payload), }); return resp.json(); - }, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage]); + }, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter]); const publish = useCallback(async () => { if (!isWHP || !whpConfig) return null;