From 448df660103edfec7835962f217973e79f9341f5 Mon Sep 17 00:00:00 2001 From: Prev Wong Date: Fri, 17 Jul 2020 16:23:44 +0800 Subject: [PATCH] feat: add parseFreshNode --- packages/core/src/editor/query.tsx | 36 +++-- packages/core/src/editor/tests/query.test.tsx | 58 +++++--- packages/core/src/interfaces/nodes.ts | 5 + packages/core/src/utils/createNode.ts | 125 ++++++++++++++++++ packages/core/src/utils/parseNodeFromJSX.tsx | 123 ++--------------- .../core/src/utils/tests/createNode.test.tsx | 109 +++++++++++++++ .../src/utils/tests/parseNodeFromJSX.test.tsx | 85 ++++++------ packages/docs/docs/api/useEditor.md | 68 ++++++---- 8 files changed, 406 insertions(+), 203 deletions(-) create mode 100644 packages/core/src/utils/createNode.ts create mode 100644 packages/core/src/utils/tests/createNode.test.tsx diff --git a/packages/core/src/editor/query.tsx b/packages/core/src/editor/query.tsx index 8208db8e0..25dc334b6 100644 --- a/packages/core/src/editor/query.tsx +++ b/packages/core/src/editor/query.tsx @@ -9,6 +9,7 @@ import { NodeTree, SerializedNodes, SerializedNode, + FreshNode, } from '../interfaces'; import invariant from 'tiny-invariant'; import { @@ -20,6 +21,7 @@ import { ROOT_NODE, } from '@craftjs/utils'; import findPosition from '../events/findPosition'; +import { createNode } from '../utils/createNode'; import { parseNodeFromJSX } from '../utils/parseNodeFromJSX'; import { fromEntries } from '../utils/fromEntries'; import { mergeTrees } from '../utils/mergeTrees'; @@ -173,22 +175,32 @@ export function QueryMethods(state: EditorState) { parseSerializedNode: (serializedNode: SerializedNode) => ({ toNode(id?: NodeId): Node { const data = deserializeNode(serializedNode, state.options.resolver); - invariant(data.type, ERROR_NOT_IN_RESOLVER); + return _() + .parseFreshNode({ + ...(id ? { id } : {}), + data, + }) + .toNode(); + }, + }), - return parseNodeFromJSX( - React.createElement(data.type, data.props), - (node) => { - if (id) { - node.id = id; - } - node.data = data; + parseFreshNode: (node: FreshNode) => ({ + toNode(normalize?: (node: Node) => void): Node { + return createNode(node, (node) => { + if (node.data.parent === DEPRECATED_ROOT_NODE) { + node.data.parent = ROOT_NODE; + } - if (node.data.parent === DEPRECATED_ROOT_NODE) { - node.data.parent = ROOT_NODE; - } + const name = resolveComponent(state.options.resolver, node.data.type); + invariant(name !== null, ERROR_NOT_IN_RESOLVER); + node.data.displayName = node.data.displayName || name; + node.data.name = name; + + if (normalize) { + normalize(node); } - ); + }); }, }), diff --git a/packages/core/src/editor/tests/query.test.tsx b/packages/core/src/editor/tests/query.test.tsx index f782d3575..b6d7679a7 100644 --- a/packages/core/src/editor/tests/query.test.tsx +++ b/packages/core/src/editor/tests/query.test.tsx @@ -8,13 +8,16 @@ import { secondaryButton, documentWithCardState, } from '../../tests/fixtures'; +import { createNode } from '../../utils/createNode'; import { parseNodeFromJSX } from '../../utils/parseNodeFromJSX'; import { deserializeNode } from '../../utils/deserializeNode'; -import { SerializedNode } from '@craftjs/core'; jest.mock('../../utils/resolveComponent', () => ({ resolveComponent: () => null, })); +jest.mock('../../utils/createNode', () => ({ + createNode: () => null, +})); jest.mock('../../utils/parseNodeFromJSX', () => ({ parseNodeFromJSX: () => null, })); @@ -35,47 +38,68 @@ describe('query', () => { describe('parseSerializedNode', () => { describe('toNode', () => { let data = { + type: 'h2', props: { className: 'hello' }, nodes: [], custom: {}, isCanvas: false, parent: null, + displayName: 'h2', hidden: false, }; - let serializedNode: SerializedNode = { - type: 'h2', - ...data, - }; beforeEach(() => { - deserializeNode = jest.fn().mockImplementation(() => serializedNode); - parseNodeFromJSX = jest.fn(); + deserializeNode = jest.fn().mockImplementation(() => data); + createNode = jest.fn().mockImplementation(() => null); - query.parseSerializedNode(serializedNode).toNode(); + query.parseSerializedNode(data).toNode(); }); it('should call deserializeNode', () => { - expect(deserializeNode).toBeCalledWith( - serializedNode, - state.options.resolver - ); + expect(deserializeNode).toBeCalledWith(data, state.options.resolver); }); it('should call parseNodeFromJSX', () => { - expect(parseNodeFromJSX).toBeCalledWith( - React.createElement('h2', data.props), + expect(createNode).toHaveBeenCalledWith( + { + data, + }, expect.any(Function) ); }); }); }); - describe('parseReactElement', () => { - describe('toNodeTree', () => {}); + describe('parseFreshNode', () => { + describe('toNode', () => { + let data = { + type: 'h1', + }; + + beforeEach(() => { + createNode = jest.fn().mockImplementation(() => null); + query + .parseFreshNode({ + data: { + type: 'h1', + }, + }) + .toNode(); + }); + + it('should call createNode', () => { + expect(createNode).toHaveBeenCalledWith( + { + data, + }, + expect.any(Function) + ); + }); + }); }); - describe('parseNodeFromReactNode', () => { + describe('parseReactElement', () => { let tree; const node =

Hello

; const name = 'Document'; diff --git a/packages/core/src/interfaces/nodes.ts b/packages/core/src/interfaces/nodes.ts index 3c64792dc..2bdc8eb0b 100644 --- a/packages/core/src/interfaces/nodes.ts +++ b/packages/core/src/interfaces/nodes.ts @@ -55,6 +55,11 @@ export type NodeData = { _childCanvas?: Record; // TODO: Deprecate in favour of linkedNodes }; +export type FreshNode = { + id?: NodeId; + data: Partial & Required>; +}; + export type ReduceCompType = | string | { diff --git a/packages/core/src/utils/createNode.ts b/packages/core/src/utils/createNode.ts new file mode 100644 index 000000000..4a8ab0572 --- /dev/null +++ b/packages/core/src/utils/createNode.ts @@ -0,0 +1,125 @@ +import React from 'react'; +import { NodeData, Node, FreshNode } from '../interfaces'; +import { produce } from 'immer'; +import { Canvas, deprecateCanvasComponent } from '../nodes/Canvas'; +import { + defaultElementProps, + Element, + elementPropToNodeData, +} from '../nodes/Element'; +import { NodeProvider } from '../nodes/NodeContext'; +import { getRandomNodeId } from './getRandomNodeId'; + +export function createNode( + newNode: FreshNode, + normalize?: (node: Node) => void +) { + let actualType = newNode.data.type as any; + let id = newNode.id || getRandomNodeId(); + + return produce({}, (node: Node) => { + node.id = id; + node._hydrationTimestamp = Date.now(); + + node.data = { + type: actualType, + props: { ...newNode.data.props }, + name: + typeof actualType == 'string' ? actualType : (actualType as any).name, + displayName: + typeof actualType == 'string' ? actualType : (actualType as any).name, + custom: {}, + isCanvas: false, + hidden: false, + ...newNode.data, + } as NodeData; + + node.related = {}; + + node.events = { + selected: false, + dragged: false, + hovered: false, + }; + + node.rules = { + canDrag: () => true, + canDrop: () => true, + canMoveIn: () => true, + canMoveOut: () => true, + ...((actualType.craft && actualType.craft.rules) || {}), + }; + + // @ts-ignore + if (node.data.type === Element || node.data.type === Canvas) { + let usingDeprecatedCanvas = node.data.type === Canvas; + const mergedProps = { + ...defaultElementProps, + ...node.data.props, + }; + + Object.keys(defaultElementProps).forEach((key) => { + node.data[elementPropToNodeData[key] || key] = mergedProps[key]; + delete node.data.props[key]; + }); + + actualType = node.data.type; + + if (usingDeprecatedCanvas) { + node.data.isCanvas = true; + deprecateCanvasComponent(); + } + } + + if (normalize) { + normalize(node); + } + + if (actualType.craft) { + node.data.props = { + ...(actualType.craft.props || actualType.craft.defaultProps || {}), + ...node.data.props, + }; + + const displayName = actualType.craft.displayName || actualType.craft.name; + if (displayName) { + node.data.displayName = displayName; + } + + if (actualType.craft.isCanvas) { + node.data.isCanvas = node.data.isCanvas || actualType.craft.isCanvas; + } + + if (actualType.craft.rules) { + Object.keys(actualType.craft.rules).forEach((key) => { + if (['canDrag', 'canDrop', 'canMoveIn', 'canMoveOut'].includes(key)) { + node.rules[key] = actualType.craft.rules[key]; + } + }); + } + + if (actualType.craft.custom) { + node.data.custom = { + ...actualType.craft.custom, + ...node.data.custom, + }; + } + + if (actualType.craft.related) { + node.related = {}; + const relatedNodeContext = { + id: node.id, + related: true, + }; + Object.keys(actualType.craft.related).forEach((comp) => { + node.related[comp] = () => + React.createElement( + NodeProvider, + relatedNodeContext, + React.createElement(actualType.craft.related[comp]) + ); + }); + } + } + }) as Node; +} diff --git a/packages/core/src/utils/parseNodeFromJSX.tsx b/packages/core/src/utils/parseNodeFromJSX.tsx index 861ff941d..515990f3d 100644 --- a/packages/core/src/utils/parseNodeFromJSX.tsx +++ b/packages/core/src/utils/parseNodeFromJSX.tsx @@ -1,14 +1,6 @@ import React, { Fragment } from 'react'; -import { NodeData, Node } from '../interfaces'; -import { produce } from 'immer'; -import { Canvas, deprecateCanvasComponent } from '../nodes/Canvas'; -import { - defaultElementProps, - Element, - elementPropToNodeData, -} from '../nodes/Element'; -import { NodeProvider } from '../nodes/NodeContext'; -import { getRandomNodeId } from './getRandomNodeId'; +import { Node } from '../interfaces'; +import { createNode } from './createNode'; export function parseNodeFromJSX( jsx: React.ReactElement | string, @@ -22,106 +14,17 @@ export function parseNodeFromJSX( let actualType = element.type as any; - let id = getRandomNodeId(); - - return produce({}, (node: Node) => { - node.id = id; - node._hydrationTimestamp = Date.now(); - - node.data = { - type: actualType, - props: { ...element.props }, - name: - typeof actualType == 'string' ? actualType : (actualType as any).name, - displayName: - typeof actualType == 'string' ? actualType : (actualType as any).name, - custom: {}, - hidden: false, - } as NodeData; - - node.related = {}; - - node.events = { - selected: false, - dragged: false, - hovered: false, - }; - - node.rules = { - canDrag: () => true, - canDrop: () => true, - canMoveIn: () => true, - canMoveOut: () => true, - ...((actualType.craft && actualType.craft.rules) || {}), - }; - - // @ts-ignore - if (node.data.type === Element || node.data.type === Canvas) { - let usingDeprecatedCanvas = node.data.type === Canvas; - const mergedProps = { - ...defaultElementProps, - ...node.data.props, - }; - - Object.keys(defaultElementProps).forEach((key) => { - node.data[elementPropToNodeData[key] || key] = mergedProps[key]; - delete node.data.props[key]; - }); - - actualType = node.data.type; - - if (usingDeprecatedCanvas) { - node.data.isCanvas = true; - deprecateCanvasComponent(); - } - } - - if (normalize) { - normalize(node, element as React.ReactElement); - } - - if (actualType.craft) { - node.data.props = { - ...(actualType.craft.props || actualType.craft.defaultProps || {}), - ...node.data.props, - }; - - const displayName = actualType.craft.displayName || actualType.craft.name; - if (displayName) { - node.data.displayName = displayName; - } - - if (actualType.craft.isCanvas) { - node.data.isCanvas = node.data.isCanvas || actualType.craft.isCanvas; - } - - if (actualType.craft.rules) { - Object.keys(actualType.craft.rules).forEach((key) => { - if (['canDrag', 'canDrop', 'canMoveIn', 'canMoveOut'].includes(key)) { - node.rules[key] = actualType.craft.rules[key]; - } - }); - } - - if (actualType.craft.custom) { - node.data.custom = node.data.custom || actualType.craft.custom; - } - - if (actualType.craft.related) { - node.related = {}; - const relatedNodeContext = { - id: node.id, - related: true, - }; - Object.keys(actualType.craft.related).forEach((comp) => { - node.related[comp] = () => - React.createElement( - NodeProvider, - relatedNodeContext, - React.createElement(actualType.craft.related[comp]) - ); - }); + return createNode( + { + data: { + type: actualType, + props: { ...element.props }, + }, + }, + (node) => { + if (normalize) { + normalize(node, element); } } - }) as Node; + ); } diff --git a/packages/core/src/utils/tests/createNode.test.tsx b/packages/core/src/utils/tests/createNode.test.tsx new file mode 100644 index 000000000..1eeefb943 --- /dev/null +++ b/packages/core/src/utils/tests/createNode.test.tsx @@ -0,0 +1,109 @@ +import { createNode } from '../createNode'; +import { createTestNode } from '../createTestNode'; + +const expectNode = (node, testData) => { + const type = node.data.type; + const isUserComponent = typeof type === 'function' && !!type.craft; + + const match = createTestNode(node.id, { + ...testData, + props: isUserComponent + ? { ...(type.craft.defaultProps || {}), ...testData.props } + : testData.props || {}, + custom: isUserComponent ? type.craft.custom : {}, + name: typeof type === 'string' ? type : type.name, + displayName: typeof type === 'string' ? type : type.name, + }); + + expect(node.data).toEqual(match.data); + + if (!isUserComponent) { + return; + } + + if (type.craft.rules) { + Object.keys(type.craft.rules).forEach((name) => { + expect(node.rules[name]).toEqual(type.craft.rules[name]); + }); + } + + if (type.craft.related) { + Object.keys(type.craft.related).forEach((name) => { + expect(node.related[name]).toEqual(expect.any(Function)); + }); + } +}; + +describe('createNode', () => { + const props = { href: 'href' }; + + describe('Returns correct type and props', () => { + it('should transform a link correctly', () => { + const data = { + type: 'a', + props, + }; + + const node = createNode({ + data, + }); + + expectNode(node, data); + }); + it('should normalise data correctly', () => { + const extraData = { props: { style: 'purple' } }; + + const { data } = createNode( + { + data: { type: 'button', props }, + }, + (node) => { + node.data.props = { + ...node.data.props, + ...extraData.props, + }; + } + ); + + expect({ type: data.type, props: data.props }).toEqual({ + type: 'button', + props: { + ...props, + ...extraData.props, + }, + }); + }); + + describe('when a User Component is passed', () => { + const Component = () => {}; + Component.craft = { + custom: { + css: { + background: '#fff', + }, + }, + rules: { + canMoveIn: () => false, + }, + defaultProps: { + text: '#000', + }, + related: { + settings: () => {}, + }, + }; + + it('should return node with correct type and user component config', () => { + const data = { + type: Component, + }; + + const node = createNode({ + data, + }); + + expectNode(node, data); + }); + }); + }); +}); diff --git a/packages/core/src/utils/tests/parseNodeFromJSX.test.tsx b/packages/core/src/utils/tests/parseNodeFromJSX.test.tsx index bfcd7800f..67ff1eecf 100644 --- a/packages/core/src/utils/tests/parseNodeFromJSX.test.tsx +++ b/packages/core/src/utils/tests/parseNodeFromJSX.test.tsx @@ -1,65 +1,70 @@ import React, { Fragment } from 'react'; import { parseNodeFromJSX } from '../parseNodeFromJSX'; +import { createNode } from '../createNode'; const Component = ({ href }) => Hi; describe('parseNodeFromJSX', () => { const props = { href: 'href' }; + beforeEach(() => { + createNode = jest.fn(); + }); + describe('Returns correct type and props', () => { it('should transform a link correctly', () => { // eslint-disable-next-line jsx-a11y/anchor-has-content - const { data } = parseNodeFromJSX(); + parseNodeFromJSX(); - expect({ type: data.type, props: data.props }).toEqual({ - type: 'a', - props, - }); - }); - it('should normalise data correctly', () => { - const extraData = { props: { style: 'purple' } }; - const { data } = parseNodeFromJSX( -