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:
@@ -4,7 +4,7 @@ import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import type { NodeTree, Node } from '@craftjs/core';
|
||||
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
|
||||
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
||||
import { getClipboardTree, setClipboardTree } from './clipboard';
|
||||
|
||||
/**
|
||||
* Regression coverage: Ctrl/Cmd+V must run the copied subtree through
|
||||
@@ -15,6 +15,13 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
||||
* ROOT-fallback targeting, the empty-clipboard no-op, and the existing
|
||||
* input-focus guard.
|
||||
*
|
||||
* Also covers the cross-page clipboard fix: copy stores a detached TREE
|
||||
* SNAPSHOT (`setClipboardTree`), not a bare node id -- so paste never needs
|
||||
* to re-resolve the original node via `query.node(id)`, which is exactly
|
||||
* what breaks once the canvas has been re-deserialized to a different page
|
||||
* (see `hooks/clipboard.ts` and the cross-page integration test in
|
||||
* `test-utils/integration/duplicate-paste.integration.test.tsx`).
|
||||
*
|
||||
* Mock pattern mirrors PageContext.pure-updaters.test.tsx /
|
||||
* PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`,
|
||||
* mounted via a bare consumer component, with REAL `keydown` events
|
||||
@@ -27,6 +34,7 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
||||
*/
|
||||
|
||||
const addNodeTreeMock = vi.fn();
|
||||
const selectNodeMock = vi.fn();
|
||||
let selectedIds: string[] = [];
|
||||
|
||||
function makeNode(id: string, parent: string | null, children: string[] = []): Node {
|
||||
@@ -62,9 +70,10 @@ const COPIED_TREE: NodeTree = {
|
||||
},
|
||||
};
|
||||
|
||||
const nodeStore: Record<string, { data: { parent: string | null } }> = {
|
||||
const nodeStore: Record<string, { data: { parent: string | null; nodes?: string[] } }> = {
|
||||
'selected-1': { data: { parent: 'parent-container-1' } },
|
||||
ROOT: { data: { parent: null } },
|
||||
'parent-container-1': { data: { parent: null, nodes: ['other-sibling', 'selected-1'] } },
|
||||
ROOT: { data: { parent: null, nodes: [] } },
|
||||
'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } },
|
||||
};
|
||||
|
||||
@@ -88,6 +97,7 @@ vi.mock('@craftjs/core', () => ({
|
||||
},
|
||||
actions: {
|
||||
addNodeTree: addNodeTreeMock,
|
||||
selectNode: selectNodeMock,
|
||||
history: { undo: vi.fn(), redo: vi.fn() },
|
||||
delete: vi.fn(),
|
||||
clearEvents: vi.fn(),
|
||||
@@ -145,44 +155,51 @@ const Consumer: React.FC = () => {
|
||||
beforeEach(() => {
|
||||
selectedIds = [];
|
||||
addNodeTreeMock.mockClear();
|
||||
selectNodeMock.mockClear();
|
||||
regenerateTreeIdsMock.mockClear();
|
||||
setClipboardNodeId(null);
|
||||
setClipboardTree(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setClipboardNodeId(null);
|
||||
setClipboardTree(null);
|
||||
if (root) unmount();
|
||||
});
|
||||
|
||||
describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
||||
test('Ctrl+C copies the selected node id to the clipboard', () => {
|
||||
test('Ctrl+C copies the selected node`s subtree (a tree snapshot, not a bare id) to the clipboard', () => {
|
||||
render(<Consumer />);
|
||||
selectedIds = ['selected-1'];
|
||||
selectedIds = ['copied-root-1'];
|
||||
|
||||
pressKey('c');
|
||||
|
||||
expect(getClipboardNodeId()).toBe('selected-1');
|
||||
const clip = getClipboardTree();
|
||||
expect(clip).not.toBeNull();
|
||||
expect(clip!.rootNodeId).toBe('copied-root-1');
|
||||
expect(Object.keys(clip!.nodes).sort()).toEqual(['copied-child-1', 'copied-root-1']);
|
||||
});
|
||||
|
||||
test('Ctrl+V pastes as a sibling of the selection (selected node`s data.parent) with FRESH ids', () => {
|
||||
test('Ctrl+V pastes as a sibling of the selection, immediately after it, with FRESH ids', () => {
|
||||
render(<Consumer />);
|
||||
|
||||
selectedIds = ['copied-root-1'];
|
||||
pressKey('c');
|
||||
expect(getClipboardNodeId()).toBe('copied-root-1');
|
||||
expect(getClipboardTree()!.rootNodeId).toBe('copied-root-1');
|
||||
|
||||
selectedIds = ['selected-1'];
|
||||
pressKey('v');
|
||||
|
||||
// regenerateTreeIds actually ran before the tree was handed to Craft.js.
|
||||
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
|
||||
expect(regenerateTreeIdsMock).toHaveBeenCalledWith(COPIED_TREE);
|
||||
expect(regenerateTreeIdsMock).toHaveBeenCalledWith(getClipboardTree());
|
||||
|
||||
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||
const [pastedTree, targetParent] = addNodeTreeMock.mock.calls[0];
|
||||
const [pastedTree, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
|
||||
|
||||
// Sibling of the current selection: selected-1's data.parent.
|
||||
expect(targetParent).toBe('parent-container-1');
|
||||
// Immediately after selected-1 (index 1 among parent-container-1's
|
||||
// children), matching duplicate()'s "insert right after" UX.
|
||||
expect(insertIndex).toBe(2);
|
||||
|
||||
// The regression this guards: pasted ids must be fresh, never reuse the
|
||||
// ids the copied node already occupies in the live Craft.js tree.
|
||||
@@ -193,9 +210,12 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
||||
for (const id of pastedIds) {
|
||||
expect(originalIds.has(id)).toBe(false);
|
||||
}
|
||||
|
||||
// The new copy is selected, same as duplicate()'s existing UX.
|
||||
expect(selectNodeMock).toHaveBeenCalledWith(pastedTree.rootNodeId);
|
||||
});
|
||||
|
||||
test('Ctrl+V with selection at ROOT falls back to ROOT as the insertion parent', () => {
|
||||
test('Ctrl+V with selection at ROOT falls back to appending into ROOT', () => {
|
||||
render(<Consumer />);
|
||||
|
||||
selectedIds = ['copied-root-1'];
|
||||
@@ -205,8 +225,24 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
||||
pressKey('v');
|
||||
|
||||
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||
const [, targetParent] = addNodeTreeMock.mock.calls[0];
|
||||
const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
|
||||
expect(targetParent).toBe('ROOT');
|
||||
expect(insertIndex).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Ctrl+V with nothing selected falls back to appending into ROOT', () => {
|
||||
render(<Consumer />);
|
||||
|
||||
selectedIds = ['copied-root-1'];
|
||||
pressKey('c');
|
||||
|
||||
selectedIds = [];
|
||||
pressKey('v');
|
||||
|
||||
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||
const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
|
||||
expect(targetParent).toBe('ROOT');
|
||||
expect(insertIndex).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Ctrl+V with an empty clipboard is a no-op', () => {
|
||||
@@ -229,9 +265,9 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
||||
selectedIds = ['selected-1'];
|
||||
|
||||
pressKey('c');
|
||||
expect(getClipboardNodeId()).toBeNull();
|
||||
expect(getClipboardTree()).toBeNull();
|
||||
|
||||
setClipboardNodeId('copied-root-1');
|
||||
setClipboardTree(COPIED_TREE);
|
||||
pressKey('v');
|
||||
expect(addNodeTreeMock).not.toHaveBeenCalled();
|
||||
|
||||
@@ -252,7 +288,7 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
||||
selectedIds = ['selected-1'];
|
||||
|
||||
pressKey('c');
|
||||
expect(getClipboardNodeId()).toBeNull();
|
||||
expect(getClipboardTree()).toBeNull();
|
||||
|
||||
activeElementSpy.mockRestore();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user