fix(builder): render template header/footer nested content in zone preview + export
TemplateModal's addTemplateComponents() built each template component via
React.createElement(Component, comp.props) without ever passing
comp.children, silently dropping every nested children array authored in
templates/definitions.ts (header/footer Container > Logo/Menu/TextBlock,
page Section > Heading/TextBlock/ButtonLink). The resulting Craft.js node had
nodes: [], so the header/footer zone preview (ZonePreview -> exportBodyHtml)
rendered as an empty strip, and published output was affected the same way.
Fix converts each TemplateComponent to a SerializedTreeNode and reuses
craft-tree.ts's buildNodeTree (sanitize -> flatten -> materialize) -- the
same tested tree pipeline already used for AI-generated content -- instead
of hand-rolling a React-element tree, since a naive nested-children fix via
parseReactElement crashes any component with an internal SHELL_INNER linked
canvas (Section/BackgroundSection/FormContainer) or linked columns
(ColumnLayout). Also fixes two latent bugs in buildNodeTree itself, only
surfaced by exercising it against a real Craft.js editor for the first time:
data.type must be the actual resolved component reference (not a string or
{resolvedName} object) for correct rendering, and the synthesized SHELL_INNER
node needs data.name set for actions.addNodeTree's own validation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Editor, useEditor } from '@craftjs/core';
|
||||
import { componentResolver } from '../../components/resolver';
|
||||
import { allTemplates } from '../../templates';
|
||||
import { buildNodeTree } from '../../utils/craft-tree';
|
||||
import { templateComponentToTreeNode } from '../../templates/apply-template';
|
||||
import { exportBodyHtml } from '../../utils/html-export';
|
||||
import { DEFAULT_HEADER_STATE } from '../../state/PageContext';
|
||||
|
||||
/**
|
||||
* Regression test for: loading a template makes the header/footer ZONE
|
||||
* PREVIEW (`ZonePreview` in `editor/Canvas.tsx`, which calls
|
||||
* `exportBodyHtml(craftState)`) render as an empty strip -- no nav links, no
|
||||
* logo, no footer text -- and (a second bug found while fixing the first)
|
||||
* makes a template page's `Section`-wrapped content crash the live editor
|
||||
* entirely.
|
||||
*
|
||||
* This drives the EXACT same path `TemplateModal.tsx`'s `applyZone` /
|
||||
* `addTemplateComponents` uses to load a template's header/footer/page
|
||||
* content onto the live Craft.js canvas (`buildNodeTree` +
|
||||
* `actions.addNodeTree(tree, 'ROOT')`), then serializes it
|
||||
* (`query.serialize()`, exactly what `PageContext`'s `switchPage` /
|
||||
* `saveCurrentState` do right after `TemplateModal` finishes applying a
|
||||
* zone/page) and feeds the result through `exportBodyHtml` -- the same
|
||||
* function `ZonePreview` calls, and the same function `handlePublish` uses
|
||||
* to compose the published header/footer HTML. So this also covers whether
|
||||
* published output is affected (it was).
|
||||
*/
|
||||
let api: any;
|
||||
function Harness() {
|
||||
const { actions, query } = useEditor();
|
||||
api = { actions, query };
|
||||
return null;
|
||||
}
|
||||
|
||||
function mount() {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
let root!: Root;
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<Editor resolver={componentResolver}>
|
||||
<Harness />
|
||||
</Editor>,
|
||||
);
|
||||
});
|
||||
return {
|
||||
container,
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Mirrors TemplateModal.tsx's addTemplateComponents(). */
|
||||
function addTemplateComponents(components: any[]) {
|
||||
for (const comp of components) {
|
||||
const tree = buildNodeTree(api.query, templateComponentToTreeNode(comp));
|
||||
api.actions.addNodeTree(tree, 'ROOT');
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_HEADER_ROOT =
|
||||
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{},"tag":"header"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
||||
|
||||
const EMPTY_CANVAS_ROOT =
|
||||
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
||||
|
||||
describe('template header/footer zone export (loading a template)', () => {
|
||||
test('sanity check: the DEFAULT header (fresh project, no template) exports its content', () => {
|
||||
const { html } = exportBodyHtml(DEFAULT_HEADER_STATE);
|
||||
expect(html).toContain('MySite');
|
||||
});
|
||||
|
||||
test('a template header (SaaS Landing) exports its real nav/logo content -- not an empty strip', () => {
|
||||
const tpl = allTemplates.find((t) => t.id === 'saas-landing');
|
||||
expect(tpl).toBeDefined();
|
||||
expect(tpl!.header.components.length).toBeGreaterThan(0);
|
||||
|
||||
const { unmount } = mount();
|
||||
act(() => {
|
||||
api.actions.deserialize(EMPTY_HEADER_ROOT);
|
||||
});
|
||||
act(() => {
|
||||
addTemplateComponents(tpl!.header.components);
|
||||
});
|
||||
|
||||
const serialized = api.query.serialize();
|
||||
const { html } = exportBodyHtml(serialized);
|
||||
|
||||
// The template's logo text and a real nav link -- if these are missing,
|
||||
// the header rendered as an empty strip (the reported bug).
|
||||
expect(html).toContain('FlowStack');
|
||||
expect(html).toContain('Features');
|
||||
expect(html).toContain('Start Free');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('a template footer (SaaS Landing) exports its real copyright/tagline content -- not an empty strip', () => {
|
||||
const tpl = allTemplates.find((t) => t.id === 'saas-landing');
|
||||
expect(tpl).toBeDefined();
|
||||
expect(tpl!.footer.components.length).toBeGreaterThan(0);
|
||||
|
||||
const { unmount } = mount();
|
||||
act(() => {
|
||||
api.actions.deserialize(
|
||||
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{},"tag":"footer"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}',
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
addTemplateComponents(tpl!.footer.components);
|
||||
});
|
||||
|
||||
const serialized = api.query.serialize();
|
||||
const { html } = exportBodyHtml(serialized);
|
||||
|
||||
expect(html).toContain('FlowStack, Inc.');
|
||||
expect(html).toContain('Ship products faster');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('a template page Section with nested children (SaaS Landing Home) builds + exports without crashing', () => {
|
||||
// Regression for the SECOND bug surfaced while fixing the first: naively
|
||||
// rebuilding nested children via React.createElement + parseReactElement
|
||||
// crashes any component with an internal SHELL_INNER linked canvas
|
||||
// (Section/BackgroundSection/FormContainer) or linked columns
|
||||
// (ColumnLayout). The SaaS Landing home page's second component is a
|
||||
// `Section` wrapping a `Heading` ("Trusted by 10,000+ development
|
||||
// teams...") -- exactly this shape.
|
||||
const tpl = allTemplates.find((t) => t.id === 'saas-landing');
|
||||
const homePage = tpl!.pages[0];
|
||||
const sectionComp = homePage.content.components.find((c) => c.type === 'Section');
|
||||
expect(sectionComp).toBeDefined();
|
||||
expect(sectionComp!.children?.length).toBeGreaterThan(0);
|
||||
|
||||
const { unmount } = mount();
|
||||
act(() => {
|
||||
api.actions.deserialize(EMPTY_CANVAS_ROOT);
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
addTemplateComponents([sectionComp!]);
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
const serialized = api.query.serialize();
|
||||
const { html } = exportBodyHtml(serialized);
|
||||
expect(html).toContain('Trusted by 10,000+ development teams');
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user