fix(builder): route live header/footer edits correctly on save
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+179
-84
@@ -3,21 +3,87 @@ import { useEditor } from '@craftjs/core';
|
|||||||
import { useEditorConfig } from '../state/EditorConfigContext';
|
import { useEditorConfig } from '../state/EditorConfigContext';
|
||||||
import { usePages } from '../state/PageContext';
|
import { usePages } from '../state/PageContext';
|
||||||
import { exportBodyHtml } from '../utils/html-export';
|
import { exportBodyHtml } from '../utils/html-export';
|
||||||
|
import { PageData } from '../types';
|
||||||
|
|
||||||
export function useWhpApi() {
|
export interface BuildSavePayloadInput {
|
||||||
const { query, actions } = useEditor();
|
siteId: number;
|
||||||
const { whpConfig, isWHP } = useEditorConfig();
|
siteName: string;
|
||||||
const { pages, headerPage, footerPage, activePageId, setHeaderCraftState, setFooterCraftState, setPagesCraftState } = usePages();
|
/** 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 isPageActive = !isEditingHeader && !isEditingFooter;
|
||||||
const currentCraftState = query.serialize();
|
|
||||||
|
|
||||||
// Export body HTML for the current page
|
// Fresh header/footer state: the live canvas wins when that zone is the
|
||||||
let currentHtml = '';
|
// one currently being edited; otherwise fall back to the last-committed
|
||||||
let css = '';
|
// 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 {
|
try {
|
||||||
const result = exportBodyHtml(currentCraftState);
|
const result = exportBodyHtml(currentCraftState);
|
||||||
currentHtml = result.html;
|
currentHtml = result.html;
|
||||||
@@ -25,84 +91,113 @@ export function useWhpApi() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('HTML export failed, saving state only:', e);
|
console.error('HTML export failed, saving state only:', e);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// Export header HTML from its craft state
|
const landingPage = pages[0] ?? null;
|
||||||
let headerHtml = '';
|
currentCraftState = landingPage?.craftState ?? null;
|
||||||
try {
|
try {
|
||||||
if (headerPage.craftState) {
|
if (currentCraftState) {
|
||||||
const hResult = exportBodyHtml(headerPage.craftState);
|
const result = exportBodyHtml(currentCraftState);
|
||||||
headerHtml = hResult.html;
|
currentHtml = result.html;
|
||||||
|
css = result.css;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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
|
return {
|
||||||
let footerHtml = '';
|
filename,
|
||||||
try {
|
title: page.name,
|
||||||
if (footerPage.craftState) {
|
html: pageHtml,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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`, {
|
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -113,7 +208,7 @@ export function useWhpApi() {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
return resp.json();
|
return resp.json();
|
||||||
}, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage]);
|
}, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter]);
|
||||||
|
|
||||||
const publish = useCallback(async () => {
|
const publish = useCallback(async () => {
|
||||||
if (!isWHP || !whpConfig) return null;
|
if (!isWHP || !whpConfig) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user