2c8425ffb0
Phase B makes the Craft.js editor genuinely usable by touch on top of Phase A's responsive shell, gated entirely behind useIsMobile()/<=768px: - Extract useNodeActions(nodeId) out of ContextMenu.tsx (move/duplicate/ delete/select-parent), shared by the desktop right-click menu (behavior unchanged) and the new mobile MobileSelectionToolbar. - MobileSelectionToolbar: bottom-fixed selection toolbar (Move Up/Down, Duplicate, Select Parent, Edit Styles, two-tap Delete confirm), hidden while a sheet is open. - BlocksPanel: tap-to-add on mobile (insert after selection, close sheet, select + scroll the new node into view); desktop drag/double-click unchanged. - LayersPanel rows >=44px on mobile; HeadCodeModal portaled to document.body (same fix TemplateModal already had); BottomSheet gets swipe-to-dismiss and on-screen-keyboard clearance via a new useVisualViewportInsets hook. Also fixes two pre-existing bugs surfaced only by driving a real Craft.js document with Playwright touch input (masked by tests that mock @craftjs/core): regenerateTreeIds structuredClone'd a live node's whole data object, including the component function reference in data.type, throwing DataCloneError and silently breaking Duplicate/Paste for every node type; and an earlier useNodeActions draft cached canMoveUp/canMoveDown inside a useEditor collector closed over nodeId, which goes stale for one render whenever the selection changes without an unrelated store event. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
233 lines
7.1 KiB
TypeScript
233 lines
7.1 KiB
TypeScript
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import React from 'react';
|
|
import { createRoot, Root } from 'react-dom/client';
|
|
import { act } from 'react-dom/test-utils';
|
|
import type { NodeTree, Node } from '@craftjs/core';
|
|
import { useNodeActions, type NodeActions } from './useNodeActions';
|
|
|
|
/**
|
|
* Phase B, item 1: `useNodeActions` was extracted out of `ContextMenu.tsx` so
|
|
* the desktop right-click menu and the mobile selection toolbar
|
|
* (`MobileSelectionToolbar`) share one implementation. This suite exercises
|
|
* the hook directly (mocking `@craftjs/core`'s `useEditor`, mirroring the
|
|
* pattern in `useKeyboardShortcuts.test.tsx`), covering:
|
|
* - moveUp/moveDown call `actions.move` with the right target index, and
|
|
* are no-ops at the respective boundary
|
|
* - canMoveUp/canMoveDown reflect the node's position among its siblings
|
|
* - duplicate regenerates ids before calling `actions.addNodeTree`
|
|
* - selectParent calls `actions.selectNode` with the parent id
|
|
* - deleteNode routes through `findDeletableTarget` (also covering the
|
|
* "no deletable target" no-op)
|
|
* - ROOT/null nodeId disables every action safely (no-ops, all
|
|
* canMoveUp/canMoveDown/canDelete false)
|
|
*/
|
|
|
|
const moveMock = vi.fn();
|
|
const addNodeTreeMock = vi.fn();
|
|
const selectNodeMock = vi.fn();
|
|
const deleteMock = vi.fn();
|
|
|
|
interface FakeNodeData {
|
|
parent: string | null;
|
|
nodes: string[];
|
|
}
|
|
|
|
let fakeNodes: Record<string, { data: FakeNodeData }> = {};
|
|
|
|
const COPIED_TREE: NodeTree = {
|
|
rootNodeId: 'child-1',
|
|
nodes: {
|
|
'child-1': {
|
|
id: 'child-1',
|
|
data: { props: {}, type: { resolvedName: 'Container' }, name: 'Container', displayName: 'Container', isCanvas: false, parent: 'parent-1', linkedNodes: {}, nodes: [], hidden: false },
|
|
info: {},
|
|
events: { selected: false, dragged: false, hovered: false },
|
|
dom: null,
|
|
related: {},
|
|
rules: {},
|
|
_hydrationTimestamp: 0,
|
|
} as unknown as Node,
|
|
},
|
|
};
|
|
|
|
function makeQuery() {
|
|
return {
|
|
node: (id: string) => ({
|
|
get: () => fakeNodes[id] ?? null,
|
|
toNodeTree: () => {
|
|
if (id !== 'child-1') throw new Error(`unexpected toNodeTree() for "${id}"`);
|
|
return COPIED_TREE;
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
|
|
vi.mock('@craftjs/core', () => ({
|
|
useEditor: (collector?: (state: any, query: any) => any) => {
|
|
const state = {
|
|
nodes: Object.fromEntries(Object.entries(fakeNodes).map(([id, n]) => [id, n])),
|
|
};
|
|
const query = makeQuery();
|
|
const collected = collector ? collector(state, query) : {};
|
|
return {
|
|
...collected,
|
|
actions: {
|
|
move: moveMock,
|
|
addNodeTree: addNodeTreeMock,
|
|
selectNode: selectNodeMock,
|
|
delete: deleteMock,
|
|
},
|
|
query,
|
|
};
|
|
},
|
|
}));
|
|
|
|
vi.mock('../utils/craft-tree', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../utils/craft-tree')>();
|
|
return { ...actual, regenerateTreeIds: vi.fn(actual.regenerateTreeIds) };
|
|
});
|
|
|
|
import { regenerateTreeIds } from '../utils/craft-tree';
|
|
const regenerateTreeIdsMock = vi.mocked(regenerateTreeIds);
|
|
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
let captured: NodeActions | null = null;
|
|
|
|
const Probe: React.FC<{ nodeId: string | null }> = ({ nodeId }) => {
|
|
captured = useNodeActions(nodeId);
|
|
return null;
|
|
};
|
|
|
|
function render(nodeId: string | null) {
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
act(() => {
|
|
root = createRoot(container);
|
|
root.render(<Probe nodeId={nodeId} />);
|
|
});
|
|
}
|
|
|
|
function rerender(nodeId: string | null) {
|
|
act(() => {
|
|
root.render(<Probe nodeId={nodeId} />);
|
|
});
|
|
}
|
|
|
|
function unmount() {
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
moveMock.mockClear();
|
|
addNodeTreeMock.mockClear();
|
|
selectNodeMock.mockClear();
|
|
deleteMock.mockClear();
|
|
regenerateTreeIdsMock.mockClear();
|
|
captured = null;
|
|
fakeNodes = {
|
|
ROOT: { data: { parent: null, nodes: ['parent-1'] } },
|
|
'parent-1': { data: { parent: 'ROOT', nodes: ['child-0', 'child-1', 'child-2'] } },
|
|
'child-0': { data: { parent: 'parent-1', nodes: [] } },
|
|
'child-1': { data: { parent: 'parent-1', nodes: [] } },
|
|
'child-2': { data: { parent: 'parent-1', nodes: [] } },
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (root) unmount();
|
|
});
|
|
|
|
describe('useNodeActions', () => {
|
|
test('a middle child can move both up and down', () => {
|
|
render('child-1');
|
|
expect(captured!.canMoveUp).toBe(true);
|
|
expect(captured!.canMoveDown).toBe(true);
|
|
});
|
|
|
|
test('the first child cannot move up, but can move down', () => {
|
|
render('child-0');
|
|
expect(captured!.canMoveUp).toBe(false);
|
|
expect(captured!.canMoveDown).toBe(true);
|
|
});
|
|
|
|
test('the last child can move up, but not down', () => {
|
|
render('child-2');
|
|
expect(captured!.canMoveUp).toBe(true);
|
|
expect(captured!.canMoveDown).toBe(false);
|
|
});
|
|
|
|
test('moveUp calls actions.move with idx - 1, moveDown with idx + 2 (Craft.js index semantics)', () => {
|
|
render('child-1');
|
|
act(() => captured!.moveUp());
|
|
expect(moveMock).toHaveBeenCalledWith('child-1', 'parent-1', 0);
|
|
|
|
moveMock.mockClear();
|
|
act(() => captured!.moveDown());
|
|
expect(moveMock).toHaveBeenCalledWith('child-1', 'parent-1', 3);
|
|
});
|
|
|
|
test('moveUp/moveDown at a boundary are no-ops', () => {
|
|
render('child-0');
|
|
act(() => captured!.moveUp());
|
|
expect(moveMock).not.toHaveBeenCalled();
|
|
|
|
rerender('child-2');
|
|
act(() => captured!.moveDown());
|
|
expect(moveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('duplicate regenerates ids before calling actions.addNodeTree with the parent id', () => {
|
|
render('child-1');
|
|
act(() => captured!.duplicate());
|
|
|
|
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
|
|
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
|
const [tree, parentId] = addNodeTreeMock.mock.calls[0];
|
|
expect(parentId).toBe('parent-1');
|
|
expect(tree.rootNodeId).not.toBe(COPIED_TREE.rootNodeId);
|
|
});
|
|
|
|
test('selectParent calls actions.selectNode with the parent id', () => {
|
|
render('child-1');
|
|
act(() => captured!.selectParent());
|
|
expect(selectNodeMock).toHaveBeenCalledWith('parent-1');
|
|
});
|
|
|
|
test('deleteNode calls actions.delete with the node id when deletable', () => {
|
|
render('child-1');
|
|
expect(captured!.canDelete).toBe(true);
|
|
act(() => captured!.deleteNode());
|
|
expect(deleteMock).toHaveBeenCalledWith('child-1');
|
|
});
|
|
|
|
test('ROOT nodeId disables every action and is a safe no-op', () => {
|
|
render('ROOT');
|
|
expect(captured!.canMoveUp).toBe(false);
|
|
expect(captured!.canMoveDown).toBe(false);
|
|
expect(captured!.canDelete).toBe(false);
|
|
|
|
act(() => {
|
|
captured!.moveUp();
|
|
captured!.moveDown();
|
|
captured!.duplicate();
|
|
captured!.selectParent();
|
|
captured!.deleteNode();
|
|
});
|
|
expect(moveMock).not.toHaveBeenCalled();
|
|
expect(addNodeTreeMock).not.toHaveBeenCalled();
|
|
expect(selectNodeMock).not.toHaveBeenCalled();
|
|
expect(deleteMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('null nodeId disables every action and is a safe no-op', () => {
|
|
render(null);
|
|
expect(captured!.canMoveUp).toBe(false);
|
|
expect(captured!.canMoveDown).toBe(false);
|
|
expect(captured!.canDelete).toBe(false);
|
|
});
|
|
});
|