feat(site-builder): page duplicate/reorder/set-landing + fix cross-page node copy/paste
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../editorHarness';
|
||||
import { useNodeActions, NodeActions } from '../../hooks/useNodeActions';
|
||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
|
||||
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
|
||||
import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
|
||||
|
||||
/**
|
||||
* Real-`@craftjs/core` integration coverage for duplicate/paste.
|
||||
@@ -67,10 +67,39 @@ const INITIAL_STATE = JSON.stringify({
|
||||
},
|
||||
});
|
||||
|
||||
// A second, independent "page" state -- distinct node ids from INITIAL_STATE,
|
||||
// simulating what PageContext.switchPage does: `query.serialize()` the
|
||||
// current page, then `actions.deserialize()` the target page's stored
|
||||
// state, replacing the ENTIRE node map. `social-1` (copied from page A)
|
||||
// does not exist anywhere in this state.
|
||||
const PAGE_B_STATE = JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' },
|
||||
isCanvas: true,
|
||||
props: { style: {}, tag: 'div' },
|
||||
displayName: 'Container',
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes: ['page-b-heading-1'],
|
||||
linkedNodes: {},
|
||||
},
|
||||
'page-b-heading-1': {
|
||||
type: { resolvedName: 'Heading' },
|
||||
isCanvas: false,
|
||||
props: { text: 'Page B Heading', level: 'h2' },
|
||||
displayName: 'Heading',
|
||||
custom: {},
|
||||
hidden: false,
|
||||
parent: 'ROOT',
|
||||
nodes: [],
|
||||
linkedNodes: {},
|
||||
},
|
||||
});
|
||||
|
||||
let harness: EditorHarness | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
setClipboardNodeId(null);
|
||||
setClipboardTree(null);
|
||||
if (harness) {
|
||||
harness.unmount();
|
||||
harness = null;
|
||||
@@ -158,7 +187,7 @@ describe('duplicate/paste (real @craftjs/core editor)', () => {
|
||||
new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
expect(getClipboardNodeId()).toBe('social-1');
|
||||
expect(getClipboardTree()!.rootNodeId).toBe('social-1');
|
||||
|
||||
// Select heading-1 (a sibling), then paste -- should land as a sibling
|
||||
// of heading-1's parent (ROOT), with brand-new ids.
|
||||
@@ -190,4 +219,76 @@ describe('duplicate/paste (real @craftjs/core editor)', () => {
|
||||
expect(pastedProps.links).not.toBe(originalProps.links);
|
||||
expect(pastedProps).toEqual(originalProps);
|
||||
});
|
||||
|
||||
test('CROSS-PAGE copy/paste: copy on page A, switch to page B, paste -- the node appears on page B', () => {
|
||||
harness = renderEditorHarness({ initialState: INITIAL_STATE });
|
||||
|
||||
const Consumer: React.FC = () => {
|
||||
useKeyboardShortcuts();
|
||||
return null;
|
||||
};
|
||||
harness.mountChild(<Consumer />);
|
||||
|
||||
// --- Page A: select + copy social-1. ---
|
||||
harness.act(() => {
|
||||
harness!.actions.selectNode('social-1');
|
||||
});
|
||||
harness.act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
expect(getClipboardTree()!.rootNodeId).toBe('social-1');
|
||||
|
||||
// --- Switch to page B: exactly what PageContext.switchPage does --
|
||||
// serialize (discarded here, a real page switch would stash it) then
|
||||
// deserialize the target page's state, replacing the ENTIRE node map.
|
||||
// `social-1` no longer exists anywhere in `query` after this. ---
|
||||
harness.act(() => {
|
||||
harness!.actions.deserialize(PAGE_B_STATE);
|
||||
});
|
||||
expect(harness.query.getNodes()['social-1']).toBeUndefined();
|
||||
|
||||
// Select page B's only node, then paste.
|
||||
harness.act(() => {
|
||||
harness!.actions.selectNode('page-b-heading-1');
|
||||
});
|
||||
|
||||
const beforePaste = Object.keys(harness.query.getNodes());
|
||||
expect(() => {
|
||||
harness!.act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'v', ctrlKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
const afterPaste = Object.keys(harness.query.getNodes());
|
||||
|
||||
// The regression this guards against: with the old id-based clipboard,
|
||||
// `query.node('social-1').get()` returns undefined once page B is
|
||||
// loaded, so the paste handler's guard silently no-ops -- NOTHING gets
|
||||
// added. With the tree-snapshot clipboard, the copied subtree is
|
||||
// detached from any live query and pastes onto page B regardless.
|
||||
expect(afterPaste.length).toBe(beforePaste.length + 1);
|
||||
|
||||
const pastedId = afterPaste.find((id) => !beforePaste.includes(id))!;
|
||||
expect(pastedId).toBeDefined();
|
||||
|
||||
const pastedNode = harness.query.node(pastedId).get();
|
||||
expect(pastedNode.data.displayName).toBe('Social Links');
|
||||
expect(pastedNode.data.props.links).toEqual([
|
||||
{ platform: 'facebook', url: 'https://facebook.com/original' },
|
||||
]);
|
||||
|
||||
// Actually landed on page B's tree, as a sibling of the selected node.
|
||||
const rootChildren: string[] = harness.query.node('ROOT').get().data.nodes;
|
||||
expect(rootChildren).toContain(pastedId);
|
||||
|
||||
// Real DOM assertion: the pasted SocialLinks component is actually
|
||||
// rendered on the (now page B) canvas.
|
||||
expect(
|
||||
harness.container.querySelectorAll('a[href="https://facebook.com/original"]'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user