From da714eba6196b3cee1d6c39845dd262ff0d6cf5e Mon Sep 17 00:00:00 2001 From: Prev Wong Date: Fri, 12 Jun 2020 13:56:32 +0800 Subject: [PATCH] feat: refactor API (#74) * Add prepush hook, some small nits to the craft project setup (#1) * add test staged * add prepush hook * Add onStateChanged hook (#2) * improve unit tests setup, refactor and move some unit tests. Also add a callback for nodes * add pull request template * add unit tests for frame, improve handling of frame * Editor state get & set (#3) * Adds nodes state get and set methods * Fixes over-shadowing bug * Add private @candulabs scope for release * Switches whole repo to point to @candulabs fork * Updates tests * Bumps up core version * Update README.md Adds warning about using this fork instead of official release * Sync changes from main repo (#4) * add test staged * add prepush hook * run prettier * change unit test matcher * ignore Canvas root id test Caused by Subscriber, to be fixed * v0.1.0-beta.4 * Update contributing * update naming * update version number * package naming Co-authored-by: Prev Wong * Adds github workflow (#5) * Adds test workflow * Fixes shell linter * Refactors flow to use node step * Adds build step * Corrects dep version * Removes bad dependency * Fixes potential case sensitive typo * Configures lerna version * Reset fork versions for lerna * v0.1.5 * Adds optional publish step * Adds better name to workflow * Removes redundant job name * Improves naming further * Actions - second attempt (#6) * Adds test workflow * Fixes shell linter * Refactors flow to use node step * Adds build step * Corrects dep version * Removes bad dependency * Fixes potential case sensitive typo * Configures lerna version * Reset fork versions for lerna * v0.1.5 * Adds optional publish step * Adds better name to workflow * Removes redundant job name * Improves naming further * Introduces split workflow * v0.1.6 * Adds npmrc * Moves env to job level * v0.1.7 * Sync changes from main repo (#8) * add test staged * add prepush hook * run prettier * change unit test matcher * ignore Canvas root id test Caused by Subscriber, to be fixed * v0.1.0-beta.4 * Update contributing * fix: allow Subscriber to collect state when created (#52) * fix: add types for subscriber * docs: update styling * docs: fix typos * docs: add example for drop indicator colours * chore: add open collective * docs: add layers gif * chore: update README * docs: fix Frame props description Co-authored-by: Michele Riccardo Esposito Co-authored-by: Prev Wong * v0.1.8 * expose use node context (#9) * v0.1.9 * Rewrite actions.delete and actions.add plus unit tests (#10) * tidy up the style inside actions * add unit tests for action * improve actions.add and actions.delete functions * fix drag selection * v0.1.10 * Refactor event handler to add more unit tests and make it more readable (#11) * couple small style improvements * refactor event handlers * sort the query methods by name * v0.1.11 * Local development with yalc (#12) * Adds setup for local development with yalc * Adds nodemon config file to npmignore * v0.1.12 * Parse entire tree when dropping a node (#13) * change query to create and parse a tree, small code style nits * parse an entire tree from jsx instead of just a single node * implement adding the tree recursively * fix dropping shadow * improve the renderNode function to render nodes among children if there are any * prevent nodes from being stripped from state if they are not in a canvas node * v0.1.13 * Adds check if resolver has isCanvas set (#14) * v0.1.14 * fix: use Subscriber to handle onStateChange (#15) * v0.1.15 * add enzyme deps * pr review * feat: add deprecation util * feat: add normaliser to createNode/transformJSX * feat: refactor actions * feat: refactor rendering * feat: deprecate Canvas with Element * cleanup * fix: render node bug when node deleted * fix tests * fix: remove timeout hack * feat: deprecate adding array of Nodes * fix: simplify RenderNode * nit: cleanup unused vars * nit: remove comments * chore: add yalc * fix: Element rehydration * chore: refactor parseNodeDataFromJSX * fix: api scheme * chore: remove capture/debounce handlers * chore: handle deprecated _childNodes * chore: hidden => isHidden * chore: _childCanvas => linkedNodes * nit: cleanup * feat: add hydrationTimestamp * chore: cleanup * nit: rename Tree => NodeTree * fix: deprecate decendants * feat: move connectors to context * fix: RenderNode * fix: deserialisation * feat: update hooks exposed actions * fix: update action * chore: cleanup * chore: update api in test * feat: refactor NodeHelpers * nit: update element isCanvas => canvas * fix: typo * fix: NodeHelpers * feat: refactor queries * chore: cleanup * fix: remove drag * chore: update docs * fix: remove drag * feat: rename addTree => addNodeTree * chore: rename onStateChange => onNodesChange * feat: update linkednode parent * chore: update docs * feat: change to click event * feat: remove id prefix and rename root node * cleanup * chore * chore: remove isHidden * chore: cleanup API * chore: add test watch command * chore: fix docs * nit * nit: cleanup * nit: pr * feat: deprecate craft name/defaultProps * feat: ensure dragged element is selected * chore: update docs * fix: ensure events exist * nit(layers): use updated API * fix: existing node in element * nit: cleanup * nit: cleanup Co-authored-by: Michele Riccardo Esposito Co-authored-by: Mateusz Drulis Co-authored-by: GitHub Action --- package.json | 2 +- packages/core/src/editor/Editor.tsx | 4 +- packages/core/src/editor/NodeHelpers.ts | 217 +++++++++++++ packages/core/src/editor/actions.ts | 168 ++++++---- packages/core/src/editor/query.tsx | 284 ++++++----------- .../core/src/editor/tests/NodeHelpers.test.ts | 174 ++++++++++ .../core/src/editor/tests/actions.test.ts | 46 +-- packages/core/src/editor/tests/query.test.tsx | 190 ++++++----- packages/core/src/events/EventHandlers.ts | 59 ++-- .../src/events/tests/EventHandlers.test.ts | 73 +++-- packages/core/src/hooks/useEditor.tsx | 15 +- packages/core/src/hooks/useNode.ts | 15 +- packages/core/src/index.tsx | 1 + packages/core/src/interfaces/editor.ts | 2 +- packages/core/src/interfaces/nodes.ts | 16 +- packages/core/src/nodes/Canvas.tsx | 145 +-------- packages/core/src/nodes/Element.tsx | 98 ++++++ packages/core/src/nodes/NodeContext.tsx | 20 +- packages/core/src/nodes/NodeElement.tsx | 13 +- packages/core/src/nodes/index.ts | 3 +- packages/core/src/nodes/useInternalNode.ts | 16 +- packages/core/src/nodes/useNodeContext.ts | 5 - packages/core/src/render/Frame.tsx | 38 +-- packages/core/src/render/RenderNode.tsx | 60 ++-- packages/core/src/render/tests/Frame.test.tsx | 28 +- .../core/src/render/tests/RenderNode.test.tsx | 55 ++-- packages/core/src/tests/fixtures.ts | 147 +++++---- packages/core/src/utils/createNode.ts | 81 ----- packages/core/src/utils/createTestNode.ts | 20 ++ packages/core/src/utils/deserializeNode.tsx | 33 +- packages/core/src/utils/getDeepNodes.ts | 29 -- packages/core/src/utils/mergeTrees.tsx | 9 +- .../core/src/utils/parseNodeDataFromJSX.tsx | 26 -- packages/core/src/utils/parseNodeFromJSX.tsx | 126 ++++++++ .../utils/tests/parseNodeDataFromJSX.test.tsx | 46 --- .../src/utils/tests/parseNodeFromJSX.test.tsx | 65 ++++ packages/docs/docs/api/Canvas.md | 17 +- packages/docs/docs/api/Editor.md | 23 +- packages/docs/docs/api/EditorState.md | 3 +- packages/docs/docs/api/Element.md | 187 +++++++++++ packages/docs/docs/api/Frame.md | 28 +- packages/docs/docs/api/Node.md | 190 ++++++++++- packages/docs/docs/api/NodeHelpers.md | 170 +++++++--- packages/docs/docs/api/NodeTree.md | 54 ++++ packages/docs/docs/api/UserComponent.md | 9 +- packages/docs/docs/api/useEditor.md | 24 +- packages/docs/docs/api/useNode.md | 1 - packages/docs/docs/concepts/nodes.md | 74 ++--- packages/docs/docs/concepts/serializing.md | 6 +- .../docs/docs/concepts/user-components.md | 171 +++++----- packages/docs/docs/guides/basic-tutorial.md | 83 ++--- packages/docs/docs/guides/save-load.md | 6 +- packages/docs/sidebars.js | 3 +- .../src/layers/DefaultLayer/DefaultLayer.tsx | 2 +- .../DefaultLayer/DefaultLayerHeader.tsx | 2 +- .../src/layers/LayerContextProvider.tsx | 10 +- packages/layers/src/layers/LayerNode.tsx | 2 +- packages/layers/src/layers/useLayer.tsx | 2 +- packages/utils/src/Handlers.ts | 1 - packages/utils/src/constants.ts | 16 +- packages/utils/src/useMethods.ts | 7 +- yarn.lock | 298 +----------------- 62 files changed, 2181 insertions(+), 1537 deletions(-) create mode 100644 packages/core/src/editor/NodeHelpers.ts create mode 100644 packages/core/src/editor/tests/NodeHelpers.test.ts create mode 100644 packages/core/src/nodes/Element.tsx delete mode 100644 packages/core/src/nodes/useNodeContext.ts delete mode 100644 packages/core/src/utils/createNode.ts create mode 100644 packages/core/src/utils/createTestNode.ts delete mode 100644 packages/core/src/utils/getDeepNodes.ts delete mode 100644 packages/core/src/utils/parseNodeDataFromJSX.tsx create mode 100644 packages/core/src/utils/parseNodeFromJSX.tsx delete mode 100644 packages/core/src/utils/tests/parseNodeDataFromJSX.test.tsx create mode 100644 packages/core/src/utils/tests/parseNodeFromJSX.test.tsx create mode 100644 packages/docs/docs/api/Element.md create mode 100644 packages/docs/docs/api/NodeTree.md diff --git a/package.json b/package.json index 9211035aa..f70717350 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "prettier": "prettier --write .", "release": "run-s lint clean build test release:npm", "release:npm": "lerna publish", - "test:watch": "jest --watch", "test": "jest", + "test:watch": "cross-env NODE_ENV=test jest --watchAll", "lint": "eslint . --ext .js,.jsx,.ts,.tsx" }, "devDependencies": { diff --git a/packages/core/src/editor/Editor.tsx b/packages/core/src/editor/Editor.tsx index 56f60c6e9..8832bf328 100644 --- a/packages/core/src/editor/Editor.tsx +++ b/packages/core/src/editor/Editor.tsx @@ -7,7 +7,7 @@ import { useEditorStore } from "./store"; import { EditorContext } from "./EditorContext"; export const withDefaults = (options: Partial = {}) => ({ - onStateChange: () => null, + onNodesChange: () => null, onRender: ({ render }) => render, resolver: {}, nodes: null, @@ -41,7 +41,7 @@ export const Editor: React.FC> = ({ json: context.query.serialize(), }), ({ json }) => { - context.query.getOptions().onStateChange(JSON.parse(json)); + context.query.getOptions().onNodesChange(JSON.parse(json)); } ); }, [context]); diff --git a/packages/core/src/editor/NodeHelpers.ts b/packages/core/src/editor/NodeHelpers.ts new file mode 100644 index 000000000..357e7805b --- /dev/null +++ b/packages/core/src/editor/NodeHelpers.ts @@ -0,0 +1,217 @@ +import { EditorState, Node, NodeId } from "@craftjs/core"; +import invariant from "tiny-invariant"; +import { + deprecationWarning, + ERROR_CANNOT_DRAG, + ERROR_DUPLICATE_NODEID, + ERROR_INVALID_NODE_ID, + ERROR_MOVE_INCOMING_PARENT, + ERROR_MOVE_NONCANVAS_CHILD, + ERROR_MOVE_OUTGOING_PARENT, + ERROR_MOVE_TO_DESCENDANT, + ERROR_MOVE_TO_NONCANVAS_PARENT, + ERROR_MOVE_TOP_LEVEL_NODE, + ROOT_NODE, +} from "@craftjs/utils"; +import { serializeNode } from "../utils/serializeNode"; +import { mergeTrees } from "../utils/mergeTrees"; + +export function NodeHelpers(state: EditorState, id: NodeId) { + invariant(typeof id == "string", ERROR_INVALID_NODE_ID); + + const node = state.nodes[id]; + + const nodeHelpers = (id) => NodeHelpers(state, id); + + const getNodeFromIdOrNode = (node: NodeId | Node) => + typeof node === "string" ? state.nodes[node] : node; + + return { + isCanvas() { + return !!node.data.isCanvas; + }, + isRoot() { + return node.id === ROOT_NODE; + }, + isLinkedNode() { + return ( + node.data.parent && + nodeHelpers(node.data.parent).linkedNodes().includes(node.id) + ); + }, + isTopLevelNode() { + return this.isRoot() || this.isLinkedNode(); + }, + isDeletable() { + return !this.isTopLevelNode(); + }, + isParentOfTopLevelNodes: () => !!node.data.linkedNodes, + isParentOfTopLevelCanvas() { + deprecationWarning("query.node(id).isParentOfTopLevelCanvas", { + suggest: "query.node(id).isParentOfTopLevelNodes", + }); + return this.isParentOfTopLevelNodes(); + }, + get() { + return node; + }, + ancestors(deep = false) { + function appendParentNode( + id: NodeId, + result: NodeId[] = [], + depth: number = 0 + ) { + result.push(id); + const node = state.nodes[id]; + if (!node.data.parent) { + return result; + } + + if (deep || (!deep && depth === 0)) { + result = appendParentNode(node.data.parent, result, depth + 1); + } + return result; + } + return appendParentNode(node.data.parent); + }, + descendants(deep = false) { + function appendChildNode( + id: NodeId, + result: NodeId[] = [], + depth: number = 0 + ) { + const node = state.nodes[id]; + if (deep || (!deep && depth === 0)) { + // Include linkedNodes if any + const linkedNodes = nodeHelpers(id).linkedNodes(); + + linkedNodes.forEach((nodeId) => { + result.push(nodeId); + result = appendChildNode(nodeId, result, depth + 1); + }); + + const childNodes = node.data.nodes; + + if (!childNodes) { + return result; + } + + // Include child Nodes if any + if (childNodes) { + childNodes.forEach((nodeId) => { + result.push(nodeId); + result = appendChildNode(nodeId, result, depth + 1); + }); + } + } + return result; + } + return appendChildNode(id); + }, + linkedNodes() { + return Object.values(node.data.linkedNodes || {}); + }, + isDraggable(onError?: (err: string) => void) { + try { + const targetNode = node; + invariant(!this.isTopLevelNode(), ERROR_MOVE_TOP_LEVEL_NODE); + invariant( + NodeHelpers(state, targetNode.data.parent).isCanvas(), + ERROR_MOVE_NONCANVAS_CHILD + ); + invariant( + targetNode.rules.canDrag(targetNode, nodeHelpers), + ERROR_CANNOT_DRAG + ); + return true; + } catch (err) { + if (onError) { + onError(err); + } + return false; + } + }, + isDroppable(target: NodeId | Node, onError?: (err: string) => void) { + const isNewNode = typeof target == "object" && !state.nodes[target.id]; + const targetNode = getNodeFromIdOrNode(target), + newParentNode = node; + try { + // If target is a NodeId (thus it's already in the state), check if it's a top-level node + if (typeof target === "string") { + invariant( + !nodeHelpers(target).isTopLevelNode(), + ERROR_MOVE_TOP_LEVEL_NODE + ); + } + + invariant(this.isCanvas(), ERROR_MOVE_TO_NONCANVAS_PARENT); + invariant( + newParentNode.rules.canMoveIn(targetNode, newParentNode, nodeHelpers), + ERROR_MOVE_INCOMING_PARENT + ); + + if (isNewNode) { + return true; + } + + const currentParentNode = + targetNode.data.parent && state.nodes[targetNode.data.parent]; + + invariant(currentParentNode.data.isCanvas, ERROR_MOVE_NONCANVAS_CHILD); + + invariant( + currentParentNode || + (!currentParentNode && !state.nodes[targetNode.id]), + ERROR_DUPLICATE_NODEID + ); + + const targetDeepNodes = nodeHelpers(targetNode.id).descendants(); + + invariant( + !targetDeepNodes.includes(newParentNode.id) && + newParentNode.id !== targetNode.id, + ERROR_MOVE_TO_DESCENDANT + ); + invariant( + currentParentNode.rules.canMoveOut( + targetNode, + currentParentNode, + nodeHelpers + ), + ERROR_MOVE_OUTGOING_PARENT + ); + + return true; + } catch (err) { + if (onError) { + onError(err); + } + return false; + } + }, + toSerializedNode() { + return serializeNode(node.data, state.options.resolver); + }, + toNodeTree() { + const childNodes = (node.data.nodes || []).map((childNodeId) => { + return NodeHelpers(state, childNodeId).toNodeTree(); + }); + + return mergeTrees(node, childNodes); + }, + + /** + Deprecated NodeHelpers + **/ + + decendants(deep = false) { + deprecationWarning("query.node(id).decendants", { + suggest: "query.node(id).descendants", + }); + return this.descendants(deep); + }, + isTopLevelCanvas() { + return !this.isRoot() && !node.data.parent; + }, + }; +} diff --git a/packages/core/src/editor/actions.ts b/packages/core/src/editor/actions.ts index 25fe60662..0c1825fe6 100644 --- a/packages/core/src/editor/actions.ts +++ b/packages/core/src/editor/actions.ts @@ -6,15 +6,17 @@ import { Nodes, Options, NodeEvents, - Tree, + NodeTree, SerializedNodes, } from "../interfaces"; import { + deprecationWarning, ERROR_INVALID_NODEID, ROOT_NODE, + DEPRECATED_ROOT_NODE, QueryCallbacksFor, ERROR_NOPARENT, - ERROR_ROOT_CANVAS_NO_ID, + ERROR_DELETE_TOP_LEVEL_NODE, } from "@craftjs/utils"; import { QueryMethods } from "./query"; import { fromEntries } from "../utils/fromEntries"; @@ -27,22 +29,57 @@ export const Actions = ( query: QueryCallbacksFor ) => { /** Helper functions */ - const addNodeToParentAtIndex = (node: Node, parent: Node, index: number) => { - if (parent && node.data.isCanvas && !parent.data.isCanvas) { - invariant(node.data.props.id, ERROR_ROOT_CANVAS_NO_ID); - if (!parent.data._childCanvas) { - parent.data._childCanvas = {}; - } - node.data.parent = parent.id; - parent.data._childCanvas[node.data.props.id] = node.id; - } else { + const addNodeToParentAtIndex = ( + node: Node, + parentId: NodeId, + index?: number + ) => { + const parent = getParentAndValidate(parentId); + // reset the parent node ids + if (!parent.data.nodes) { + parent.data.nodes = []; + } + + if (parent.data.props.children) { + delete parent.data.props["children"]; + } + + if (index != null) { parent.data.nodes.splice(index, 0, node.id); - node.data.parent = parent.id; + } else { + parent.data.nodes.push(node.id); } + node.data.parent = parent.id; state.nodes[node.id] = node; }; + const addTreeToParentAtIndex = ( + tree: NodeTree, + parentId?: NodeId, + index?: number + ) => { + const node = tree.nodes[tree.rootNodeId]; + + if (parentId != null) { + addNodeToParentAtIndex(node, parentId, index); + } + + if (!node.data.nodes) { + return; + } + // we need to deep clone here... + const childToAdd = [...node.data.nodes]; + node.data.nodes = []; + childToAdd.forEach((childId, index) => + addTreeToParentAtIndex( + { rootNodeId: childId, nodes: tree.nodes }, + node.id, + index + ) + ); + }; + const getParentAndValidate = (parentId: NodeId): Node => { invariant(parentId, ERROR_NOPARENT); const parent = state.nodes[parentId]; @@ -52,78 +89,68 @@ export const Actions = ( return { /** - * Add a new Node(s) to the editor. + * @private + * Add a new linked Node to the editor. + * Only used internally by the component * - * @param nodes + * @param tree * @param parentId + * @param id */ - add(nodes: Node[] | Node, parentId: NodeId) { + addLinkedNodeFromTree(tree: NodeTree, parentId: NodeId, id?: string) { const parent = getParentAndValidate(parentId); - - if (!parent.data.nodes) { - parent.data.nodes = []; + if (!parent.data.linkedNodes) { + parent.data.linkedNodes = {}; } - if (parent.data.props.children) { - delete parent.data.props["children"]; - } + parent.data.linkedNodes[id] = tree.rootNodeId; - const nodesToAdd = Array.isArray(nodes) ? nodes : [nodes]; - nodesToAdd.forEach((node, index) => - addNodeToParentAtIndex(node, parent, index) - ); + tree.nodes[tree.rootNodeId].data.parent = parentId; + state.nodes[tree.rootNodeId] = tree.nodes[tree.rootNodeId]; + + addTreeToParentAtIndex(tree); }, /** - * Given a Node, it adds it at the correct position among the node children + * Add a new Node to the editor. * - * @param node + * @param nodeToAdd * @param parentId * @param index */ - addNodeAtIndex(node: Node, parentId: NodeId, index: number) { - const parent = getParentAndValidate(parentId); - - invariant( - index > -1 && index <= parent.data.nodes.length, - "AddNodeAtIndex: index must be between 0 and parentNodeLength inclusive" - ); - - addNodeToParentAtIndex(node, parent, index); + add(nodeToAdd: Node | Node[], parentId: NodeId, index?: number) { + // TODO: Deprecate adding array of Nodes to keep implementation simpler + let nodes = [nodeToAdd]; + if (Array.isArray(nodeToAdd)) { + deprecationWarning("actions.add(node: Node[])", { + suggest: "actions.add(node: Node)", + }); + nodes = nodeToAdd; + } + nodes.forEach((node: Node) => { + addNodeToParentAtIndex(node, parentId, index); + }); }, /** - * Given a tree, it adds it at the correct position among the node children + * Add a NodeTree to the editor * * @param tree * @param parentId * @param index */ - addTreeAtIndex(tree: Tree, parentId: NodeId, index: number) { - const parent = getParentAndValidate(parentId); - - invariant( - index > -1 && index <= parent.data.nodes.length, - "AddTreeAtIndex: index must be between 0 and parentNodeLength inclusive" - ); + addNodeTree(tree: NodeTree, parentId?: NodeId, index?: number) { const node = tree.nodes[tree.rootNodeId]; - // first, add the node - this.addNodeAtIndex(node, parentId, index); - if (!node.data.nodes) { - return; - } - // then add all the children - const addChild = (childId, index) => - this.addTreeAtIndex( - { rootNodeId: childId, nodes: tree.nodes }, - node.id, - index + + if (!parentId) { + invariant( + tree.rootNodeId === ROOT_NODE, + "Cannot add non-root Node without a parent" ); + state.nodes[tree.rootNodeId] = node; + } - // we need to deep clone here... - const childToAdd = [...node.data.nodes]; - node.data.nodes = []; - childToAdd.forEach(addChild); + addTreeToParentAtIndex(tree, parentId, index); }, /** @@ -131,16 +158,16 @@ export const Actions = ( * @param id */ delete(id: NodeId) { - invariant(id !== ROOT_NODE, "Cannot delete Root node"); - const targetNode = state.nodes[id]; + invariant(!query.node(id).isTopLevelNode(), ERROR_DELETE_TOP_LEVEL_NODE); + const targetNode = state.nodes[id]; if (targetNode.data.nodes) { // we deep clone here because otherwise immer will mutate the node // object as we remove nodes [...targetNode.data.nodes].forEach((childId) => this.delete(childId)); } - const parentChildren = state.nodes[targetNode.data.parent].data.nodes!; + const parentChildren = state.nodes[targetNode.data.parent].data.nodes; parentChildren.splice(parentChildren.indexOf(id), 1); updateEventsNode(state, id, true); @@ -151,14 +178,21 @@ export const Actions = ( const dehydratedNodes = typeof input == "string" ? JSON.parse(input) : input; - const nodePairs = Object.keys(dehydratedNodes).map((id) => [ - id, - query.parseNodeFromSerializedNode(dehydratedNodes[id], id), - ]); + const nodePairs = Object.keys(dehydratedNodes).map((id) => { + let nodeId = id; + + if (id === DEPRECATED_ROOT_NODE) { + nodeId = ROOT_NODE; + } + + return [ + nodeId, + query.parseSerializedNode(dehydratedNodes[id]).toNode(nodeId), + ]; + }); this.replaceNodes(fromEntries(nodePairs)); }, - /** * Move a target Node to a new Parent at a given index * @param targetId diff --git a/packages/core/src/editor/query.tsx b/packages/core/src/editor/query.tsx index 866f1bdca..080efb65b 100644 --- a/packages/core/src/editor/query.tsx +++ b/packages/core/src/editor/query.tsx @@ -6,38 +6,26 @@ import { Node, Options, NodeInfo, - Tree, + NodeTree, SerializedNodes, SerializedNode, } from "../interfaces"; import invariant from "tiny-invariant"; import { QueryCallbacksFor, - ROOT_NODE, - ERRROR_NOT_IN_RESOLVER, - ERROR_MOVE_TO_NONCANVAS_PARENT, - ERROR_MOVE_OUTGOING_PARENT, - ERROR_MOVE_INCOMING_PARENT, - ERROR_MOVE_TO_DESCENDANT, - ERROR_MOVE_NONCANVAS_CHILD, - ERROR_DUPLICATE_NODEID, + ERROR_NOT_IN_RESOLVER, getDOMInfo, - ERROR_CANNOT_DRAG, - ERROR_MOVE_TOP_LEVEL_CANVAS, - ERROR_MOVE_ROOT_NODE, - ERROR_INVALID_NODE_ID, deprecationWarning, + DEPRECATED_ROOT_NODE, + 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"; -import { getDeepNodes } from "../utils/getDeepNodes"; -import { parseNodeDataFromJSX } from "../utils/parseNodeDataFromJSX"; -import { serializeNode } from "../utils/serializeNode"; -import { getRandomNodeId } from "../utils/getRandomNodeId"; import { resolveComponent } from "../utils/resolveComponent"; import { deserializeNode } from "../utils/deserializeNode"; +import { NodeHelpers } from "./NodeHelpers"; export function QueryMethods(state: EditorState) { const options = state && state.options; @@ -45,67 +33,7 @@ export function QueryMethods(state: EditorState) { const _: () => QueryCallbacksFor = () => QueryMethods(state); - const getNodeFromIdOrNode = (node: NodeId | Node) => - typeof node === "string" ? state.nodes[node] : node; - return { - /** - * @deprecated - * Get a Node representing the specified React Element - * @param reactElement - * @param extras - */ - createNode(reactElement: React.ReactElement | string, extras?: any): Node { - deprecationWarning("query.createNode()", { - suggest: "query.parseNodeFromReactNode()", - }); - return this.parseNodeFromReactNode(reactElement, extras); - }, - - /** - * Given a `nodeData` and an optional Id, it will parse a new `Node` - * - * @param nodeData `node.data` property of the future data - * @param id an optional ID correspondent to the node - */ - parseNodeFromSerializedNode(nodeData: SerializedNode, id?: NodeId): Node { - const data = deserializeNode(nodeData, options.resolver); - - invariant(data.type, ERRROR_NOT_IN_RESOLVER); - - return this.parseNodeFromReactNode( - React.createElement(data.type, data.props), - { id, data } - ); - }, - - parseNodeFromReactNode( - reactElement: React.ReactElement | string, - extras: any = {} - ): Node { - const nodeData = parseNodeDataFromJSX(reactElement, extras.data); - // @ts-ignore - const node = createNode(nodeData, extras.id || getRandomNodeId()); - - const name = resolveComponent(options.resolver, node.data.type); - invariant(name !== null, ERRROR_NOT_IN_RESOLVER); - node.data.displayName = node.data.displayName || name; - node.data.name = name; - - return node; - }, - - parseTreeFromReactNode(reactNode: React.ReactElement): Tree | undefined { - const node = this.parseNodeFromReactNode(reactNode); - const childrenNodes = React.Children.map( - (reactNode.props && reactNode.props.children) || [], - (child) => - React.isValidElement(child) && this.parseTreeFromReactNode(child) - ).filter((children) => !!children); - - return mergeTrees(node, childrenNodes); - }, - /** * Determine the best possible location to drop the source Node relative to the target Node */ @@ -125,9 +53,9 @@ export function QueryMethods(state: EditorState) { ? targetNode : state.nodes[targetNode.data.parent]; - const targetParentNodes = targetParent.data._childCanvas - ? Object.values(targetParent.data._childCanvas) - : targetParent.data.nodes || []; + if (!targetParent) return; + + const targetParentNodes = targetParent.data.nodes || []; const dimensionsInContainer = targetParentNodes ? targetParentNodes.reduce((result, id: NodeId) => { @@ -189,116 +117,7 @@ export function QueryMethods(state: EditorState) { * @param id */ node(id: NodeId) { - invariant(typeof id == "string", ERROR_INVALID_NODE_ID); - - const node = state.nodes[id]; - const nodeQuery = _().node; - - return { - isCanvas: () => node.data.isCanvas, - isRoot: () => node.id === ROOT_NODE, - isTopLevelCanvas: () => - !nodeQuery(node.id).isRoot() && - !node.data.parent.startsWith("canvas-"), - isDeletable: () => - !nodeQuery(id).isRoot() && - (nodeQuery(id).isCanvas() ? !nodeQuery(id).isTopLevelCanvas() : true), - isParentOfTopLevelCanvas: () => !!node.data._childCanvas, - get: () => node, - ancestors: (result = []) => { - const parent = node.data.parent; - if (parent) { - result.push(parent); - nodeQuery(parent).ancestors(result); - } - return result; - }, - decendants: (deep = false) => { - return getDeepNodes(state.nodes, id, deep); - }, - isDraggable: (onError?: (err: string) => void) => { - try { - const targetNode = node; - invariant(!nodeQuery(targetNode.id).isRoot(), ERROR_MOVE_ROOT_NODE); - if (!nodeQuery(targetNode.id).isRoot()) { - invariant( - nodeQuery(targetNode.data.parent).isCanvas() === true, - ERROR_MOVE_TOP_LEVEL_CANVAS - ); - invariant( - targetNode.rules.canDrag(targetNode, _().node), - ERROR_CANNOT_DRAG - ); - } - return true; - } catch (err) { - if (onError) onError(err); - return false; - } - }, - isDroppable: ( - target: NodeId | Node, - onError?: (err: string) => void - ) => { - try { - const targetNode = getNodeFromIdOrNode(target); - - const currentParentNode = - targetNode.data.parent && state.nodes[targetNode.data.parent], - newParentNode = node; - - invariant( - currentParentNode || - (!currentParentNode && !state.nodes[targetNode.id]), - ERROR_DUPLICATE_NODEID - ); - - invariant( - nodeQuery(newParentNode.id).isCanvas(), - ERROR_MOVE_TO_NONCANVAS_PARENT - ); - invariant( - newParentNode.rules.canMoveIn( - targetNode, - newParentNode, - _().node - ), - ERROR_MOVE_INCOMING_PARENT - ); - - if (currentParentNode) { - const targetDeepNodes = nodeQuery(targetNode.id).decendants(); - invariant(targetNode.data.parent, ERROR_MOVE_NONCANVAS_CHILD); - invariant( - !targetDeepNodes.includes(newParentNode.id), - ERROR_MOVE_TO_DESCENDANT - ); - invariant( - currentParentNode.rules.canMoveOut( - targetNode, - currentParentNode, - _().node - ), - ERROR_MOVE_OUTGOING_PARENT - ); - } - return true; - } catch (err) { - if (onError) onError(err); - return false; - } - }, - serialize: () => this.serialise(node), - }; - }, - - /** - * Given a Node, it serializes it to its node data. Useful if you need to compare state of different nodes. - * - * @param node - */ - parseSerializedNodeFromNode(node: Node): SerializedNode { - return serializeNode(node.data, options.resolver); + return NodeHelpers(state, id); }, /** @@ -307,7 +126,7 @@ export function QueryMethods(state: EditorState) { getSerializedNodes(): SerializedNodes { const nodePairs = Object.keys(state.nodes).map((id: NodeId) => [ id, - this.parseSerializedNodeFromNode(state.nodes[id]), + this.node(id).toSerializedNode(), ]); return fromEntries(nodePairs); }, @@ -318,5 +137,86 @@ export function QueryMethods(state: EditorState) { serialize(): string { return JSON.stringify(this.getSerializedNodes()); }, + + parseReactElement: (reactElement: React.ReactElement) => ({ + toNodeTree( + normalize?: (node: Node, jsx: React.ReactElement) => void + ): NodeTree { + let node = parseNodeFromJSX(reactElement, (node, jsx) => { + 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, jsx); + } + }); + + let childrenNodes = []; + + if (reactElement.props && reactElement.props.children) { + childrenNodes = React.Children.toArray( + reactElement.props.children + ).reduce((accum, child) => { + if (React.isValidElement(child)) { + accum.push(_().parseReactElement(child).toNodeTree(normalize)); + } + return accum; + }, []); + } + + return mergeTrees(node, childrenNodes); + }, + }), + + parseSerializedNode: (serializedNode: SerializedNode) => ({ + toNode(id?: NodeId): Node { + const data = deserializeNode(serializedNode, state.options.resolver); + + invariant(data.type, ERROR_NOT_IN_RESOLVER); + + return parseNodeFromJSX( + React.createElement(data.type, data.props), + (node) => { + if (id) { + node.id = id; + } + node.data = data; + + if (node.data.parent === DEPRECATED_ROOT_NODE) { + node.data.parent = ROOT_NODE; + } + } + ); + }, + }), + + createNode(reactElement: React.ReactElement, extras?: any) { + deprecationWarning(`query.createNode(${reactElement})`, { + suggest: `query.parseReactElement(${reactElement}).toNodeTree()`, + }); + + const tree = this.parseReactElement(reactElement).toNodeTree(); + + const node = tree.nodes[tree.rootNodeId]; + + if (!extras) { + return node; + } + + if (extras.id) { + node.id = extras.id; + } + + if (extras.data) { + node.data = { + ...node.data, + ...extras.data, + }; + } + + return node; + }, }; } diff --git a/packages/core/src/editor/tests/NodeHelpers.test.ts b/packages/core/src/editor/tests/NodeHelpers.test.ts new file mode 100644 index 000000000..984d15c77 --- /dev/null +++ b/packages/core/src/editor/tests/NodeHelpers.test.ts @@ -0,0 +1,174 @@ +import { NodeHelpers } from "../NodeHelpers"; +import { + card, + documentWithVariousNodes, + primaryButton, + rootNode, + secondaryButton, +} from "../../tests/fixtures"; +import { serializeNode } from "../../utils/serializeNode"; + +let helper; + +jest.mock("../../utils/serializeNode", () => ({ + serializeNode: jest.fn(), +})); + +describe("NodeHelpers", () => { + beforeEach(() => { + helper = (id) => NodeHelpers(documentWithVariousNodes as any, id); + }); + + it("should throw error if invalid value supplied as NodeId", () => { + expect(() => helper({})).toThrowError(); + }); + + describe("isRoot", () => { + it("should return true if root node", () => { + expect(helper("ROOT").isRoot()).toBe(true); + }); + it("should return false if non-root node", () => { + expect(helper("node-card").isRoot()).toBe(false); + }); + }); + + describe("isCanvas", () => { + it("should return true if node is canvas", () => { + expect(helper("canvas-node").isCanvas()).toBe(true); + }); + it("should return false if node is non-canvas", () => { + expect(helper(primaryButton.id).isCanvas()).toBe(false); + }); + }); + + describe("isTopLevelNode", () => { + it("should return true if linked Node", () => { + expect(helper("linked-node").isTopLevelNode()).toBe(true); + }); + it("should return true if root Node", () => { + expect(helper("ROOT").isTopLevelNode()).toBe(true); + }); + it("should return false if non-top-level Node", () => { + expect(helper(secondaryButton.id).isCanvas()).toBe(false); + }); + }); + + describe("isDeletable", () => { + it("should return true if non-top level Node", () => { + expect(helper(secondaryButton.id).isDeletable()).toBe(true); + }); + it("should return false if top-level Node", () => { + expect(helper("linked-node").isDeletable()).toBe(false); + }); + }); + + describe("get", () => { + it("should return node", () => { + expect(helper(secondaryButton.id).get()).toBe( + documentWithVariousNodes.nodes[secondaryButton.id] + ); + }); + }); + + describe("descendants", () => { + it("should return immediate child node ids", () => { + expect(helper(rootNode.id).descendants()).toStrictEqual([card.id]); + }); + it("should return all child nodes", () => { + expect(helper(rootNode.id).descendants(true)).toStrictEqual([ + card.id, + ...documentWithVariousNodes.nodes[card.id].data.nodes, + ]); + }); + }); + + describe("ancestors", () => { + it("should return immediate parent node id", () => { + expect(helper(card.id).ancestors()).toStrictEqual([rootNode.id]); + }); + it("should return parent node id", () => { + expect(helper(secondaryButton.id).ancestors(true)).toStrictEqual([ + card.id, + rootNode.id, + ]); + }); + }); + + describe("isDraggable", () => { + it("should return false if top-level node", () => { + expect(helper(rootNode.id).isDraggable()).toEqual(false); + }); + it("should return false if node's rule rejects", () => { + expect(helper("node-reject-dnd").isDraggable()).toEqual(false); + }); + }); + + describe("isDroppable", () => { + it("should return false if target node is a top-level node", () => { + expect(helper("canvas-node").isDroppable("linked-node")).toEqual(false); + }); + it("should return false if target node is a not an immediate child of a Canvas", () => { + expect( + helper("canvas-node").isDroppable("non-immediate-canvas-child") + ).toEqual(false); + }); + it("should return false if droppable node is a not a Canvas", () => { + expect(helper(primaryButton.id).isDroppable(secondaryButton.id)).toEqual( + false + ); + }); + it("should return false if node's rule rejects incoming target", () => { + expect( + helper("canvas-node-reject-dnd").isDroppable(secondaryButton.id) + ).toEqual(false); + }); + it("should return false if node's rule rejects outgoing target", () => { + expect(helper("canvas-node").isDroppable("fixed-child-node")).toEqual( + false + ); + }); + it("should return false if target is a descendant", () => { + expect( + helper("parent-of-linked-node").isDroppable("canvas-node-reject-dnd") + ).toEqual(false); + }); + }); + + describe("toSerializedNode", () => { + it("should call serializeNode", () => { + helper("canvas-node").toSerializedNode(); + expect(serializeNode).toBeCalledTimes(1); + }); + }); + describe("toNodeTree", () => { + let tree; + beforeEach(() => { + tree = helper("ROOT").toNodeTree(); + }); + + it("should have correct rootNodeId", () => { + expect(tree.rootNodeId).toEqual("ROOT"); + }); + it("should contain root and child nodes", () => { + const { nodes } = tree; + + expect(nodes).toStrictEqual({ + ROOT: documentWithVariousNodes.nodes["ROOT"], + ...documentWithVariousNodes.nodes["ROOT"].data.nodes.reduce( + (accum, key) => { + accum[key] = documentWithVariousNodes.nodes[key]; + return accum; + }, + {} + ), + ...documentWithVariousNodes.nodes[card.id].data.nodes.reduce( + (accum, key) => { + accum[key] = documentWithVariousNodes.nodes[key]; + return accum; + }, + {} + ), + }); + }); + }); +}); diff --git a/packages/core/src/editor/tests/actions.test.ts b/packages/core/src/editor/tests/actions.test.ts index e24e9591b..7842d6387 100644 --- a/packages/core/src/editor/tests/actions.test.ts +++ b/packages/core/src/editor/tests/actions.test.ts @@ -33,14 +33,13 @@ describe("actions.add", () => { Actions(emptyState)((actions) => actions.add(rootNode, rootNode.id)) ).toThrow(); }); - it("should be able to add leaft to the document", () => { + it("should be able to add leaf to the document", () => { const newState = Actions(documentState)((actions) => actions.add(leafNode, rootNode.id) ); expect(newState).toEqual(documentWithLeafState); }); - it("should be able to add two nodes", () => { const newState = Actions(documentState)((actions) => actions.add([primaryButton, secondaryButton], rootNode.id) @@ -67,13 +66,13 @@ describe("actions.addNodeAtIndex", () => { }); it("should be able to add the node at 0", () => { const newState = Actions(documentState)((actions) => - actions.addNodeAtIndex(leafNode, rootNode.id, 0) + actions.add(leafNode, rootNode.id, 0) ); expect(newState).toEqual(documentWithLeafState); }); }); -describe("actions.addTreeAtIndex", () => { +describe("actions.addNodeTree", () => { it("should throw if we give a parentId that doesnt exist", () => { expect(() => Actions(emptyState)((actions) => actions.addTreeAtIndex(leafNode)) @@ -82,10 +81,10 @@ describe("actions.addTreeAtIndex", () => { it("should throw if we give an invalid index", () => { const state = Actions(documentState); expect(() => - state((actions) => actions.addTreeAtIndex(leafNode, rootNode.id, -1)) + state((actions) => actions.addNodeTree(leafNode, rootNode.id, -1)) ).toThrow(); expect(() => - state((actions) => actions.addTreeAtIndex(leafNode, rootNode.id, 1)) + state((actions) => actions.addNodeTree(leafNode, rootNode.id, 1)) ).toThrow(); }); it("should be able to add a single node at 0", () => { @@ -94,7 +93,7 @@ describe("actions.addTreeAtIndex", () => { nodes: { [leafNode.id]: leafNode }, }; const newState = Actions(documentState)((actions) => - actions.addTreeAtIndex(tree, rootNode.id, 0) + actions.addNodeTree(tree, rootNode.id, 0) ); expect(newState).toEqual(documentWithLeafState); }); @@ -104,7 +103,7 @@ describe("actions.addTreeAtIndex", () => { nodes: cloneDeep(documentWithCardState.nodes), }; const newState = Actions(documentState)((actions) => - actions.addTreeAtIndex(tree, rootNode.id, 0) + actions.addNodeTree(tree, rootNode.id, 0) ); expect(newState).toEqual(documentWithCardState); }); @@ -162,7 +161,6 @@ describe("actions.reset", () => { describe("actions.deserialize", () => { const serialized = mapValues(documentState.nodes, ({ data }) => ({ - type: {}, ...data, })); @@ -171,30 +169,12 @@ describe("actions.deserialize", () => { actions.deserialize(serialized) ); - const nodes = { - "canvas-ROOT": { - data: { - _childCanvas: undefined, - custom: {}, - displayName: "Document", - hidden: undefined, - isCanvas: undefined, - name: "Document", - nodes: [], - parent: undefined, - props: {}, - type: "div", - }, - events: { - dragged: false, - hovered: false, - selected: false, - }, - related: {}, - rules: expect.any(Object), - id: "canvas-ROOT", - }, + const node = { + ...rootNode, + rules: expect.anything(), + _hydrationTimestamp: expect.anything(), }; - expect(newState.nodes).toEqual(nodes); + + expect(newState.nodes["ROOT"]).toEqual(node); }); }); diff --git a/packages/core/src/editor/tests/query.test.tsx b/packages/core/src/editor/tests/query.test.tsx index 6231f631e..9f7899e97 100644 --- a/packages/core/src/editor/tests/query.test.tsx +++ b/packages/core/src/editor/tests/query.test.tsx @@ -8,12 +8,18 @@ import { secondaryButton, documentWithCardState, } from "../../tests/fixtures"; +import { parseNodeFromJSX } from "../../utils/parseNodeFromJSX"; +import { deserializeNode } from "../../utils/deserializeNode"; +import { SerializedNode } from "@craftjs/core"; jest.mock("../../utils/resolveComponent", () => ({ resolveComponent: () => null, })); -jest.mock("../../utils/parseNodeDataFromJSX", () => ({ - parseNodeDataFromJSX: () => ({ ...rootNode.data, type: "div" }), +jest.mock("../../utils/parseNodeFromJSX", () => ({ + parseNodeFromJSX: () => null, +})); +jest.mock("../../utils/deserializeNode", () => ({ + deserializeNode: () => null, })); describe("query", () => { @@ -26,112 +32,130 @@ describe("query", () => { query = QueryMethods(state); }); - describe("parseNodeFromReactNode", () => { - const extras = { id: 1 }; - const node =

Hello

; - const name = "Document"; - const nodeData = { ...rootNode.data, type: "div" }; + describe("parseSerializedNode", () => { + describe("toNode", () => { + let data = { + props: { className: "hello" }, + nodes: [], + custom: {}, + isCanvas: false, + parent: null, + displayName: "h2", + hidden: false, + }; + let serializedNode: SerializedNode = { + type: "h2", + ...data, + }; - describe("when we can resolve the type", () => { beforeEach(() => { - resolveComponent = jest.fn().mockImplementation(() => name); - query.parseNodeFromReactNode(node, extras); + deserializeNode = jest.fn().mockImplementation(() => serializedNode); + parseNodeFromJSX = jest.fn(); + + query.parseSerializedNode(serializedNode).toNode(); }); - it("should have called the resolveComponent", () => { - expect(resolveComponent).toHaveBeenCalledWith( - state.options.resolver, - nodeData.type + + it("should call deserializeNode", () => { + expect(deserializeNode).toBeCalledWith( + serializedNode, + state.options.resolver ); }); - it("should have changed the displayName and name of the node", () => { - expect(rootNode.data.name).toEqual(name); - }); - }); - describe("when we cant resolve a name", () => { - beforeEach(() => { - resolveComponent = jest.fn().mockImplementation(() => null); - }); - it("should throw an error", () => { - expect(() => query.parseNodeFromReactNode(node)).toThrow(); + it("should call parseNodeFromJSX", () => { + expect(parseNodeFromJSX).toBeCalledWith( + React.createElement("h2", data.props), + expect.any(Function) + ); }); }); }); - describe("parseTreeFromReactNode", () => { + describe("parseReactElement", () => { + describe("toNodeTree", () => {}); + }); + + describe("parseNodeFromReactNode", () => { let tree; - beforeEach(() => { - query.parseNodeFromReactNode = jest - .fn() - .mockImplementation(() => rootNode); - }); + const node =

Hello

; + const name = "Document"; + const nodeData = { ...rootNode.data, type: "div" }; - describe("when there is a single node with no children", () => { - const node = - + describe("when there is a complex tree", () => { + const node = ( +
+
+ + +
- - ); - beforeEach(() => { - query.parseNodeFromReactNode = jest - .fn() - .mockImplementationOnce(() => rootNode) - .mockImplementationOnce(() => card) - .mockImplementationOnce(() => primaryButton) - .mockImplementationOnce(() => secondaryButton); - tree = query.parseTreeFromReactNode(node); - }); - it("should call parseNodeFromReactNode with the right payload", () => { - expect(query.parseNodeFromReactNode).toHaveBeenCalledWith(node); - }); - it("should have called parseNodeFromReactNode 4 times", () => { - expect(query.parseNodeFromReactNode).toHaveBeenCalledTimes(4); - }); - it("should have replied with the right payload", () => { - expect(tree).toEqual({ - rootNodeId: rootNode.id, - nodes: documentWithCardState.nodes, + ); + beforeEach(() => { + parseNodeFromJSX = jest + .fn() + .mockImplementationOnce(() => rootNode) + .mockImplementationOnce(() => card) + .mockImplementationOnce(() => primaryButton) + .mockImplementationOnce(() => secondaryButton); + tree = query.parseReactElement(node).toNodeTree(); + }); + it("should have called parseNodeFromReactNode 4 times", () => { + expect(parseNodeFromJSX).toHaveBeenCalledTimes(4); + }); + it("should have replied with the right payload", () => { + expect(tree).toEqual({ + rootNodeId: rootNode.id, + nodes: documentWithCardState.nodes, + }); }); }); }); diff --git a/packages/core/src/events/EventHandlers.ts b/packages/core/src/events/EventHandlers.ts index 4d8944d08..2e02f26d9 100644 --- a/packages/core/src/events/EventHandlers.ts +++ b/packages/core/src/events/EventHandlers.ts @@ -1,16 +1,13 @@ import { createShadow } from "./createShadow"; -import { Indicator, NodeId, Tree } from "../interfaces"; +import { Indicator, NodeId, NodeTree } from "../interfaces"; import { ConnectorsForHandlers, defineEventListener, Handlers, } from "@craftjs/utils"; -import { debounce } from "debounce"; import { EditorStore } from "../editor/store"; -type DraggedElement = NodeId | Tree; - -const rapidDebounce = (f) => debounce(f, 1); +type DraggedElement = NodeId | NodeTree; /** * Specifies Editor-wide event handlers and connectors @@ -27,25 +24,19 @@ export class EventHandlers extends Handlers< select: { init: () => () => this.store.actions.setNodeEvent("selected", null), events: [ - defineEventListener( - "mousedown", - rapidDebounce((_, id: NodeId) => - this.store.actions.setNodeEvent("selected", id) - ), - true - ), + defineEventListener("click", (e, id: NodeId) => { + e.stopPropagation(); + this.store.actions.setNodeEvent("selected", id); + }), ], }, hover: { init: () => () => this.store.actions.setNodeEvent("hovered", null), events: [ - defineEventListener( - "mouseover", - rapidDebounce((_, id: NodeId) => - this.store.actions.setNodeEvent("hovered", id) - ), - true - ), + defineEventListener("mouseover", (e, id: NodeId) => { + e.stopPropagation(); + this.store.actions.setNodeEvent("hovered", id); + }), ], }, drop: { @@ -60,7 +51,7 @@ export class EventHandlers extends Handlers< e.preventDefault(); e.stopPropagation(); - const draggedElement = EventHandlers.draggedElement as Tree; + const draggedElement = EventHandlers.draggedElement as NodeTree; if (!draggedElement) { return; } @@ -86,15 +77,22 @@ export class EventHandlers extends Handlers< }, drag: { - init: (el) => { - el.setAttribute("draggable", true); - return () => el.setAttribute("draggable", false); + init: (el, id) => { + if (!this.store.query.node(id).isDraggable()) { + return () => {}; + } + + el.setAttribute("draggable", "true"); + return () => el.setAttribute("draggable", "false"); }, events: [ defineEventListener("dragstart", (e: DragEvent, id: NodeId) => { e.stopPropagation(); e.stopImmediatePropagation(); + // Ensure the Node is selected when it is being dragged + this.store.actions.setNodeEvent("selected", id); + this.store.actions.setNodeEvent("dragged", id); EventHandlers.draggedElementShadow = createShadow(e); @@ -115,7 +113,7 @@ export class EventHandlers extends Handlers< }, create: { init: (el) => { - el.setAttribute("draggable", true); + el.setAttribute("draggable", "true"); return () => el.removeAttribute("draggable"); }, events: [ @@ -125,7 +123,9 @@ export class EventHandlers extends Handlers< e.stopPropagation(); e.stopImmediatePropagation(); - const tree = this.store.query.parseTreeFromReactNode(userElement); + const tree = this.store.query + .parseReactElement(userElement) + .toNodeTree(); EventHandlers.draggedElementShadow = createShadow(e); EventHandlers.draggedElement = tree; @@ -137,7 +137,7 @@ export class EventHandlers extends Handlers< const onDropElement = (draggedElement, placement) => { const index = placement.index + (placement.where === "after" ? 1 : 0); - this.store.actions.addTreeAtIndex( + this.store.actions.addNodeTree( draggedElement, placement.parent.id, index @@ -157,7 +157,12 @@ export class EventHandlers extends Handlers< ) => void ) { const { draggedElement, draggedElementShadow, events } = EventHandlers; - if (draggedElement && events.indicator && !events.indicator.error) { + if ( + draggedElement && + events && + events.indicator && + !events.indicator.error + ) { const { placement } = events.indicator; onDropNode(draggedElement, placement); } diff --git a/packages/core/src/events/tests/EventHandlers.test.ts b/packages/core/src/events/tests/EventHandlers.test.ts index 93f35dd67..273238b1f 100644 --- a/packages/core/src/events/tests/EventHandlers.test.ts +++ b/packages/core/src/events/tests/EventHandlers.test.ts @@ -21,6 +21,12 @@ describe("EventHandlers", () => { let actions; let query; + let isDraggable; + let parsedNodeTree; + let parseReactElement = jest.fn().mockImplementation(() => ({ + toNodeTree: jest.fn().mockImplementation(() => parsedNodeTree), + })); + beforeEach(() => { e = { preventDefault: jest.fn(), @@ -35,14 +41,17 @@ describe("EventHandlers", () => { EventHandlers.events = undefined; actions = { - addTreeAtIndex: jest.fn(), + addNodeTree: jest.fn(), move: jest.fn(), setIndicator: jest.fn(), setNodeEvent: jest.fn(), }; query = { - parseTreeFromReactNode: jest.fn(), + parseReactElement, getDropPlaceholder: jest.fn(), + node: jest.fn().mockImplementation(() => ({ + isDraggable: jest.fn().mockImplementation(() => isDraggable), + })), }; store = { actions, query }; eventHandlers = new EventHandlers(store); @@ -58,12 +67,12 @@ describe("EventHandlers", () => { select.init()(); expect(actions.setNodeEvent).toHaveBeenCalledWith("selected", null); }); - it("should contain one event with mousedown", () => { + it("should contain one event with click", () => { expect(select.events).toHaveLength(1); - expect(getHandler(select.events, "mousedown")).toBeDefined(); + expect(getHandler(select.events, "click")).toBeDefined(); }); it("should call setNodeEvent on mousedown", () => { - callHandler(select.events, "mousedown")(null, nodeId); + callHandler(select.events, "click")(e, nodeId); expect(actions.setNodeEvent).toHaveBeenCalledWith("selected", nodeId); }); }); @@ -83,7 +92,7 @@ describe("EventHandlers", () => { expect(getHandler(hover.events, "mouseover")).toBeDefined(); }); it("should call setNodeEvent on mouseover", () => { - callHandler(hover.events, "mouseover")(null, nodeId); + callHandler(hover.events, "mouseover")(e, nodeId); expect(actions.setNodeEvent).toHaveBeenCalledWith("hovered", nodeId); }); }); @@ -172,15 +181,35 @@ describe("EventHandlers", () => { }); describe("init", () => { - beforeEach(() => { - drag.init(el)(); - }); - it("should call setAttribute twice on init", () => { - expect(el.setAttribute).toHaveBeenCalledTimes(2); + describe("when node can be dragged", () => { + beforeEach(() => { + isDraggable = true; + drag.init(el)(); + }); + it("should call setAttribute twice on init", () => { + expect(el.setAttribute).toHaveBeenCalledTimes(2); + }); + it("should call setAttribute with the right arguments", () => { + expect(el.setAttribute).toHaveBeenNthCalledWith( + 1, + "draggable", + "true" + ); + expect(el.setAttribute).toHaveBeenNthCalledWith( + 2, + "draggable", + "false" + ); + }); }); - it("should call setAttribute with the right arguments", () => { - expect(el.setAttribute).toHaveBeenNthCalledWith(1, "draggable", true); - expect(el.setAttribute).toHaveBeenNthCalledWith(2, "draggable", false); + describe("when node cannot be dragged", () => { + beforeEach(() => { + isDraggable = false; + drag.init(el)(); + }); + it("should not have called setAttribute", () => { + expect(el.setAttribute).toHaveBeenCalledTimes(0); + }); }); }); @@ -273,7 +302,7 @@ describe("EventHandlers", () => { expect(el.removeAttribute).toHaveBeenCalledTimes(1); }); it("should call setAttribute with the right arguments", () => { - expect(el.setAttribute).toHaveBeenNthCalledWith(1, "draggable", true); + expect(el.setAttribute).toHaveBeenNthCalledWith(1, "draggable", "true"); expect(el.removeAttribute).toHaveBeenNthCalledWith(1, "draggable"); }); }); @@ -281,15 +310,15 @@ describe("EventHandlers", () => { describe("dragstart", () => { const node = "a node"; beforeEach(() => { - query.parseTreeFromReactNode.mockImplementationOnce(() => node); + parsedNodeTree = node; callHandler(create.events, "dragstart")(e, nodeId); }); it("should have stopped propagation", () => { expect(e.stopImmediatePropagation).toHaveBeenCalled(); expect(e.stopPropagation).toHaveBeenCalled(); }); - it("should call parseTreeFromReactNode on mousedown", () => { - expect(query.parseTreeFromReactNode).toHaveBeenCalledWith(nodeId); + it("should call parseReactElement.toNodeTree on mousedown", () => { + expect(query.parseReactElement).toHaveBeenCalled(); }); it("should have called createShadow", () => { expect(createShadow).toHaveBeenCalled(); @@ -315,8 +344,8 @@ describe("EventHandlers", () => { expect(e.stopImmediatePropagation).not.toHaveBeenCalled(); expect(e.stopPropagation).toHaveBeenCalled(); }); - it("should have not call addTreeAtIndex", () => { - expect(actions.addTreeAtIndex).not.toHaveBeenCalled(); + it("should have not call addNodeTree", () => { + expect(actions.addNodeTree).not.toHaveBeenCalled(); }); }); @@ -334,8 +363,8 @@ describe("EventHandlers", () => { expect(EventHandlers.draggedElement).toBe(null); expect(EventHandlers.draggedElementShadow).toBe(undefined); }); - it("should have call addTreeAtIndex", () => { - expect(actions.addTreeAtIndex).toHaveBeenCalledWith( + it("should have call addNodeTree", () => { + expect(actions.addNodeTree).toHaveBeenCalledWith( nodeId, events.indicator.placement.parent.id, events.indicator.placement.index diff --git a/packages/core/src/hooks/useEditor.tsx b/packages/core/src/hooks/useEditor.tsx index e7851479f..27b8a50b5 100644 --- a/packages/core/src/hooks/useEditor.tsx +++ b/packages/core/src/hooks/useEditor.tsx @@ -13,7 +13,11 @@ export type useEditor = Overwrite< { actions: Delete< useInternalEditor["actions"], - "setNodeEvent" | "setDOM" | "replaceNodes" | "reset" + | "addLinkedNodeFromTree" + | "setNodeEvent" + | "setDOM" + | "replaceNodes" + | "reset" > & { selectNode: (nodeId: NodeId | null) => void; }; @@ -31,7 +35,14 @@ export function useEditor(collect: EditorCollector): useEditor; export function useEditor(collect?: any): useEditor { const { connectors, - actions: { setDOM, setNodeEvent, replaceNodes, reset, ...EditorActions }, + actions: { + addLinkedNodeFromTree, + setDOM, + setNodeEvent, + replaceNodes, + reset, + ...EditorActions + }, query: { deserialize, ...query }, store, ...collected diff --git a/packages/core/src/hooks/useNode.ts b/packages/core/src/hooks/useNode.ts index a601627aa..65e69c39b 100644 --- a/packages/core/src/hooks/useNode.ts +++ b/packages/core/src/hooks/useNode.ts @@ -1,7 +1,8 @@ import { Node } from "../interfaces"; import { useInternalNode } from "../nodes/useInternalNode"; +import { deprecationWarning } from "@craftjs/utils"; -export type useNode = Omit, "actions"> & +export type useNode = useInternalNode & Pick["actions"], "setProp">; export function useNode(): useNode; @@ -10,13 +11,13 @@ export function useNode(collect?: (node: Node) => S): useNode; /** * A Hook to that provides methods and state information related to the corresponding Node that manages the current component. - * @param collector Collector function to consume values from the corresponding Node's state + * @param collect - Collector function to consume values from the corresponding Node's state */ export function useNode(collect?: (node: Node) => S): useNode { const { id, related, - actions: { setProp }, + actions, inNodeContext, connectors, ...collected @@ -24,9 +25,15 @@ export function useNode(collect?: (node: Node) => S): useNode { return { ...(collected as any), + actions, id, related, - setProp, + setProp: (cb) => { + deprecationWarning("useNode().setProp()", { + suggest: "useNode().actions.setProp()", + }); + return actions.setProp(cb); + }, inNodeContext, connectors, }; diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index 4a11c6455..8f113e6e5 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -4,3 +4,4 @@ export * from "./interfaces"; export * from "./hooks"; export * from "./editor"; export * from "./events"; +export { ROOT_NODE } from "@craftjs/utils"; diff --git a/packages/core/src/interfaces/editor.ts b/packages/core/src/interfaces/editor.ts index 6e77c5dcb..2e38e7652 100644 --- a/packages/core/src/interfaces/editor.ts +++ b/packages/core/src/interfaces/editor.ts @@ -4,7 +4,7 @@ import { useInternalEditor } from "../editor/useInternalEditor"; export type Options = { onRender: React.ComponentType<{ render: React.ReactElement }>; - onStateChange: (Nodes) => any; + onNodesChange: (Nodes) => any; resolver: Resolver; enabled: boolean; indicator: Record<"success" | "error", string>; diff --git a/packages/core/src/interfaces/nodes.ts b/packages/core/src/interfaces/nodes.ts index 235a5e5ec..28f5219d4 100644 --- a/packages/core/src/interfaces/nodes.ts +++ b/packages/core/src/interfaces/nodes.ts @@ -3,9 +3,13 @@ import { QueryMethods } from "../editor/query"; import { QueryCallbacksFor } from "@craftjs/utils"; type UserComponentConfig = { - name: string; + displayName: string; rules: Partial; related: Partial; + props: Partial; + + // TODO: Deprecate + name: string; defaultProps: Partial; }; @@ -22,6 +26,7 @@ export type Node = { dom: HTMLElement; related: Record; rules: NodeRules; + _hydrationTimestamp: number; }; export type NodeHelpers = QueryCallbacksFor["node"]; @@ -40,12 +45,13 @@ export type NodeData = { name: string; displayName: string; isCanvas?: boolean; - parent: NodeId; + parent?: NodeId; index?: number; - _childCanvas?: Record; + linkedNodes?: Record; nodes?: NodeId[]; hidden: boolean; custom?: any; + _childCanvas?: Record; // TODO: Deprecate in favour of linkedNodes }; export type ReduceCompType = @@ -74,13 +80,13 @@ export type SerializedNodeData = SerializedNode; export type Nodes = Record; /** - * A tree is an internal data structure for CRUD operations that involve + * A NodeTree is an internal data structure for CRUD operations that involve * more than a single node. * * For example, when we drop a component we use a tree because we * need to drop more than a single component. */ -export interface Tree { +export interface NodeTree { rootNodeId: NodeId; nodes: Nodes; } diff --git a/packages/core/src/nodes/Canvas.tsx b/packages/core/src/nodes/Canvas.tsx index 703859fcc..4bce9dff8 100644 --- a/packages/core/src/nodes/Canvas.tsx +++ b/packages/core/src/nodes/Canvas.tsx @@ -1,141 +1,16 @@ -import React, { useState, useEffect } from "react"; -import { NodeId } from "../interfaces"; -import { mapChildrenToNodes } from "../utils/mapChildrenToNodes"; -import { useInternalNode } from "./useInternalNode"; -import { useInternalEditor } from "../editor/useInternalEditor"; -import { - ERROR_ROOT_CANVAS_NO_ID, - ERROR_INFINITE_CANVAS, - useEffectOnce, -} from "@craftjs/utils"; -import invariant from "tiny-invariant"; -import { SimpleElement } from "../render/SimpleElement"; -import { NodeElement } from "./NodeElement"; +import React, { useEffect } from "react"; +import { Element } from "./Element"; +import { deprecationWarning } from "@craftjs/utils"; -/** - * A React Component which defines a droppable region and draggable immediate children - */ -export type Canvas = { - id?: NodeId; - style?: any; - className?: any; - is?: T; - children?: React.ReactNode; - passThrough?: boolean; -} & React.ComponentProps; +export type Canvas = Element; -export function Canvas({ - is, - children, - passThrough, - ...props -}: Canvas) { - const id = props.id; - const { - actions: { add, setProp }, - query, - inContext, - } = useInternalEditor(); - const { node, inNodeContext } = useInternalNode((node) => ({ - node: { - id: node.id, - data: node.data, - }, - })); - const [internalId, setInternalId] = useState(null); - const [initialised, setInitialised] = useState(false); - - /** Only create/recreate nodes on the initial render. From there on, the re-renders will be handled by Nodes */ - useEffectOnce(() => { - const { id: nodeId, data } = node; - if (inContext && inNodeContext) { - if (data.isCanvas) { - invariant(passThrough, ERROR_INFINITE_CANVAS); - if (!data.nodes) { - const childNodes = mapChildrenToNodes(children, (jsx) => - query.parseNodeFromReactNode(jsx) - ); - - add(childNodes, nodeId); - } - } else { - invariant(id, ERROR_ROOT_CANVAS_NO_ID); - - let internalId; - - const existingNode = - data._childCanvas && - data._childCanvas[id] && - query.node(data._childCanvas[id]).get(); - - let newProps = { is, ...props }; - - if (existingNode) { - if (existingNode.data.type === is && typeof is !== "string") { - newProps = { - ...newProps, - ...existingNode.data.props, - }; - } - } - - const rootNode = query.parseNodeFromReactNode( - React.createElement(Canvas, newProps, children), - existingNode && { - id: existingNode.id, - data: existingNode.data, - } - ); - - internalId = rootNode.id; - add(rootNode, nodeId); - - setInternalId(internalId); - } - } - - setInitialised(true); +export const deprecateCanvasComponent = () => + deprecationWarning("", { + suggest: "", }); - /** - * - * (https://github.com/prevwong/craft.js/issues/31) - * When non-children props on Canvases in User Components are updated, we need to update the prop values in their corresponding Nodes - * in order to trigger a re-render - */ - useEffect(() => { - if (internalId) { - setProp(internalId, (nodeProps) => { - Object.entries(props).forEach(([key, value]) => { - nodeProps[key] = value; - }); - }); - } - }, [internalId, props, setProp]); +export function Canvas({ ...props }: Canvas) { + useEffect(() => deprecateCanvasComponent(), []); - return ( - - {initialised ? ( - inContext && inNodeContext ? ( - node.data.isCanvas && node.data.nodes ? ( - - {node.data.nodes.map((id: NodeId) => ( - - ))} - - )} - /> - ) : internalId ? ( - - ) : null - ) : ( - React.createElement(is as any, props, children) - ) - ) : null} - - ); + return ; } diff --git a/packages/core/src/nodes/Element.tsx b/packages/core/src/nodes/Element.tsx new file mode 100644 index 000000000..733314c04 --- /dev/null +++ b/packages/core/src/nodes/Element.tsx @@ -0,0 +1,98 @@ +import React, { useState } from "react"; +import { NodeId } from "../interfaces"; +import { useInternalNode } from "./useInternalNode"; +import { ERROR_TOP_LEVEL_ELEMENT_NO_ID, useEffectOnce } from "@craftjs/utils"; +import invariant from "tiny-invariant"; +import { useInternalEditor } from "../editor/useInternalEditor"; +import { NodeElement } from "./NodeElement"; + +export const defaultElementProps = { + is: "div", + canvas: false, + custom: {}, + hidden: false, +}; + +export const elementPropToNodeData = { + is: "type", + canvas: "isCanvas", +}; + +export type Element = { + id?: NodeId; + is?: T; + custom?: Record; + children?: React.ReactNode; + canvas?: boolean; +} & React.ComponentProps; + +export function Element({ + id, + children, + ...otherProps +}: Element) { + const props = { + ...defaultElementProps, + ...otherProps, + }; + + const { query, actions } = useInternalEditor(); + const { node, inNodeContext } = useInternalNode((node) => ({ + node: { + id: node.id, + data: node.data, + }, + })); + + const [internalId, setInternalId] = useState(null); + const [initialised, setInitialised] = useState(false); + + useEffectOnce(() => { + invariant(id !== null, ERROR_TOP_LEVEL_ELEMENT_NO_ID); + const { id: nodeId, data } = node; + + if (inNodeContext) { + let internalId, + newProps = props; + + const existingNode = + data.linkedNodes && + data.linkedNodes[id] && + query.node(data.linkedNodes[id]).get(); + + if ( + existingNode && + existingNode.data.type === props.is && + typeof props.is !== "string" + ) { + newProps = { + ...newProps, + ...existingNode.data.props, + }; + } + + const linkedElement = React.createElement(Element, newProps, children); + + const tree = query + .parseReactElement(linkedElement) + .toNodeTree((node, jsx) => { + if (jsx === linkedElement) { + node.id = existingNode ? existingNode.id : node.id; + node.data = { + ...(existingNode ? existingNode.data.props : {}), + ...node.data, + }; + } + }); + + internalId = tree.rootNodeId; + actions.addLinkedNodeFromTree(tree, nodeId, id); + + setInternalId(internalId); + } + + setInitialised(true); + }); + + return initialised ? : null; +} diff --git a/packages/core/src/nodes/NodeContext.tsx b/packages/core/src/nodes/NodeContext.tsx index e404f7783..f490c4830 100644 --- a/packages/core/src/nodes/NodeContext.tsx +++ b/packages/core/src/nodes/NodeContext.tsx @@ -1,21 +1,33 @@ -import React from "react"; +import React, { useMemo } from "react"; import { NodeId } from "../interfaces"; -import { NodeConnectors } from "./NodeHandlers"; +import { NodeHandlers } from "./NodeHandlers"; +import { useEventHandler } from "../events"; +import { useInternalEditor } from "../editor/useInternalEditor"; export const NodeContext = React.createContext(null); export type NodeProvider = { id: NodeId; related?: boolean; - connectors?: NodeConnectors; }; export const NodeProvider: React.FC = ({ id, related = false, - connectors, children, }) => { + const handlers = useEventHandler(); + + const { hydrationTimestamp } = useInternalEditor((state) => ({ + hydrationTimestamp: state.nodes[id] && state.nodes[id]._hydrationTimestamp, + })); + + // Get fresh connectors whenever the Nodes are rehydrated (eg: after deserialisation) + const connectors = useMemo(() => { + return handlers.derive(NodeHandlers, id).connectors(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handlers, hydrationTimestamp, id]); + return ( {children} diff --git a/packages/core/src/nodes/NodeElement.tsx b/packages/core/src/nodes/NodeElement.tsx index 37aca4694..00bf4c6f5 100644 --- a/packages/core/src/nodes/NodeElement.tsx +++ b/packages/core/src/nodes/NodeElement.tsx @@ -1,24 +1,15 @@ -import React, { useMemo } from "react"; +import React from "react"; import { NodeProvider } from "./NodeContext"; import { RenderNodeToElement } from "../render/RenderNode"; import { NodeId } from "../interfaces"; -import { NodeHandlers } from "./NodeHandlers"; -import { useEventHandler } from "../events"; export type NodeElement = { id: NodeId; }; export const NodeElement: React.FC = React.memo(({ id }) => { - const handlers = useEventHandler(); - - const connectors = useMemo( - () => handlers.derive(NodeHandlers, id).connectors(), - [handlers, id] - ); - return ( - + ); diff --git a/packages/core/src/nodes/index.ts b/packages/core/src/nodes/index.ts index aa7a46e7b..21057e330 100644 --- a/packages/core/src/nodes/index.ts +++ b/packages/core/src/nodes/index.ts @@ -1,2 +1,3 @@ export * from "./Canvas"; -export * from "./useNodeContext"; +export * from "./Element"; +export * from "./NodeContext"; diff --git a/packages/core/src/nodes/useInternalNode.ts b/packages/core/src/nodes/useInternalNode.ts index c882e8e51..95ea2d1be 100644 --- a/packages/core/src/nodes/useInternalNode.ts +++ b/packages/core/src/nodes/useInternalNode.ts @@ -1,17 +1,21 @@ -import { useMemo } from "react"; -import { NodeProvider } from "./NodeContext"; +import { useMemo, useContext } from "react"; +import { NodeContext, NodeProvider } from "./NodeContext"; import { Node } from "../interfaces"; import { useInternalEditor } from "../editor/useInternalEditor"; -import { useNodeContext } from "./useNodeContext"; +import { NodeConnectors } from "./NodeHandlers"; type internalActions = NodeProvider & { inNodeContext: boolean; + connectors: NodeConnectors; actions: { - setProp: (cb: any) => void; + setProp: (cb: (props: any) => void) => void; + setCustom: (cb: (custom: any) => void) => void; + setHidden: (bool: boolean) => void; }; }; +// TODO: Deprecate useInternalNode in favor of useNode export type useInternalNode = S extends null ? internalActions : S & internalActions; @@ -22,7 +26,7 @@ export function useInternalNode( export function useInternalNode( collect?: (node: Node) => S ): useInternalNode { - const context = useNodeContext(); + const context = useContext(NodeContext); const { id, related, connectors } = context; const { actions: EditorActions, query, ...collected } = useInternalEditor( @@ -32,6 +36,8 @@ export function useInternalNode( const actions = useMemo(() => { return { setProp: (cb: any) => EditorActions.setProp(id, cb), + setCustom: (cb: any) => EditorActions.setCustom(id, cb), + setHidden: (bool: boolean) => EditorActions.setHidden(id, bool), }; }, [EditorActions, id]); diff --git a/packages/core/src/nodes/useNodeContext.ts b/packages/core/src/nodes/useNodeContext.ts deleted file mode 100644 index 3d368900c..000000000 --- a/packages/core/src/nodes/useNodeContext.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useContext } from "react"; - -import { NodeContext } from "./NodeContext"; - -export const useNodeContext = () => useContext(NodeContext); diff --git a/packages/core/src/render/Frame.tsx b/packages/core/src/render/Frame.tsx index 7f64fa171..468405cb3 100644 --- a/packages/core/src/render/Frame.tsx +++ b/packages/core/src/render/Frame.tsx @@ -1,17 +1,12 @@ import React, { useEffect, useState, useRef } from "react"; import { NodeElement } from "../nodes/NodeElement"; -import { Canvas } from "../nodes/Canvas"; -import { ROOT_NODE, ERROR_FRAME_IMMEDIATE_NON_CANVAS } from "@craftjs/utils"; +import { deprecationWarning, ROOT_NODE } from "@craftjs/utils"; import { useInternalEditor } from "../editor/useInternalEditor"; -import invariant from "tiny-invariant"; -import { Nodes } from "../interfaces"; +import { SerializedNodes } from "../interfaces"; export type Frame = { - /** The initial document defined in a json string */ - nodes?: Nodes; json?: string; - // TODO(mat) this can be typed in nicer way - data?: any; + data?: string | SerializedNodes; }; /** @@ -22,29 +17,36 @@ export const Frame: React.FC = ({ children, json, data }) => { const [render, setRender] = useState(null); + if (!!json) { + deprecationWarning("", { + suggest: "", + }); + } + const initialState = useRef({ initialChildren: children, - initialData: data || (json && JSON.parse(json)), + initialData: data || json, }); useEffect(() => { - const { replaceNodes, deserialize } = actions; - const { parseNodeFromReactNode } = query; + const { deserialize } = actions; const { initialChildren, initialData } = initialState.current; if (initialData) { deserialize(initialData); } else if (initialChildren) { - const rootCanvas = React.Children.only( + const rootNode = React.Children.only( initialChildren ) as React.ReactElement; - invariant( - rootCanvas.type && rootCanvas.type === Canvas, - ERROR_FRAME_IMMEDIATE_NON_CANVAS - ); - const node = parseNodeFromReactNode(rootCanvas, { id: ROOT_NODE }); - replaceNodes({ [ROOT_NODE]: node }); + const node = query.parseReactElement(rootNode).toNodeTree((node, jsx) => { + if (jsx === rootNode) { + node.id = ROOT_NODE; + } + return node; + }); + + actions.addNodeTree(node); } setRender(); diff --git a/packages/core/src/render/RenderNode.tsx b/packages/core/src/render/RenderNode.tsx index ad01a7888..2cb544cf2 100644 --- a/packages/core/src/render/RenderNode.tsx +++ b/packages/core/src/render/RenderNode.tsx @@ -1,32 +1,42 @@ -import React from "react"; +import React, { useMemo } from "react"; import { useInternalEditor } from "../editor/useInternalEditor"; -import { useNode } from "../hooks/useNode"; -import { Canvas } from "../nodes/Canvas"; +import { NodeElement } from "../nodes/NodeElement"; import { SimpleElement } from "./SimpleElement"; - -const Render = (injectedProps) => { - const { type, props, isCanvas } = useNode((node) => ({ - type: node.data.type, - props: node.data.props, - isCanvas: node.data.isCanvas, - })); - - if (isCanvas) { - return ; - } - - const Component = type; - const render = ; - - if (typeof Component === "string") { - return ; - } - - return render; +import { NodeId } from "../interfaces"; +import { useInternalNode } from "../nodes/useInternalNode"; + +const Render = () => { + const { type, props, nodes, hydrationTimestamp } = useInternalNode( + (node) => ({ + type: node.data.type, + props: node.data.props, + nodes: node.data.nodes, + hydrationTimestamp: node._hydrationTimestamp, + }) + ); + + return useMemo(() => { + const render = React.createElement( + type, + props, + + {nodes + ? nodes.map((id: NodeId) => ) + : props && props.children} + + ); + + if (typeof type == "string") { + return ; + } + + return render; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [type, props, hydrationTimestamp, nodes]); }; -export const RenderNodeToElement: React.FC = (injectedProps) => { - const { hidden } = useNode((node) => ({ +export const RenderNodeToElement: React.FC = () => { + const { hidden } = useInternalNode((node) => ({ hidden: node.data.hidden, })); diff --git a/packages/core/src/render/tests/Frame.test.tsx b/packages/core/src/render/tests/Frame.test.tsx index 8be5c8252..ba444a49d 100644 --- a/packages/core/src/render/tests/Frame.test.tsx +++ b/packages/core/src/render/tests/Frame.test.tsx @@ -1,13 +1,8 @@ import React from "react"; import { mount } from "enzyme"; -import invariant from "tiny-invariant"; -import { ERROR_FRAME_IMMEDIATE_NON_CANVAS } from "@craftjs/utils"; - import { Frame } from "../Frame"; import { useInternalEditor } from "../../editor/useInternalEditor"; -const children =

a children

; - jest.mock("tiny-invariant"); jest.mock("../../editor/useInternalEditor"); jest.mock("../../nodes/NodeElement", () => ({ @@ -18,33 +13,14 @@ const mockEditor = useInternalEditor as jest.Mock; describe("", () => { const data = {}; - const json = JSON.stringify(data); let actions; let query; beforeEach(() => { - actions = { replaceNodes: jest.fn(), deserialize: jest.fn() }; - query = { parseNodeFromReactNode: jest.fn() }; + actions = { addTreeAtIndex: jest.fn(), deserialize: jest.fn() }; + query = { createNode: jest.fn(), parseTreeFromReactNode: jest.fn() }; mockEditor.mockImplementation(() => ({ actions, query })); }); - describe("When rendering a Frame with no Children and no Data", () => { - it("should throw an error if the children is not a canvas", () => { - mount({children}); - expect(invariant).toHaveBeenCalledWith( - false, - ERROR_FRAME_IMMEDIATE_NON_CANVAS - ); - }); - }); - - describe("When rendering using `json`", () => { - beforeEach(() => { - mount(); - }); - it("should parse json and call deserialize", () => { - expect(actions.deserialize).toHaveBeenCalledWith(JSON.parse(json)); - }); - }); describe("When rendering using `data`", () => { beforeEach(() => { diff --git a/packages/core/src/render/tests/RenderNode.test.tsx b/packages/core/src/render/tests/RenderNode.test.tsx index 60a1277f3..1bd7d8d0f 100644 --- a/packages/core/src/render/tests/RenderNode.test.tsx +++ b/packages/core/src/render/tests/RenderNode.test.tsx @@ -2,22 +2,26 @@ import React from "react"; import identity from "lodash/identity"; import { mount } from "enzyme"; -import { Canvas } from "../../nodes/Canvas"; import { NodeElement } from "../../nodes/NodeElement"; import { RenderNodeToElement } from "../RenderNode"; import { SimpleElement } from "../SimpleElement"; -import { Node } from "@craftjs/core"; -let node: { type: any; props?: any; hidden?: boolean }; -let onRender; +let nodeContext = { + id: 1, + connectors: { connect: identity, drag: identity }, +}; + +let node = {}; +let onRender = jest.fn(); jest.mock("../../editor/useInternalEditor", () => ({ useInternalEditor: () => ({ onRender }), })); -jest.mock("../../hooks/useNode", () => ({ - useNode: () => ({ + +jest.mock("../../nodes/useInternalNode", () => ({ + useInternalNode: () => ({ ...node, - connectors: { connect: identity, drag: identity }, + ...nodeContext, }), })); jest.mock("../../nodes/Canvas", () => ({ @@ -27,6 +31,10 @@ jest.mock("../../nodes/NodeElement", () => ({ NodeElement: () => null, })); +jest.mock("../SimpleElement", () => ({ + SimpleElement: () => null, +})); + describe("", () => { const injectedProps = { className: "hi", style: { fontSize: 18 } }; let component; @@ -60,9 +68,6 @@ describe("", () => { it("should have called onRender", () => { expect(onRender).toHaveBeenCalled(); }); - it("should contain the right props", () => { - expect(component.props()).toEqual({ ...props, ...injectedProps }); - }); }); describe("When the node has type and no nodes", () => { @@ -82,33 +87,37 @@ describe("", () => { it("should not contain a SimpleElement", () => { expect(component.find(SimpleElement)).toHaveLength(0); }); - it("should contain the right props", () => { - expect(component.props()).toEqual({ ...props, ...injectedProps }); - }); it("should contain a button", () => { expect(component.find("button")).toHaveLength(1); }); }); - describe("When the node is a canvas", () => { - const type = Canvas; + describe("When the node has type and contains nodes", () => { + const type = ({ children }) => ( +

+ - + - - + + @@ -320,21 +316,25 @@ export default function App() { ``` Every element that is rendered in `` is managed by an object in the editor's internal state called a `Node` which describes the element, its events, and props among other things. Whether an element is draggable or droppable or not depends on the type of `Node` that manages it. - + - If the `Node` is a Canvas, then it's droppable - If the `Node` is an immediate child of a Canvas, then it's draggable. +By default, every element inside the `` will have a Node automatically created for it; but using the `` component, we can configure certain aspects of the Node - for instance, defining a Canvas Node. + + ```jsx -// Explanation +... + - // Canvas Node of type Container, droppable + // Canvas Node of type Container, droppable // Node of type Card // Node of type Button, draggable // Node of type Text, draggable - // Canvas Node of type Text, droppable and draggable + // Canvas Node of type Text, droppable and draggable // Node of type Text, draggable - - + + ``` @@ -421,31 +421,36 @@ At this point, you could refresh the page and you would be able to drag stuff ar #### Defining Droppable regions -Of course, our Card component is supposed to have two droppable regions. Remember how `` defined a droppable region earlier in our application? We can do the same here insde our Card component. + +Of course, our Card component is supposed to have 2 droppable regions, which means we'll need 2 Canvas nodes. + +But hold up, you might be wondering how do we even create Canvas node inside a User Component? Remember the `` component that was used to **configure** a Node earlier in our application? Well, inside a User Component - it can be used to **create** a Node. + +> The nodes created via `` inside User Components are called linked Nodes. Read more [here](../concepts/user-components#defining-editable-elements) ```jsx {2,8,11,12,14} // components/user/Card.js -import {useNode, Canvas} from "@craftjs/core"; +import {useNode, Element} from "@craftjs/core"; export const Card = (({bg, padding})) => { return ( - + - - + +