From 8fa6f008d42291791a6d999422dacb3da4ef70e5 Mon Sep 17 00:00:00 2001 From: Michele Riccardo Esposito Date: Sat, 16 May 2020 15:31:05 +0100 Subject: [PATCH] Merge changes from candulabs/craft.js (#69) * 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: Improvements to PR-69 (#72) * Add parseNodeFromNodeData method in query, deprecate query.createNode (#18) * rename query.createNode to query.parseNodeFromReactNode * refactor transformJSXToNode and create node * some light refactoring for readibiliyt * try up the code * add a couple unit tests, fix some small bugs * fix fromEntries on node # Conflicts: # packages/core/src/render/Frame.tsx # packages/core/src/render/tests/Frame.test.tsx # packages/utils/src/tests/History.test.ts * fix: remove support for adding child nodes in non-Canvas This will be added when the Element component is introduced * fix: resetting parent nodes, re-added support for childCanvas * chore: move event() to utils * feat: PR improvements * chore: rename SerialisedNodeData * chore * nit Co-authored-by: Michele Riccardo Esposito Co-authored-by: Mateusz Drulis Co-authored-by: Prev Wong Co-authored-by: GitHub Action Co-authored-by: Prev Wong --- jest/setup.js | 7 +- package.json | 4 +- packages/core/src/__tests__/Canvas.spec.tsx | 61 --- packages/core/src/__tests__/README.md | 8 - .../core/src/__tests__/useEditor.spec.tsx | 33 -- packages/core/src/editor/Editor.tsx | 54 +++ packages/core/src/editor/actions.ts | 363 ++++++++++-------- packages/core/src/editor/index.tsx | 43 +-- packages/core/src/editor/query.tsx | 156 +++++--- packages/core/src/editor/store.tsx | 18 +- .../core/src/editor/tests/Editor.test.tsx | 40 ++ .../core/src/editor/tests/actions.test.ts | 200 ++++++++++ packages/core/src/editor/tests/query.test.tsx | 139 +++++++ packages/core/src/events/EventContext.ts | 4 +- packages/core/src/events/EventHandlers.ts | 235 +++++------- packages/core/src/events/Events.tsx | 36 ++ packages/core/src/events/createShadow.ts | 14 + packages/core/src/events/findPosition.ts | 2 +- packages/core/src/events/index.tsx | 39 +- .../src/events/tests/EventHandlers.test.ts | 347 +++++++++++++++++ .../core/src/hooks/tests/useEditor.test.tsx | 50 +++ packages/core/src/hooks/useEditor.tsx | 2 +- packages/core/src/interfaces/editor.ts | 1 + packages/core/src/interfaces/nodes.ts | 21 +- packages/core/src/nodes/Canvas.tsx | 18 +- packages/core/src/nodes/index.ts | 1 + packages/core/src/nodes/useInternalNode.ts | 8 +- packages/core/src/nodes/useNodeContext.ts | 5 + packages/core/src/render/Frame.tsx | 33 +- packages/core/src/render/RenderNode.tsx | 35 +- packages/core/src/render/tests/Frame.test.tsx | 57 +++ .../core/src/render/tests/RenderNode.test.tsx | 114 ++++++ packages/core/src/tests/fixtures.ts | 152 ++++++++ packages/core/src/utils/createNode.ts | 8 +- packages/core/src/utils/deserializeNode.tsx | 42 +- packages/core/src/utils/fromEntries.ts | 12 + packages/core/src/utils/getRandomNodeId.ts | 3 + packages/core/src/utils/mergeTrees.tsx | 30 ++ .../core/src/utils/parseNodeDataFromJSX.tsx | 26 ++ packages/core/src/utils/serializeNode.tsx | 14 +- .../utils/tests/parseNodeDataFromJSX.test.tsx | 46 +++ packages/core/src/utils/transformJSX.tsx | 32 -- packages/docs/docs/additional/layers.md | 2 +- packages/docs/docs/api/API.js | 51 ++- packages/docs/docs/overview.md | 2 +- packages/utils/src/Handlers.ts | 6 +- packages/utils/src/deprecate.ts | 21 + packages/utils/src/index.ts | 1 + yarn.lock | 298 +++++++++++++- 49 files changed, 2234 insertions(+), 660 deletions(-) delete mode 100644 packages/core/src/__tests__/Canvas.spec.tsx delete mode 100644 packages/core/src/__tests__/README.md delete mode 100644 packages/core/src/__tests__/useEditor.spec.tsx create mode 100644 packages/core/src/editor/Editor.tsx create mode 100644 packages/core/src/editor/tests/Editor.test.tsx create mode 100644 packages/core/src/editor/tests/actions.test.ts create mode 100644 packages/core/src/editor/tests/query.test.tsx create mode 100644 packages/core/src/events/Events.tsx create mode 100644 packages/core/src/events/createShadow.ts create mode 100644 packages/core/src/events/tests/EventHandlers.test.ts create mode 100644 packages/core/src/hooks/tests/useEditor.test.tsx create mode 100644 packages/core/src/nodes/useNodeContext.ts create mode 100644 packages/core/src/render/tests/Frame.test.tsx create mode 100644 packages/core/src/render/tests/RenderNode.test.tsx create mode 100644 packages/core/src/tests/fixtures.ts create mode 100644 packages/core/src/utils/fromEntries.ts create mode 100644 packages/core/src/utils/getRandomNodeId.ts create mode 100644 packages/core/src/utils/mergeTrees.tsx create mode 100644 packages/core/src/utils/parseNodeDataFromJSX.tsx create mode 100644 packages/core/src/utils/tests/parseNodeDataFromJSX.test.tsx delete mode 100644 packages/core/src/utils/transformJSX.tsx create mode 100644 packages/utils/src/deprecate.ts diff --git a/jest/setup.js b/jest/setup.js index 31b1fec4a..a420529b1 100644 --- a/jest/setup.js +++ b/jest/setup.js @@ -1,3 +1,4 @@ -beforeEach(() => { - console.error = jest.fn(); -}); +const Enzyme = require("enzyme"); +const Adapter = require("enzyme-adapter-react-16"); + +Enzyme.configure({ adapter: new Adapter() }); diff --git a/package.json b/package.json index 11ccc9e10..9211035aa 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "@typescript-eslint/parser": "^2.14.0", "babel-eslint": "^10.0.3", "cross-env": "^6.0.3", + "enzyme": "^3.11.0", + "enzyme-adapter-react-16": "^1.15.2", "eslint": "^6.8.0", "eslint-config-prettier": "^6.10.1", "eslint-config-react-app": "^5.1.0", @@ -83,7 +85,7 @@ "/packages/core/" ], "testMatch": [ - "/packages/core/src/**/__tests__/?(*.)spec.ts(x|)" + "/packages/core/src/**/?(*.)test.ts(x|)" ], "transform": { "^.+\\.(ts|tsx)$": "ts-jest" diff --git a/packages/core/src/__tests__/Canvas.spec.tsx b/packages/core/src/__tests__/Canvas.spec.tsx deleted file mode 100644 index f1f2768ba..000000000 --- a/packages/core/src/__tests__/Canvas.spec.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React, { useEffect } from "react"; -import { render } from "@testing-library/react"; -import { ERROR_ROOT_CANVAS_NO_ID } from "@craftjs/utils"; - -import { Editor } from "../editor"; -import { useEditor } from "../hooks"; -import { Canvas } from "../nodes/Canvas"; -import { Frame } from "../render/Frame"; - -describe("Canvas", () => { - const TestComponent = () => { - return ( -
- -

Hi

-
-
- ); - }; - - it("Converts nodes", async () => { - const nodes = await new Promise((resolve) => { - const Collector = () => { - const { nodes } = useEditor((state) => ({ nodes: state.nodes })); - useEffect(() => { - resolve(nodes); - }, [nodes]); - return null; - }; - render( - - - - -
-

Hi

-
-

Lol

-
- -
- ); - }); - - expect(nodes).not.toBeNull(); - }); - - xit("Throw error when id is ommited in Top-level Canvas", async () => { - expect(() => - render( - - - - - - - - ) - ).toThrowError(ERROR_ROOT_CANVAS_NO_ID); - }); -}); diff --git a/packages/core/src/__tests__/README.md b/packages/core/src/__tests__/README.md deleted file mode 100644 index aab3c11bb..000000000 --- a/packages/core/src/__tests__/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Tests (WIP) - -Currently being rewritten for the updated core. 🐵 🐒 - -Tooling - -- `jest` -- `react-testing-library` diff --git a/packages/core/src/__tests__/useEditor.spec.tsx b/packages/core/src/__tests__/useEditor.spec.tsx deleted file mode 100644 index d14653d1f..000000000 --- a/packages/core/src/__tests__/useEditor.spec.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React, { useEffect } from "react"; -import { Editor } from "../editor"; -import { render } from "@testing-library/react"; -import { useEditor } from "../hooks"; - -describe("useEditor", () => { - it("returns actions, query and collectors", () => { - expect(async () => { - await new Promise((resolve, reject) => { - const Collector = () => { - const { actions, query, connectors } = useEditor(); - useEffect(() => { - if (actions && query && connectors) { - resolve(); - } else { - reject(); - } - }, [actions, connectors, query]); - return null; - }; - - render( - - - - ); - }); - }).not.toThrowError(); - }); - - // TODO - it("Collects editor state", () => {}); -}); diff --git a/packages/core/src/editor/Editor.tsx b/packages/core/src/editor/Editor.tsx new file mode 100644 index 000000000..56f60c6e9 --- /dev/null +++ b/packages/core/src/editor/Editor.tsx @@ -0,0 +1,54 @@ +import React, { useEffect } from "react"; + +import { Options } from "../interfaces"; +import { Events } from "../events"; + +import { useEditorStore } from "./store"; +import { EditorContext } from "./EditorContext"; + +export const withDefaults = (options: Partial = {}) => ({ + onStateChange: () => null, + onRender: ({ render }) => render, + resolver: {}, + nodes: null, + enabled: true, + indicator: { + error: "red", + success: "rgb(98, 196, 98)", + }, + ...options, +}); + +/** + * A React Component that provides the Editor context + */ +export const Editor: React.FC> = ({ + children, + ...options +}) => { + const context = useEditorStore(withDefaults(options)); + + useEffect(() => { + if (context && options) + context.actions.setOptions((editorOptions) => { + editorOptions = options; + }); + }, [context, options]); + + useEffect(() => { + context.subscribe( + (_) => ({ + json: context.query.serialize(), + }), + ({ json }) => { + context.query.getOptions().onStateChange(JSON.parse(json)); + } + ); + }, [context]); + + return context ? ( + + {children} + + ) : null; +}; diff --git a/packages/core/src/editor/actions.ts b/packages/core/src/editor/actions.ts index 55794e618..25fe60662 100644 --- a/packages/core/src/editor/actions.ts +++ b/packages/core/src/editor/actions.ts @@ -1,122 +1,164 @@ import { + EditorState, + Indicator, NodeId, Node, Nodes, Options, NodeEvents, - SerializedNodeData, + Tree, + SerializedNodes, } from "../interfaces"; -import { EditorState, Indicator } from "../interfaces"; import { ERROR_INVALID_NODEID, - ERROR_ROOT_CANVAS_NO_ID, ROOT_NODE, - CallbacksFor, QueryCallbacksFor, ERROR_NOPARENT, + ERROR_ROOT_CANVAS_NO_ID, } from "@craftjs/utils"; import { QueryMethods } from "./query"; +import { fromEntries } from "../utils/fromEntries"; import { updateEventsNode } from "../utils/updateEventsNode"; import invariant from "tiny-invariant"; -import { deserializeNode } from "../utils/deserializeNode"; -import { createElement } from "react"; +import { editorInitialState } from "./store"; export const Actions = ( state: EditorState, query: QueryCallbacksFor ) => { - const _ = >(name: T) => - Actions(state, query)[name]; + /** 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 { + parent.data.nodes.splice(index, 0, node.id); + node.data.parent = parent.id; + } + + state.nodes[node.id] = node; + }; + + const getParentAndValidate = (parentId: NodeId): Node => { + invariant(parentId, ERROR_NOPARENT); + const parent = state.nodes[parentId]; + invariant(parent, ERROR_INVALID_NODEID); + return parent; + }; + return { - setOptions(cb: (options: Partial) => void) { - cb(state.options); - }, - setNodeEvent(eventType: NodeEvents, id: NodeId | null) { - const current = state.events[eventType]; - if (current && id !== current) { - state.nodes[current].events[eventType] = false; + /** + * Add a new Node(s) to the editor. + * + * @param nodes + * @param parentId + */ + add(nodes: Node[] | Node, parentId: NodeId) { + const parent = getParentAndValidate(parentId); + + if (!parent.data.nodes) { + parent.data.nodes = []; } - if (id) { - state.nodes[id].events[eventType] = true; - state.events[eventType] = id; - } else { - state.events[eventType] = null; + if (parent.data.props.children) { + delete parent.data.props["children"]; } + + const nodesToAdd = Array.isArray(nodes) ? nodes : [nodes]; + nodesToAdd.forEach((node, index) => + addNodeToParentAtIndex(node, parent, index) + ); }, - replaceNodes(nodes: Nodes) { - state.nodes = nodes; - }, - reset() { - state.nodes = {}; - state.events = { - dragged: null, - selected: null, - hovered: null, - indicator: null, - }; - }, - setDOM(id: NodeId, dom: HTMLElement) { - invariant(state.nodes[id], ERROR_INVALID_NODEID); - state.nodes[id].dom = dom; + + /** + * Given a Node, it adds it at the correct position among the node children + * + * @param node + * @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); }, - setIndicator(indicator: Indicator | null) { - if ( - indicator && - (!indicator.placement.parent.dom || - (indicator.placement.currentNode && - !indicator.placement.currentNode.dom)) - ) + + /** + * Given a tree, it adds it at the correct position among the node children + * + * @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" + ); + const node = tree.nodes[tree.rootNodeId]; + // first, add the node + this.addNodeAtIndex(node, parentId, index); + if (!node.data.nodes) { return; - state.events.indicator = indicator; + } + // then add all the children + const addChild = (childId, index) => + this.addTreeAtIndex( + { rootNodeId: childId, nodes: tree.nodes }, + node.id, + index + ); + + // we need to deep clone here... + const childToAdd = [...node.data.nodes]; + node.data.nodes = []; + childToAdd.forEach(addChild); }, + /** - * Add a new Node(s) to the editor - * @param nodes - * @param parentId + * Delete a Node + * @param id */ - add(nodes: Node[] | Node, parentId?: NodeId) { - const isCanvas = (node: Node | NodeId) => - node && - (typeof node === "string" - ? node.startsWith("canvas-") - : node.data.isCanvas); - - if (!Array.isArray(nodes)) nodes = [nodes]; - if (parentId && !state.nodes[parentId].data.nodes && isCanvas(parentId)) - state.nodes[parentId].data.nodes = []; - - (nodes as Node[]).forEach((node) => { - const parent = parentId ? parentId : node.data.parent; - invariant(parent !== null, ERROR_NOPARENT); - - const parentNode = state.nodes[parent!]; - - if (parentNode && isCanvas(node) && !isCanvas(parentNode)) { - invariant(node.data.props.id, ERROR_ROOT_CANVAS_NO_ID); - if (!parentNode.data._childCanvas) parentNode.data._childCanvas = {}; - node.data.parent = parentNode.id; - parentNode.data._childCanvas[node.data.props.id] = node.id; - } else { - if (parentId) { - if (parentNode.data.props.children) - delete parentNode.data.props["children"]; - - if (!parentNode.data.nodes) parentNode.data.nodes = []; - const currentNodes = parentNode.data.nodes; - currentNodes.splice( - node.data.index !== undefined - ? node.data.index - : currentNodes.length, - 0, - node.id - ); - node.data.parent = parent; - } - } - state.nodes[node.id] = node; - }); + delete(id: NodeId) { + invariant(id !== ROOT_NODE, "Cannot delete Root 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!; + parentChildren.splice(parentChildren.indexOf(id), 1); + + updateEventsNode(state, id, true); + delete state.nodes[id]; + }, + + deserialize(input: SerializedNodes | string) { + const dehydratedNodes = + typeof input == "string" ? JSON.parse(input) : input; + + const nodePairs = Object.keys(dehydratedNodes).map((id) => [ + id, + query.parseNodeFromSerializedNode(dehydratedNodes[id], id), + ]); + + this.replaceNodes(fromEntries(nodePairs)); }, + /** * Move a target Node to a new Parent at a given index * @param targetId @@ -138,50 +180,91 @@ export const Actions = ( currentParentNodes[currentParentNodes.indexOf(targetId)] = "marked"; - if (newParentNodes) newParentNodes.splice(index, 0, targetId); - else newParent.data.nodes = [targetId]; + if (newParentNodes) { + newParentNodes.splice(index, 0, targetId); + } else { + newParent.data.nodes = [targetId]; + } state.nodes[targetId].data.parent = newParentId; state.nodes[targetId].data.index = index; currentParentNodes.splice(currentParentNodes.indexOf("marked"), 1); }, + + replaceNodes(nodes: Nodes) { + state.nodes = nodes; + this.clearEvents(); + }, + + clearEvents() { + state.events = editorInitialState.events; + }, + /** - * Delete a Node - * @param id + * Resets all the editor state. */ - delete(id: NodeId) { - invariant(id !== ROOT_NODE, "Cannot delete Root node"); - const targetNode = state.nodes[id]; - if (query.node(targetNode.id).isCanvas()) { - invariant( - !query.node(targetNode.id).isTopLevelCanvas(), - "Cannot delete a Canvas that is not a direct child of another Canvas" - ); - if (targetNode.data.nodes) { - [...targetNode.data.nodes].forEach((childId) => { - _("delete")(childId); - }); - } - } + reset() { + this.replaceNodes({}); + this.clearEvents(); + }, + + /** + * Set editor options via a callback function + * + * @param cb: function used to set the options. + */ + setOptions(cb: (options: Partial) => void) { + cb(state.options); + }, - const parentNode = state.nodes[targetNode.data.parent], - parentChildNodesId = parentNode.data.nodes!; + setNodeEvent(eventType: NodeEvents, id: NodeId | null) { + const current = state.events[eventType]; + if (current && id !== current) { + state.nodes[current].events[eventType] = false; + } - if (parentNode && parentChildNodesId.indexOf(id) > -1) { - parentChildNodesId.splice(parentChildNodesId.indexOf(id), 1); + if (id) { + state.nodes[id].events[eventType] = true; + state.events[eventType] = id; + } else { + state.events[eventType] = null; } - updateEventsNode(state, id, true); - delete state.nodes[id]; }, + /** - * Update the props of a Node + * Set custom values to a Node * @param id * @param cb */ - setProp(id: NodeId, cb: (props: any) => void) { + setCustom( + id: T, + cb: (data: EditorState["nodes"][T]["data"]["custom"]) => void + ) { + cb(state.nodes[id].data.custom); + }, + + /** + * Given a `id`, it will set the `dom` porperty of that node. + * + * @param id of the node we want to set + * @param dom + */ + setDOM(id: NodeId, dom: HTMLElement) { invariant(state.nodes[id], ERROR_INVALID_NODEID); - cb(state.nodes[id].data.props); + state.nodes[id].dom = dom; }, + + setIndicator(indicator: Indicator | null) { + if ( + indicator && + (!indicator.placement.parent.dom || + (indicator.placement.currentNode && + !indicator.placement.currentNode.dom)) + ) + return; + state.events.indicator = indicator; + }, + /** * Hide a Node * @param id @@ -190,57 +273,15 @@ export const Actions = ( setHidden(id: NodeId, bool: boolean) { state.nodes[id].data.hidden = bool; }, + /** - * Set custom values to a Node + * Update the props of a Node * @param id * @param cb */ - setCustom( - id: T, - cb: (data: EditorState["nodes"][T]["data"]["custom"]) => void - ) { - cb(state.nodes[id].data.custom); - }, - deserialize(json: string) { - const reducedNodes: Record = JSON.parse(json); - const rehydratedNodes = Object.keys(reducedNodes).reduce( - (accum: Nodes, id) => { - const { - type: Comp, - props, - parent, - nodes, - _childCanvas, - isCanvas, - hidden, - custom, - } = deserializeNode(reducedNodes[id], state.options.resolver); - - if (!Comp) return accum; - - accum[id] = query.createNode(createElement(Comp, props), { - id, - data: { - ...(isCanvas && { isCanvas }), - ...(hidden && { hidden }), - parent, - ...(isCanvas && { nodes }), - ...(_childCanvas && { _childCanvas }), - custom, - }, - }); - return accum; - }, - {} - ); - - state.events = { - dragged: null, - selected: null, - hovered: null, - indicator: null, - }; - state.nodes = rehydratedNodes; + setProp(id: NodeId, cb: (props: any) => void) { + invariant(state.nodes[id], ERROR_INVALID_NODEID); + cb(state.nodes[id].data.props); }, }; }; diff --git a/packages/core/src/editor/index.tsx b/packages/core/src/editor/index.tsx index 95e8e70e2..ca69f6ed2 100644 --- a/packages/core/src/editor/index.tsx +++ b/packages/core/src/editor/index.tsx @@ -1,42 +1 @@ -import React, { useEffect } from "react"; -import { Options } from "../interfaces"; -import { useEditorStore } from "../editor/store"; -import { EditorContext } from "./EditorContext"; -import { Events } from "../events"; - -export const createEditorStoreOptions = (options: Partial = {}) => { - return { - onRender: ({ render }) => render, - resolver: {}, - nodes: null, - enabled: true, - indicator: { - error: "red", - success: "rgb(98, 196, 98)", - }, - ...options, - }; -}; - -/** - * A React Component that provides the Editor context - */ -export const Editor: React.FC> = ({ - children, - ...options -}) => { - const context = useEditorStore(createEditorStoreOptions(options)); - - useEffect(() => { - if (context && options) - context.actions.setOptions((editorOptions) => { - editorOptions = options; - }); - }, [context, options]); - - return context ? ( - - {children} - - ) : null; -}; +export * from "./Editor"; diff --git a/packages/core/src/editor/query.tsx b/packages/core/src/editor/query.tsx index 669114052..866f1bdca 100644 --- a/packages/core/src/editor/query.tsx +++ b/packages/core/src/editor/query.tsx @@ -6,9 +6,10 @@ import { Node, Options, NodeInfo, + Tree, + SerializedNodes, + SerializedNode, } from "../interfaces"; -import { serializeNode } from "../utils/serializeNode"; -import { resolveComponent } from "../utils/resolveComponent"; import invariant from "tiny-invariant"; import { QueryCallbacksFor, @@ -25,62 +26,86 @@ import { ERROR_MOVE_TOP_LEVEL_CANVAS, ERROR_MOVE_ROOT_NODE, ERROR_INVALID_NODE_ID, + deprecationWarning, } from "@craftjs/utils"; import findPosition from "../events/findPosition"; +import { createNode } from "../utils/createNode"; +import { fromEntries } from "../utils/fromEntries"; +import { mergeTrees } from "../utils/mergeTrees"; import { getDeepNodes } from "../utils/getDeepNodes"; -import { transformJSXToNode } from "../utils/transformJSX"; +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"; -export function QueryMethods(Editor: EditorState) { - const options = Editor && Editor.options; +export function QueryMethods(state: EditorState) { + const options = state && state.options; const _: () => QueryCallbacksFor = () => - QueryMethods(Editor); + QueryMethods(state); const getNodeFromIdOrNode = (node: NodeId | Node) => - typeof node === "string" ? Editor.nodes[node] : node; + typeof node === "string" ? state.nodes[node] : node; return { /** - * Get the current Editor options + * @deprecated + * Get a Node representing the specified React Element + * @param reactElement + * @param extras */ - getOptions(): Options { - return options; + createNode(reactElement: React.ReactElement | string, extras?: any): Node { + deprecationWarning("query.createNode()", { + suggest: "query.parseNodeFromReactNode()", + }); + return this.parseNodeFromReactNode(reactElement, extras); }, + /** - * Get a Node representing the specified React Element - * @param child - * @param 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 */ - createNode(child: React.ReactElement | string, extras?: any) { - const node = transformJSXToNode(child, extras); + 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 - ? node.data.displayName - : name; - + node.data.displayName = node.data.displayName || name; node.data.name = name; + return node; }, - /** - * Retrieve the JSON representation of the editor's Nodes - */ - serialize(): string { - const simplifiedNodes = Object.keys(Editor.nodes).reduce( - (result: any, id: NodeId) => { - const { - data: { ...data }, - } = Editor.nodes[id]; - result[id] = serializeNode({ ...data }, options.resolver); - return result; - }, - {} - ); - const json = JSON.stringify(simplifiedNodes); + 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 json; + return mergeTrees(node, childrenNodes); }, + /** * Determine the best possible location to drop the source Node relative to the target Node */ @@ -89,17 +114,16 @@ export function QueryMethods(Editor: EditorState) { target: NodeId, pos: { x: number; y: number }, nodesToDOM: (node: Node) => HTMLElement = (node) => - Editor.nodes[node.id].dom + state.nodes[node.id].dom ) => { if (source === target) return; - const sourceNodeFromId = - typeof source == "string" && Editor.nodes[source], - targetNode = Editor.nodes[target], + const sourceNodeFromId = typeof source == "string" && state.nodes[source], + targetNode = state.nodes[target], isTargetCanvas = _().node(targetNode.id).isCanvas(); const targetParent = isTargetCanvas ? targetNode - : Editor.nodes[targetNode.data.parent]; + : state.nodes[targetNode.data.parent]; const targetParentNodes = targetParent.data._childCanvas ? Object.values(targetParent.data._childCanvas) @@ -107,7 +131,7 @@ export function QueryMethods(Editor: EditorState) { const dimensionsInContainer = targetParentNodes ? targetParentNodes.reduce((result, id: NodeId) => { - const dom = nodesToDOM(Editor.nodes[id]); + const dom = nodesToDOM(state.nodes[id]); if (dom) { const info: NodeInfo = { id, @@ -126,9 +150,9 @@ export function QueryMethods(Editor: EditorState) { pos.x, pos.y ); - const currentNode = targetParentNodes.length - ? Editor.nodes[targetParentNodes[dropAction.index]] - : null; + const currentNode = + targetParentNodes.length && + state.nodes[targetParentNodes[dropAction.index]]; const output: Indicator = { placement: { @@ -152,6 +176,14 @@ export function QueryMethods(Editor: EditorState) { return output; }, + + /** + * Get the current Editor options + */ + getOptions(): Options { + return options; + }, + /** * Helper methods to describe the specified Node * @param id @@ -159,7 +191,7 @@ export function QueryMethods(Editor: EditorState) { node(id: NodeId) { invariant(typeof id == "string", ERROR_INVALID_NODE_ID); - const node = Editor.nodes[id]; + const node = state.nodes[id]; const nodeQuery = _().node; return { @@ -182,7 +214,7 @@ export function QueryMethods(Editor: EditorState) { return result; }, decendants: (deep = false) => { - return getDeepNodes(Editor.nodes, id, deep); + return getDeepNodes(state.nodes, id, deep); }, isDraggable: (onError?: (err: string) => void) => { try { @@ -212,12 +244,12 @@ export function QueryMethods(Editor: EditorState) { const targetNode = getNodeFromIdOrNode(target); const currentParentNode = - targetNode.data.parent && Editor.nodes[targetNode.data.parent], + targetNode.data.parent && state.nodes[targetNode.data.parent], newParentNode = node; invariant( currentParentNode || - (!currentParentNode && !Editor.nodes[targetNode.id]), + (!currentParentNode && !state.nodes[targetNode.id]), ERROR_DUPLICATE_NODEID ); @@ -256,7 +288,35 @@ export function QueryMethods(Editor: EditorState) { 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); + }, + + /** + * Returns all the `nodes` in a serialized format + */ + getSerializedNodes(): SerializedNodes { + const nodePairs = Object.keys(state.nodes).map((id: NodeId) => [ + id, + this.parseSerializedNodeFromNode(state.nodes[id]), + ]); + return fromEntries(nodePairs); + }, + + /** + * Retrieve the JSON representation of the editor's Nodes + */ + serialize(): string { + return JSON.stringify(this.getSerializedNodes()); + }, }; } diff --git a/packages/core/src/editor/store.tsx b/packages/core/src/editor/store.tsx index 35aa40dae..e37c67dfd 100644 --- a/packages/core/src/editor/store.tsx +++ b/packages/core/src/editor/store.tsx @@ -4,17 +4,21 @@ import { QueryMethods } from "./query"; export type EditorStore = SubscriberAndCallbacksFor; +export const editorInitialState = { + nodes: {}, + events: { + dragged: null, + selected: null, + hovered: null, + indicator: null, + }, +}; + export const useEditorStore = (options): EditorStore => { return useMethods( Actions, { - nodes: {}, - events: { - selected: null, - dragged: null, - hovered: null, - indicator: null, - }, + ...editorInitialState, options, }, QueryMethods diff --git a/packages/core/src/editor/tests/Editor.test.tsx b/packages/core/src/editor/tests/Editor.test.tsx new file mode 100644 index 000000000..734c3f115 --- /dev/null +++ b/packages/core/src/editor/tests/Editor.test.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { act } from "react-dom/test-utils"; + +import { EditorContext } from "../EditorContext"; +import { Editor } from "../Editor"; +import { Events } from "../../events"; +import { useEditorStore } from "../store"; + +jest.mock("../store"); +const mockStore = useEditorStore as jest.Mock; + +describe("", () => { + const children =

a children

; + let actions; + let component; + let query; + let onStateChange; + + beforeEach(() => { + React.useEffect = (f) => f(); + + query = { serialize: jest.fn().mockImplementation(() => "{}") }; + onStateChange = jest.fn(); + mockStore.mockImplementation((value) => ({ ...value, query, actions })); + act(() => { + component = shallow( + {children} + ); + }); + }); + it("should render the children with events", () => { + expect(component.contains({children})).toBe(true); + }); + it("should render the EditorContext.Provider", () => { + expect(component.find(EditorContext.Provider)).toHaveLength(1); + }); + + // TODO: use react-testing-library to test hook-related code +}); diff --git a/packages/core/src/editor/tests/actions.test.ts b/packages/core/src/editor/tests/actions.test.ts new file mode 100644 index 000000000..e24e9591b --- /dev/null +++ b/packages/core/src/editor/tests/actions.test.ts @@ -0,0 +1,200 @@ +import cloneDeep from "lodash/cloneDeep"; +import mapValues from "lodash/mapValues"; +import * as actions from "../actions"; +import { produce } from "immer"; +import { QueryMethods } from "../../editor/query"; +import { + card, + documentState, + documentWithButtonsState, + documentWithCardState, + documentWithLeafState, + emptyState, + leafNode, + primaryButton, + rootNode, + secondaryButton, +} from "../../tests/fixtures"; +import { EditorState } from "@craftjs/core"; + +const Actions = (state) => (cb) => + produce(state, (draft) => + cb(actions.Actions(draft as any, QueryMethods(state))) + ); + +describe("actions.add", () => { + it("should throw if we give a parentId that doesnt exist", () => { + expect(() => + Actions(emptyState)((actions) => actions.add(leafNode)) + ).toThrow(); + }); + it("should throw if we create a node that doesnt have a parent and we dont provide a parent ", () => { + expect(() => + Actions(emptyState)((actions) => actions.add(rootNode, rootNode.id)) + ).toThrow(); + }); + it("should be able to add leaft 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) + ); + + expect(newState).toEqual(documentWithButtonsState); + }); +}); + +describe("actions.addNodeAtIndex", () => { + it("should throw if we give a parentId that doesnt exist", () => { + expect(() => + Actions(emptyState)((actions) => actions.addNodeAtIndex(leafNode)) + ).toThrow(); + }); + it("should throw if we give an invalid index", () => { + const state = Actions(documentState); + expect(() => + state((actions) => actions.addNodeAtIndex(leafNode, rootNode.id, -1)) + ).toThrow(); + expect(() => + state((actions) => actions.addNodeAtIndex(leafNode, rootNode.id, 1)) + ).toThrow(); + }); + it("should be able to add the node at 0", () => { + const newState = Actions(documentState)((actions) => + actions.addNodeAtIndex(leafNode, rootNode.id, 0) + ); + expect(newState).toEqual(documentWithLeafState); + }); +}); + +describe("actions.addTreeAtIndex", () => { + it("should throw if we give a parentId that doesnt exist", () => { + expect(() => + Actions(emptyState)((actions) => actions.addTreeAtIndex(leafNode)) + ).toThrow(); + }); + it("should throw if we give an invalid index", () => { + const state = Actions(documentState); + expect(() => + state((actions) => actions.addTreeAtIndex(leafNode, rootNode.id, -1)) + ).toThrow(); + expect(() => + state((actions) => actions.addTreeAtIndex(leafNode, rootNode.id, 1)) + ).toThrow(); + }); + it("should be able to add a single node at 0", () => { + const tree = { + rootNodeId: leafNode.id, + nodes: { [leafNode.id]: leafNode }, + }; + const newState = Actions(documentState)((actions) => + actions.addTreeAtIndex(tree, rootNode.id, 0) + ); + expect(newState).toEqual(documentWithLeafState); + }); + it("should be able to add a larger tree", () => { + const tree = { + rootNodeId: card.id, + nodes: cloneDeep(documentWithCardState.nodes), + }; + const newState = Actions(documentState)((actions) => + actions.addTreeAtIndex(tree, rootNode.id, 0) + ); + expect(newState).toEqual(documentWithCardState); + }); +}); + +describe("actions.delete", () => { + it("should throw if you try to a non existing node", () => { + expect(() => Actions(emptyState)((actions) => actions.delete(leafNode.id))); + }); + it("should throw if you try to delete the root", () => { + expect(() => Actions(documentState)((actions) => actions.add(rootNode.id))); + }); + it("should be able to delete leaf from the document", () => { + const newState = Actions(documentWithLeafState)((actions) => + actions.delete(leafNode.id) + ); + + expect(newState).toEqual(documentState); + }); + it("should be able to delete a card", () => { + const newState = Actions(documentWithCardState)((actions) => + actions.delete(card.id) + ); + + expect(newState).toEqual(documentState); + }); +}); + +describe("actions.clearEvents", () => { + const newEvents = { ...emptyState.events }; + it("should be able to reset the events", () => { + const newState = Actions(emptyState)((actions) => actions.clearEvents()); + + expect(newState).toEqual({ ...emptyState, events: newEvents }); + }); +}); + +describe("actions.replaceNodes", () => { + it("should be able to replace the nodes", () => { + const newState = Actions(emptyState)((actions) => + actions.replaceNodes(documentState.nodes) + ); + + expect(newState).toEqual(documentState); + }); +}); + +describe("actions.reset", () => { + it("should reset the entire state", () => { + const newState = Actions(documentState)((actions) => actions.reset()); + + expect(newState).toEqual(emptyState); + }); +}); + +describe("actions.deserialize", () => { + const serialized = mapValues(documentState.nodes, ({ data }) => ({ + type: {}, + ...data, + })); + + it("should be able to set the state correctly", () => { + const newState = Actions(emptyState)((actions) => + 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", + }, + }; + expect(newState.nodes).toEqual(nodes); + }); +}); diff --git a/packages/core/src/editor/tests/query.test.tsx b/packages/core/src/editor/tests/query.test.tsx new file mode 100644 index 000000000..6231f631e --- /dev/null +++ b/packages/core/src/editor/tests/query.test.tsx @@ -0,0 +1,139 @@ +import React from "react"; +import { resolveComponent } from "../../utils/resolveComponent"; +import { QueryMethods } from "../query"; +import { + rootNode, + card, + primaryButton, + secondaryButton, + documentWithCardState, +} from "../../tests/fixtures"; + +jest.mock("../../utils/resolveComponent", () => ({ + resolveComponent: () => null, +})); +jest.mock("../../utils/parseNodeDataFromJSX", () => ({ + parseNodeDataFromJSX: () => ({ ...rootNode.data, type: "div" }), +})); + +describe("query", () => { + const resolver = { H1: () => null }; + let query; + let state; + + beforeEach(() => { + state = { options: { resolver } }; + query = QueryMethods(state); + }); + + describe("parseNodeFromReactNode", () => { + const extras = { id: 1 }; + const node =

Hello

; + const name = "Document"; + const nodeData = { ...rootNode.data, type: "div" }; + + describe("when we can resolve the type", () => { + beforeEach(() => { + resolveComponent = jest.fn().mockImplementation(() => name); + query.parseNodeFromReactNode(node, extras); + }); + it("should have called the resolveComponent", () => { + expect(resolveComponent).toHaveBeenCalledWith( + state.options.resolver, + nodeData.type + ); + }); + 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(); + }); + }); + }); + + describe("parseTreeFromReactNode", () => { + let tree; + beforeEach(() => { + query.parseNodeFromReactNode = jest + .fn() + .mockImplementation(() => rootNode); + }); + + describe("when there is a single node with no children", () => { + 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, + }); + }); + }); + }); +}); diff --git a/packages/core/src/events/EventContext.ts b/packages/core/src/events/EventContext.ts index 7982c96f3..b9e175456 100644 --- a/packages/core/src/events/EventContext.ts +++ b/packages/core/src/events/EventContext.ts @@ -3,6 +3,4 @@ import { EventHandlers } from "./EventHandlers"; export const EventHandlerContext = createContext(null); -export const useEventHandler = () => { - return useContext(EventHandlerContext); -}; +export const useEventHandler = () => useContext(EventHandlerContext); diff --git a/packages/core/src/events/EventHandlers.ts b/packages/core/src/events/EventHandlers.ts index 4016b8d7e..4d8944d08 100644 --- a/packages/core/src/events/EventHandlers.ts +++ b/packages/core/src/events/EventHandlers.ts @@ -1,9 +1,16 @@ -import { NodeId, Node, Indicator } from "../interfaces"; -import { Handlers, ConnectorsForHandlers } from "@craftjs/utils"; +import { createShadow } from "./createShadow"; +import { Indicator, NodeId, Tree } from "../interfaces"; +import { + ConnectorsForHandlers, + defineEventListener, + Handlers, +} from "@craftjs/utils"; import { debounce } from "debounce"; import { EditorStore } from "../editor/store"; -type DraggedElement = NodeId | Node; +type DraggedElement = NodeId | Tree; + +const rapidDebounce = (f) => debounce(f, 1); /** * Specifies Editor-wide event handlers and connectors @@ -16,141 +23,131 @@ export class EventHandlers extends Handlers< static events: { indicator: Indicator }; handlers() { - let handlers = { + return { select: { - init: () => { - return () => { - this.store.actions.setNodeEvent("selected", null); - }; - }, + init: () => () => this.store.actions.setNodeEvent("selected", null), events: [ - [ + defineEventListener( "mousedown", - debounce((_, id: NodeId) => { - this.store.actions.setNodeEvent("selected", id); - }, 1), - true, - ], + rapidDebounce((_, id: NodeId) => + this.store.actions.setNodeEvent("selected", id) + ), + true + ), ], }, hover: { - init: () => { - return () => { - this.store.actions.setNodeEvent("hovered", null); - }; - }, + init: () => () => this.store.actions.setNodeEvent("hovered", null), events: [ - [ + defineEventListener( "mouseover", - debounce((_, id: NodeId) => { - this.store.actions.setNodeEvent("hovered", id); - }, 1), - true, - ], + rapidDebounce((_, id: NodeId) => + this.store.actions.setNodeEvent("hovered", id) + ), + true + ), ], }, drop: { events: [ - [ - "dragover", - (e: MouseEvent, id: NodeId) => { - e.preventDefault(); - e.stopPropagation(); - }, - ], - [ + defineEventListener("dragover", (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }), + defineEventListener( "dragenter", - (e: MouseEvent, id: NodeId) => { + (e: MouseEvent, targetId: NodeId) => { e.preventDefault(); e.stopPropagation(); - if (!EventHandlers.draggedElement) return; + const draggedElement = EventHandlers.draggedElement as Tree; + if (!draggedElement) { + return; + } - const getPlaceholder = this.store.query.getDropPlaceholder( - EventHandlers.draggedElement, - id, - { - x: e.clientX, - y: e.clientY, - } + const node = draggedElement.rootNodeId + ? draggedElement.nodes[draggedElement.rootNodeId] + : draggedElement; + const { clientX: x, clientY: y } = e; + const indicator = this.store.query.getDropPlaceholder( + node, + targetId, + { x, y } ); - if (getPlaceholder) { - this.store.actions.setIndicator(getPlaceholder); - EventHandlers.events = { - indicator: getPlaceholder, - }; + if (!indicator) { + return; } - }, - ], + this.store.actions.setIndicator(indicator); + EventHandlers.events = { indicator }; + } + ), ], }, drag: { - init: (node) => { - node.setAttribute("draggable", true); - return () => { - node.setAttribute("draggable", false); - }; + init: (el) => { + el.setAttribute("draggable", true); + return () => el.setAttribute("draggable", false); }, events: [ - [ - "dragstart", - (e: DragEvent, id: NodeId) => { - e.stopPropagation(); - e.stopImmediatePropagation(); - this.store.actions.setNodeEvent("dragged", id); - EventHandlers.createShadow(e, id); - }, - ], - [ - "dragend", - (e: DragEvent) => { - e.stopPropagation(); - this.dropElement((draggedElement, placement) => { - this.store.actions.move( - draggedElement as NodeId, - placement.parent.id, - placement.index + (placement.where === "after" ? 1 : 0) - ); - }); - }, - ], + defineEventListener("dragstart", (e: DragEvent, id: NodeId) => { + e.stopPropagation(); + e.stopImmediatePropagation(); + + this.store.actions.setNodeEvent("dragged", id); + + EventHandlers.draggedElementShadow = createShadow(e); + EventHandlers.draggedElement = id; + }), + defineEventListener("dragend", (e: DragEvent) => { + e.stopPropagation(); + + const onDropElement = (draggedElement, placement) => + this.store.actions.move( + draggedElement as NodeId, + placement.parent.id, + placement.index + (placement.where === "after" ? 1 : 0) + ); + this.dropElement(onDropElement); + }), ], }, create: { init: (el) => { el.setAttribute("draggable", true); - return () => { - el.removeAttribute("draggable"); - }; + return () => el.removeAttribute("draggable"); }, events: [ - [ + defineEventListener( "dragstart", - (e: DragEvent, userElement: React.ElementType) => { + (e: DragEvent, userElement: React.ReactElement) => { e.stopPropagation(); e.stopImmediatePropagation(); - const node = this.store.query.createNode(userElement); - EventHandlers.createShadow(e, node); - }, - ], - [ - "dragend", - (e: DragEvent) => { - e.stopPropagation(); - this.dropElement((draggedElement, placement) => { - (draggedElement as Node).data.index = - placement.index + (placement.where === "after" ? 1 : 0); - this.store.actions.add(draggedElement, placement.parent.id); - }); - }, - ], + + const tree = this.store.query.parseTreeFromReactNode(userElement); + + EventHandlers.draggedElementShadow = createShadow(e); + EventHandlers.draggedElement = tree; + } + ), + defineEventListener("dragend", (e: DragEvent) => { + e.stopPropagation(); + + const onDropElement = (draggedElement, placement) => { + const index = + placement.index + (placement.where === "after" ? 1 : 0); + this.store.actions.addTreeAtIndex( + draggedElement, + placement.parent.id, + index + ); + }; + this.dropElement(onDropElement); + }), ], }, }; - - return handlers; } private dropElement( @@ -159,20 +156,14 @@ export class EventHandlers extends Handlers< placement: Indicator["placement"] ) => void ) { - const events = EventHandlers.events; - if ( - EventHandlers.draggedElement && - events.indicator && - !events.indicator.error - ) { + const { draggedElement, draggedElementShadow, events } = EventHandlers; + if (draggedElement && events.indicator && !events.indicator.error) { const { placement } = events.indicator; - onDropNode(EventHandlers.draggedElement, placement); + onDropNode(draggedElement, placement); } - if (EventHandlers.draggedElementShadow) { - EventHandlers.draggedElementShadow.parentNode.removeChild( - EventHandlers.draggedElementShadow - ); + if (draggedElementShadow) { + draggedElementShadow.parentNode.removeChild(draggedElementShadow); EventHandlers.draggedElementShadow = null; } @@ -181,33 +172,16 @@ export class EventHandlers extends Handlers< this.store.actions.setNodeEvent("dragged", null); } - static createShadow(e: DragEvent, node?: DraggedElement) { - const shadow = (e.target as HTMLElement).cloneNode(true) as HTMLElement; - const { width, height } = (e.target as HTMLElement).getBoundingClientRect(); - shadow.style.width = `${width}px`; - shadow.style.height = `${height}px`; - shadow.style.position = "fixed"; - shadow.style.left = "-100%"; - shadow.style.top = "-100%"; - - document.body.appendChild(shadow); - e.dataTransfer.setDragImage(shadow, 0, 0); - - EventHandlers.draggedElementShadow = shadow; - EventHandlers.draggedElement = node; - } - /** * Create a new instance of Handlers with reference to the current EventHandlers * @param type A class that extends DerivedEventHandlers - * @param args Additional arguements to pass to the constructor + * @param args Additional arguments to pass to the constructor */ derive, U extends any[]>( type: { new (store: EditorStore, derived: EventHandlers, ...args: U): T }, ...args: U ): T { - const derivedHandler = new type(this.store, this, ...args); - return derivedHandler; + return new type(this.store, this, ...args); } } @@ -218,7 +192,8 @@ export abstract class DerivedEventHandlers extends Handlers< T > { derived: EventHandlers; - constructor(store: EditorStore, derived: EventHandlers) { + + protected constructor(store: EditorStore, derived: EventHandlers) { super(store); this.derived = derived; } diff --git a/packages/core/src/events/Events.tsx b/packages/core/src/events/Events.tsx new file mode 100644 index 000000000..c56d557cf --- /dev/null +++ b/packages/core/src/events/Events.tsx @@ -0,0 +1,36 @@ +import React, { useMemo } from "react"; +import { useInternalEditor } from "../editor/useInternalEditor"; +import { RenderIndicator, getDOMInfo } from "@craftjs/utils"; +import movePlaceholder from "./movePlaceholder"; +import { EventHandlers } from "./EventHandlers"; +import { EventHandlerContext } from "./EventContext"; + +export const Events: React.FC = ({ children }) => { + const { events, indicator, store } = useInternalEditor((state) => ({ + events: state.events, + indicator: state.options.indicator, + })); + + const handler = useMemo(() => new EventHandlers(store), [store]); + + return ( + + {events.indicator && + React.createElement(RenderIndicator, { + style: { + ...movePlaceholder( + events.indicator.placement, + getDOMInfo(events.indicator.placement.parent.dom), + events.indicator.placement.currentNode && + getDOMInfo(events.indicator.placement.currentNode.dom) + ), + backgroundColor: events.indicator.error + ? indicator.error + : indicator.success, + transition: "0.2s ease-in", + }, + })} + {children} + + ); +}; diff --git a/packages/core/src/events/createShadow.ts b/packages/core/src/events/createShadow.ts new file mode 100644 index 000000000..d09f41d90 --- /dev/null +++ b/packages/core/src/events/createShadow.ts @@ -0,0 +1,14 @@ +export const createShadow = (e: DragEvent) => { + const shadow = (e.target as HTMLElement).cloneNode(true) as HTMLElement; + const { width, height } = (e.target as HTMLElement).getBoundingClientRect(); + shadow.style.width = `${width}px`; + shadow.style.height = `${height}px`; + shadow.style.position = "fixed"; + shadow.style.left = "-100%"; + shadow.style.top = "-100%"; + + document.body.appendChild(shadow); + e.dataTransfer.setDragImage(shadow, 0, 0); + + return shadow; +}; diff --git a/packages/core/src/events/findPosition.ts b/packages/core/src/events/findPosition.ts index 19c13ab25..75046533b 100644 --- a/packages/core/src/events/findPosition.ts +++ b/packages/core/src/events/findPosition.ts @@ -21,7 +21,7 @@ export default function findPosition( dimDown = 0; // Each dim is: Top, Left, Height, Width - for (var i = 0, len = dims.length; i < len; i++) { + for (let i = 0, len = dims.length; i < len; i++) { const dim = dims[i]; // Right position of the element. Left + Width diff --git a/packages/core/src/events/index.tsx b/packages/core/src/events/index.tsx index 8b5a803f0..5a17ec436 100644 --- a/packages/core/src/events/index.tsx +++ b/packages/core/src/events/index.tsx @@ -1,40 +1,3 @@ -import React, { useMemo } from "react"; -import { useInternalEditor } from "../editor/useInternalEditor"; -import { RenderIndicator, getDOMInfo } from "@craftjs/utils"; -import movePlaceholder from "./movePlaceholder"; -import { EventHandlers } from "./EventHandlers"; -import { EventHandlerContext } from "./EventContext"; export { useEventHandler } from "./EventContext"; export { DerivedEventHandlers } from "./EventHandlers"; - -export const Events: React.FC = ({ children }) => { - const { events, indicator, store } = useInternalEditor((state) => ({ - events: state.events, - indicator: state.options.indicator, - })); - - const handler = useMemo(() => new EventHandlers(store), [store]); - - return ( - - {events.indicator - ? React.createElement(RenderIndicator, { - style: { - ...movePlaceholder( - events.indicator.placement, - getDOMInfo(events.indicator.placement.parent.dom), - events.indicator.placement.currentNode - ? getDOMInfo(events.indicator.placement.currentNode.dom) - : null - ), - backgroundColor: events.indicator.error - ? indicator.error - : indicator.success, - transition: "0.2s ease-in", - }, - }) - : null} - {children} - - ); -}; +export { Events } from "./Events"; diff --git a/packages/core/src/events/tests/EventHandlers.test.ts b/packages/core/src/events/tests/EventHandlers.test.ts new file mode 100644 index 000000000..93f35dd67 --- /dev/null +++ b/packages/core/src/events/tests/EventHandlers.test.ts @@ -0,0 +1,347 @@ +import { EventHandlers } from "../EventHandlers"; +import { createShadow } from "../createShadow"; + +jest.mock("debounce", () => ({ + debounce: (f) => (...args) => f(...args), +})); +jest.mock("../createShadow", () => ({ + createShadow: () => null, +})); + +describe("EventHandlers", () => { + const nodeId = "3901"; + const getHandler = (events, name) => + events.find(([eventName]) => name === eventName); + const callHandler = (events, name) => getHandler(events, name)[1]; + const shadow = "a shadow"; + + let e; + let eventHandlers; + let store; + let actions; + let query; + + beforeEach(() => { + e = { + preventDefault: jest.fn(), + stopImmediatePropagation: jest.fn(), + stopPropagation: jest.fn(), + }; + + createShadow = jest.fn().mockImplementation(() => shadow); + + EventHandlers.draggedElement = undefined; + EventHandlers.draggedElementShadow = undefined; + EventHandlers.events = undefined; + + actions = { + addTreeAtIndex: jest.fn(), + move: jest.fn(), + setIndicator: jest.fn(), + setNodeEvent: jest.fn(), + }; + query = { + parseTreeFromReactNode: jest.fn(), + getDropPlaceholder: jest.fn(), + }; + store = { actions, query }; + eventHandlers = new EventHandlers(store); + }); + + describe("handlers.select", () => { + let select; + + beforeEach(() => { + select = eventHandlers.handlers().select; + }); + it("should call setNodeEvent on init", () => { + select.init()(); + expect(actions.setNodeEvent).toHaveBeenCalledWith("selected", null); + }); + it("should contain one event with mousedown", () => { + expect(select.events).toHaveLength(1); + expect(getHandler(select.events, "mousedown")).toBeDefined(); + }); + it("should call setNodeEvent on mousedown", () => { + callHandler(select.events, "mousedown")(null, nodeId); + expect(actions.setNodeEvent).toHaveBeenCalledWith("selected", nodeId); + }); + }); + + describe("handlers.hover", () => { + let hover; + + beforeEach(() => { + hover = eventHandlers.handlers().hover; + }); + it("should call setNodeEvent on init", () => { + hover.init()(); + expect(actions.setNodeEvent).toHaveBeenCalledWith("hovered", null); + }); + it("should contain one event with mousedown", () => { + expect(hover.events).toHaveLength(1); + expect(getHandler(hover.events, "mouseover")).toBeDefined(); + }); + it("should call setNodeEvent on mouseover", () => { + callHandler(hover.events, "mouseover")(null, nodeId); + expect(actions.setNodeEvent).toHaveBeenCalledWith("hovered", nodeId); + }); + }); + + describe("handlers.drop", () => { + let drop; + + beforeEach(() => { + e = { preventDefault: jest.fn(), stopPropagation: jest.fn() }; + drop = eventHandlers.handlers().drop; + }); + it("should contain two events, dragover and dragenter", () => { + expect(drop.events).toHaveLength(2); + expect(getHandler(drop.events, "dragover")).toBeDefined(); + expect(getHandler(drop.events, "dragenter")).toBeDefined(); + }); + it("should have prevented default on dragover", () => { + callHandler(drop.events, "dragover")(e); + expect(e.preventDefault).toHaveBeenCalled(); + expect(e.stopPropagation).toHaveBeenCalled(); + expect(actions.setIndicator).not.toHaveBeenCalled(); + }); + + describe("dragenter with no dragged element", () => { + beforeEach(() => { + callHandler(drop.events, "dragenter")(e); + }); + it("should have prevented default", () => { + expect(e.preventDefault).toHaveBeenCalled(); + expect(e.stopPropagation).toHaveBeenCalled(); + }); + it("should have not called set indicator or getDropPlacehalder", () => { + expect(actions.setIndicator).not.toHaveBeenCalled(); + expect(query.getDropPlaceholder).not.toHaveBeenCalled(); + }); + it("should not have set the indicator", () => { + expect(actions.setIndicator).not.toHaveBeenCalled(); + }); + }); + + describe("dragenter with a dragged element and a placeholder", () => { + const coordinates = { x: 130, y: 310 }; + const draggedElement = "an element"; + const indicator = "an indicator"; + + beforeEach(() => { + EventHandlers.draggedElement = draggedElement; + query.getDropPlaceholder.mockImplementationOnce(() => indicator); + e.clientY = coordinates.y; + e.clientX = coordinates.x; + callHandler(drop.events, "dragenter")(e, nodeId); + }); + it("should have prevented default", () => { + expect(e.preventDefault).toHaveBeenCalled(); + expect(e.stopPropagation).toHaveBeenCalled(); + }); + it("should have called getdropPlaceholder with the right arguments", () => { + expect(query.getDropPlaceholder).toHaveBeenCalledWith( + draggedElement, + nodeId, + coordinates + ); + }); + it("should have called set indicator or getDropPlacehalder", () => { + expect(actions.setIndicator).toHaveBeenCalledWith(indicator); + }); + it("should have set EventHandlers.evenst", () => { + expect(EventHandlers.events).toEqual({ indicator }); + }); + }); + }); + + describe("handlers.drag", () => { + const shadow = "a shadow"; + let drag; + let el; + + beforeEach(() => { + drag = eventHandlers.handlers().drag; + el = { setAttribute: jest.fn() }; + }); + it("should contain two events, dragover and dragenter", () => { + expect(drag.events).toHaveLength(2); + expect(getHandler(drag.events, "dragstart")).toBeDefined(); + expect(getHandler(drag.events, "dragend")).toBeDefined(); + }); + + describe("init", () => { + beforeEach(() => { + 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); + }); + }); + + describe("dragstart", () => { + beforeEach(() => { + callHandler(drag.events, "dragstart")(e, nodeId); + }); + it("should have stopped propagation", () => { + expect(e.stopImmediatePropagation).toHaveBeenCalled(); + expect(e.stopPropagation).toHaveBeenCalled(); + }); + it("should call setNodeEvent on mousedown", () => { + expect(actions.setNodeEvent).toHaveBeenCalledWith("dragged", nodeId); + }); + it("should have called createShadow", () => { + expect(createShadow).toHaveBeenCalled(); + }); + it("should have set the correct dragged elements", () => { + expect(EventHandlers.draggedElement).toEqual(nodeId); + expect(EventHandlers.draggedElementShadow).toEqual(shadow); + }); + }); + + describe("dragend", () => { + const events = { + indicator: { + placement: { parent: { id: 1 }, where: "after", index: 1 }, + }, + }; + + describe("if there are no elements or events", () => { + beforeEach(() => { + callHandler(drag.events, "dragend")(e, nodeId); + }); + it("should have stopped propagation", () => { + expect(e.stopImmediatePropagation).not.toHaveBeenCalled(); + expect(e.stopPropagation).toHaveBeenCalled(); + }); + it("should have not call move", () => { + expect(actions.move).not.toHaveBeenCalled(); + }); + }); + + describe("if there are all the events", () => { + beforeEach(() => { + EventHandlers.events = events; + EventHandlers.draggedElement = nodeId; + callHandler(drag.events, "dragend")(e, nodeId); + }); + it("should have called the right actions", () => { + expect(actions.setIndicator).toHaveBeenCalledWith(null); + expect(actions.setNodeEvent).toHaveBeenCalledWith("dragged", null); + }); + it("should have reset all the variables", () => { + expect(EventHandlers.draggedElement).toBe(null); + expect(EventHandlers.draggedElementShadow).toBe(undefined); + }); + it("should have call move", () => { + expect(actions.move).toHaveBeenCalledWith( + nodeId, + events.indicator.placement.parent.id, + 2 + ); + }); + }); + }); + }); + + describe("handlers.create", () => { + let create; + let el; + + beforeEach(() => { + createShadow = jest.fn().mockImplementation(() => shadow); + create = eventHandlers.handlers().create; + el = { setAttribute: jest.fn(), removeAttribute: jest.fn() }; + }); + it("should contain two events, dragover and dragenter", () => { + expect(create.events).toHaveLength(2); + expect(getHandler(create.events, "dragstart")).toBeDefined(); + expect(getHandler(create.events, "dragend")).toBeDefined(); + }); + + describe("init", () => { + beforeEach(() => { + create.init(el)(); + }); + it("should call setAttribute twice on init", () => { + expect(el.setAttribute).toHaveBeenCalledTimes(1); + expect(el.removeAttribute).toHaveBeenCalledTimes(1); + }); + it("should call setAttribute with the right arguments", () => { + expect(el.setAttribute).toHaveBeenNthCalledWith(1, "draggable", true); + expect(el.removeAttribute).toHaveBeenNthCalledWith(1, "draggable"); + }); + }); + + describe("dragstart", () => { + const node = "a node"; + beforeEach(() => { + query.parseTreeFromReactNode.mockImplementationOnce(() => 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 have called createShadow", () => { + expect(createShadow).toHaveBeenCalled(); + }); + it("should have set the correct dragged elements", () => { + expect(EventHandlers.draggedElement).toEqual(node); + expect(EventHandlers.draggedElementShadow).toEqual(shadow); + }); + }); + + describe("dragend", () => { + const events = { + indicator: { + placement: { parent: { id: 1 }, where: "before", index: 1 }, + }, + }; + + describe("if there are no elements or events", () => { + beforeEach(() => { + callHandler(create.events, "dragend")(e, nodeId); + }); + it("should have stopped propagation", () => { + expect(e.stopImmediatePropagation).not.toHaveBeenCalled(); + expect(e.stopPropagation).toHaveBeenCalled(); + }); + it("should have not call addTreeAtIndex", () => { + expect(actions.addTreeAtIndex).not.toHaveBeenCalled(); + }); + }); + + describe("if there are all the events", () => { + beforeEach(() => { + EventHandlers.events = events; + EventHandlers.draggedElement = nodeId; + callHandler(create.events, "dragend")(e, nodeId); + }); + it("should have called the right actions", () => { + expect(actions.setIndicator).toHaveBeenCalledWith(null); + expect(actions.setNodeEvent).toHaveBeenCalledWith("dragged", null); + }); + it("should have reset all the variables", () => { + expect(EventHandlers.draggedElement).toBe(null); + expect(EventHandlers.draggedElementShadow).toBe(undefined); + }); + it("should have call addTreeAtIndex", () => { + expect(actions.addTreeAtIndex).toHaveBeenCalledWith( + nodeId, + events.indicator.placement.parent.id, + events.indicator.placement.index + ); + }); + }); + }); + }); +}); diff --git a/packages/core/src/hooks/tests/useEditor.test.tsx b/packages/core/src/hooks/tests/useEditor.test.tsx new file mode 100644 index 000000000..9a502ec0d --- /dev/null +++ b/packages/core/src/hooks/tests/useEditor.test.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { useEditor } from "../useEditor"; + +import { useInternalEditor } from "../../editor/useInternalEditor"; + +jest.mock("../../editor/useInternalEditor"); +const internalEditorMock = useInternalEditor as jest.Mock; + +describe("useEditor", () => { + const otherActions = { one: "one" }; + const actions = { + setDOM: "setDOM", + setNodeEvent: "setNodeEvent", + replaceNodes: "replaceNodes", + reset: "reset", + ...otherActions, + }; + const otherQueries = { another: "query" }; + const query = { deserialize: "deserialize", ...otherQueries }; + const state = { + aRandomValue: "aRandomValue", + connectors: "one", + actions, + query, + store: {}, + }; + let collect; + let editor; + + beforeEach(() => { + React.useMemo = (f) => f(); + + internalEditorMock.mockImplementation(() => state); + collect = jest.fn(); + editor = useEditor(collect); + }); + it("should have called internal state with collect", () => { + expect(useInternalEditor).toHaveBeenCalledWith(collect); + }); + it("should return the correct editor", () => { + expect(editor).toEqual( + expect.objectContaining({ + actions: { ...otherActions, selectNode: expect.any(Function) }, + connectors: state.connectors, + query: otherQueries, + aRandomValue: state.aRandomValue, + }) + ); + }); +}); diff --git a/packages/core/src/hooks/useEditor.tsx b/packages/core/src/hooks/useEditor.tsx index 7acd6ee20..e7851479f 100644 --- a/packages/core/src/hooks/useEditor.tsx +++ b/packages/core/src/hooks/useEditor.tsx @@ -48,7 +48,7 @@ export function useEditor(collect?: any): useEditor { }, [EditorActions, setNodeEvent]); return { - connectors: connectors, + connectors, actions, query, ...(collected as any), diff --git a/packages/core/src/interfaces/editor.ts b/packages/core/src/interfaces/editor.ts index 193d3d76c..6e77c5dcb 100644 --- a/packages/core/src/interfaces/editor.ts +++ b/packages/core/src/interfaces/editor.ts @@ -4,6 +4,7 @@ import { useInternalEditor } from "../editor/useInternalEditor"; export type Options = { onRender: React.ComponentType<{ render: React.ReactElement }>; + onStateChange: (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 f17293f55..235a5e5ec 100644 --- a/packages/core/src/interfaces/nodes.ts +++ b/packages/core/src/interfaces/nodes.ts @@ -26,7 +26,6 @@ export type Node = { export type NodeHelpers = QueryCallbacksFor["node"]; export type NodeEvents = "selected" | "dragged" | "hovered"; -export type InternalNode = Pick & NodeData; export type NodeRefEvent = Record; export type NodeRules = { canDrag(node: Node, helpers: NodeHelpers): boolean; @@ -61,11 +60,27 @@ export type ReducedComp = { props: any; }; -export type SerializedNodeData = Omit< +export type SerializedNode = Omit< NodeData, "type" | "subtype" | "name" | "event" > & ReducedComp; +export type SerializedNodes = Record; + +// TODO: Deprecate in favor of SerializedNode +export type SerializedNodeData = SerializedNode; + export type Nodes = Record; -export type TreeNode = Node & { children?: any }; + +/** + * A tree 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 { + rootNodeId: NodeId; + nodes: Nodes; +} diff --git a/packages/core/src/nodes/Canvas.tsx b/packages/core/src/nodes/Canvas.tsx index e6184fc3c..703859fcc 100644 --- a/packages/core/src/nodes/Canvas.tsx +++ b/packages/core/src/nodes/Canvas.tsx @@ -52,10 +52,9 @@ export function Canvas({ if (data.isCanvas) { invariant(passThrough, ERROR_INFINITE_CANVAS); if (!data.nodes) { - const childNodes = mapChildrenToNodes(children, (jsx) => { - const node = query.createNode(jsx); - return node; - }); + const childNodes = mapChildrenToNodes(children, (jsx) => + query.parseNodeFromReactNode(jsx) + ); add(childNodes, nodeId); } @@ -80,7 +79,7 @@ export function Canvas({ } } - const rootNode = query.createNode( + const rootNode = query.parseNodeFromReactNode( React.createElement(Canvas, newProps, children), existingNode && { id: existingNode.id, @@ -124,10 +123,9 @@ export function Canvas({ node.data.type, props, - {node.data.nodes && - node.data.nodes.map((id: NodeId) => ( - - ))} + {node.data.nodes.map((id: NodeId) => ( + + ))} )} /> @@ -141,5 +139,3 @@ export function Canvas({ ); } - -// Canvas.name = 'Canvas' diff --git a/packages/core/src/nodes/index.ts b/packages/core/src/nodes/index.ts index 90dc254d4..aa7a46e7b 100644 --- a/packages/core/src/nodes/index.ts +++ b/packages/core/src/nodes/index.ts @@ -1 +1,2 @@ export * from "./Canvas"; +export * from "./useNodeContext"; diff --git a/packages/core/src/nodes/useInternalNode.ts b/packages/core/src/nodes/useInternalNode.ts index 196beb035..c882e8e51 100644 --- a/packages/core/src/nodes/useInternalNode.ts +++ b/packages/core/src/nodes/useInternalNode.ts @@ -1,8 +1,10 @@ -import { useContext, useMemo } from "react"; -import { NodeContext, NodeProvider } from "./NodeContext"; +import { useMemo } from "react"; +import { NodeProvider } from "./NodeContext"; import { Node } from "../interfaces"; import { useInternalEditor } from "../editor/useInternalEditor"; +import { useNodeContext } from "./useNodeContext"; + type internalActions = NodeProvider & { inNodeContext: boolean; actions: { @@ -20,7 +22,7 @@ export function useInternalNode( export function useInternalNode( collect?: (node: Node) => S ): useInternalNode { - const context = useContext(NodeContext); + const context = useNodeContext(); const { id, related, connectors } = context; const { actions: EditorActions, query, ...collected } = useInternalEditor( diff --git a/packages/core/src/nodes/useNodeContext.ts b/packages/core/src/nodes/useNodeContext.ts new file mode 100644 index 000000000..3d368900c --- /dev/null +++ b/packages/core/src/nodes/useNodeContext.ts @@ -0,0 +1,5 @@ +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 2237ee6fc..7f64fa171 100644 --- a/packages/core/src/render/Frame.tsx +++ b/packages/core/src/render/Frame.tsx @@ -4,42 +4,47 @@ import { Canvas } from "../nodes/Canvas"; import { ROOT_NODE, ERROR_FRAME_IMMEDIATE_NON_CANVAS } from "@craftjs/utils"; import { useInternalEditor } from "../editor/useInternalEditor"; import invariant from "tiny-invariant"; +import { Nodes } 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; }; /** * A React Component that defines the editable area */ -export const Frame: React.FC = ({ children, json }) => { +export const Frame: React.FC = ({ children, json, data }) => { const { actions, query } = useInternalEditor(); const [render, setRender] = useState(null); - const initialProps = useRef({ + const initialState = useRef({ initialChildren: children, - initialJson: json, + initialData: data || (json && JSON.parse(json)), }); useEffect(() => { const { replaceNodes, deserialize } = actions; - const { createNode } = query; - - const { - initialChildren: children, - initialJson: json, - } = initialProps.current; - if (!json) { - const rootCanvas = React.Children.only(children) as React.ReactElement; + const { parseNodeFromReactNode } = query; + const { initialChildren, initialData } = initialState.current; + + if (initialData) { + deserialize(initialData); + } else if (initialChildren) { + const rootCanvas = React.Children.only( + initialChildren + ) as React.ReactElement; + invariant( rootCanvas.type && rootCanvas.type === Canvas, ERROR_FRAME_IMMEDIATE_NON_CANVAS ); - const node = createNode(rootCanvas, { id: ROOT_NODE }); + const node = parseNodeFromReactNode(rootCanvas, { id: ROOT_NODE }); replaceNodes({ [ROOT_NODE]: node }); - } else { - deserialize(json); } setRender(); diff --git a/packages/core/src/render/RenderNode.tsx b/packages/core/src/render/RenderNode.tsx index fb3bf64b8..ad01a7888 100644 --- a/packages/core/src/render/RenderNode.tsx +++ b/packages/core/src/render/RenderNode.tsx @@ -1,14 +1,32 @@ import React from "react"; +import { useInternalEditor } from "../editor/useInternalEditor"; import { useNode } from "../hooks/useNode"; import { Canvas } from "../nodes/Canvas"; -import { useInternalEditor } from "../editor/useInternalEditor"; import { SimpleElement } from "./SimpleElement"; -export const RenderNodeToElement: React.FC = ({ ...injectedProps }) => { - const { type, props, isCanvas, hidden } = useNode((node) => ({ +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; +}; + +export const RenderNodeToElement: React.FC = (injectedProps) => { + const { hidden } = useNode((node) => ({ hidden: node.data.hidden, })); @@ -16,11 +34,10 @@ export const RenderNodeToElement: React.FC = ({ ...injectedProps }) => { onRender: state.options.onRender, })); - let Comp = isCanvas ? Canvas : type; - let render = React.cloneElement(); - if (typeof Comp === "string") render = ; - else if (Comp === Canvas) - render = React.cloneElement(render, { passThrough: true }); + // don't display the node since it's hidden + if (hidden) { + return null; + } - return !hidden ? React.createElement(onRender, { render }, null) : null; + return React.createElement(onRender, { render: }); }; diff --git a/packages/core/src/render/tests/Frame.test.tsx b/packages/core/src/render/tests/Frame.test.tsx new file mode 100644 index 000000000..8be5c8252 --- /dev/null +++ b/packages/core/src/render/tests/Frame.test.tsx @@ -0,0 +1,57 @@ +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", () => ({ + NodeElement: () => null, +})); + +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() }; + 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(() => { + mount(); + }); + it("should deserialize the nodes", () => { + expect(actions.deserialize).toHaveBeenCalledWith(data); + }); + }); +}); diff --git a/packages/core/src/render/tests/RenderNode.test.tsx b/packages/core/src/render/tests/RenderNode.test.tsx new file mode 100644 index 000000000..60a1277f3 --- /dev/null +++ b/packages/core/src/render/tests/RenderNode.test.tsx @@ -0,0 +1,114 @@ +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; + +jest.mock("../../editor/useInternalEditor", () => ({ + useInternalEditor: () => ({ onRender }), +})); +jest.mock("../../hooks/useNode", () => ({ + useNode: () => ({ + ...node, + connectors: { connect: identity, drag: identity }, + }), +})); +jest.mock("../../nodes/Canvas", () => ({ + Canvas: () => null, +})); +jest.mock("../../nodes/NodeElement", () => ({ + NodeElement: () => null, +})); + +describe("", () => { + const injectedProps = { className: "hi", style: { fontSize: 18 } }; + let component; + + beforeEach(() => { + onRender = jest.fn().mockImplementation(({ render }) => render); + }); + + describe("When the node is hidden", () => { + beforeEach(() => { + node = { hidden: true, type: jest.fn() }; + component = mount(); + }); + it("should not have called onRender", () => { + expect(onRender).not.toHaveBeenCalled(); + }); + it("should not have called type", () => { + expect(node.type).not.toHaveBeenCalled(); + }); + }); + + describe("When the component is a simple component", () => { + const props = { className: "hello" }; + beforeEach(() => { + node = { type: "h1", props }; + component = mount(); + }); + it("should contain a SimpleElement", () => { + expect(component.find(SimpleElement)).toHaveLength(1); + }); + 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", () => { + const type = () => ( +

+