da558fd52d
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>
163 lines
6.0 KiB
TypeScript
163 lines
6.0 KiB
TypeScript
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();
|
|
});
|
|
});
|