Skip to content

Commit

Permalink
Add onStateChanged hook (#2)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
mresposito authored Mar 28, 2020
1 parent f61c311 commit 663958e
Show file tree
Hide file tree
Showing 18 changed files with 541 additions and 247 deletions.
15 changes: 15 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# New
<!-- Provide a description of the changes you’ve made in this pr -->


# Testing
<!-- Please select all the testing methods that apply to this pr -->

- [ ] Unit
- [ ] Integration
- [ ] Localhost


# Screenshots
<!-- If you have made client facing changes please provide screenshots -->

7 changes: 4 additions & 3 deletions jest/setup.js
Original file line number Diff line number Diff line change
@@ -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() });
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@
"@babel/plugin-proposal-class-properties": "^7.7.4",
"@babel/plugin-proposal-object-rest-spread": "^7.7.4",
"@babel/preset-env": "^7.7.4",
"@testing-library/react": "^9.4.0",
"@types/jest": "^24.0.25",
"@types/react": "^16.9.11",
"@types/react-dom": "^16.8.0",
"@typescript-eslint/eslint-plugin": "^2.14.0",
"@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.9.0",
"eslint-config-react-app": "^5.1.0",
Expand Down Expand Up @@ -81,7 +82,7 @@
"<rootDir>/packages/core/"
],
"testMatch": [
"<rootDir>/packages/core/src/tests/**/?(*.)(spec|test).ts(x|)"
"<rootDir>/packages/core/src/**/?(*.)(spec|test).ts(x|)"
],
"transform": {
"^.+\\.(ts|tsx)$": "ts-jest"
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/editor/Editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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<Options> = {}) => ({
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<Partial<Options>> = ({
children,
...options
}) => {
const context = useEditorStore(withDefaults(options));

useEffect(() => {
if (context && options)
context.actions.setOptions(editorOptions => {
editorOptions = options;
});
}, [context, options]);

const json = context && context.query.serialize();
const { onStateChange } = options;
// because `useEffect` doesnt allow for deep comparison, we use this trick.
// TODO: improve to actually use a deep comparison.
useEffect(() => {
onStateChange && json && onStateChange(JSON.parse(json));
}, [onStateChange, json]);

return context ? (
<EditorContext.Provider value={context}>
<Events>{children}</Events>
</EditorContext.Provider>
) : null;
};
14 changes: 8 additions & 6 deletions packages/core/src/editor/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,10 @@ export const Actions = (
) {
cb(state.nodes[id].data.custom);
},
deserialize(json: string) {
const reducedNodes: Record<NodeId, SerializedNodeData> = JSON.parse(json);
const rehydratedNodes = Object.keys(reducedNodes).reduce(
(accum: Nodes, id) => {
deserialize(nodes: object) {
const dehydratedNodes = nodes as Record<NodeId, SerializedNodeData>;
const rehydratedNodes = Object.keys(dehydratedNodes).reduce(
(accum: Nodes, id: string) => {
const {
type: Comp,
props,
Expand All @@ -224,9 +224,11 @@ export const Actions = (
isCanvas,
hidden,
custom
} = deserializeNode(reducedNodes[id], state.options.resolver);
} = deserializeNode(dehydratedNodes[id], state.options.resolver);

if (!Comp) return accum;
if (!Comp) {
return accum;
}

accum[id] = query.createNode(createElement(Comp, props), {
id,
Expand Down
43 changes: 1 addition & 42 deletions packages/core/src/editor/index.tsx
Original file line number Diff line number Diff line change
@@ -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<Options> = {}) => {
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<Partial<Options>> = ({
children,
...options
}) => {
const context = useEditorStore(createEditorStoreOptions(options));

useEffect(() => {
if (context && options)
context.actions.setOptions(editorOptions => {
editorOptions = options;
});
}, [context, options]);

return context ? (
<EditorContext.Provider value={context}>
<Events>{children}</Events>
</EditorContext.Provider>
) : null;
};
export * from "./Editor";
26 changes: 12 additions & 14 deletions packages/core/src/editor/query.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,24 +62,22 @@ export function QueryMethods(Editor: EditorState) {
node.data.name = name;
return node;
},

getSerializedNodes(): object {
return Object.keys(Editor.nodes).reduce((result: any, id: NodeId) => {
const {
data: { ...data }
} = Editor.nodes[id];
result[id] = serializeNode({ ...data }, options.resolver);
return result;
}, {});
},

/**
* 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);

return json;
return JSON.stringify(_().getSerializedNodes());
},
/**
* Determine the best possible location to drop the source Node relative to the target Node
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/editor/tests/Editor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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<any>;

describe("<Editor />", () => {
const children = <h1>a children</h1>;
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(
<Editor onStateChange={onStateChange}>{children}</Editor>
);
});
});
it("should render the children with events", () => {
expect(component.contains(<Events>{children}</Events>)).toBe(true);
});
it("should render the EditorContext.Provider", () => {
expect(component.find(EditorContext.Provider)).toHaveLength(1);
});
it("should have called serialize", () => {
expect(query.serialize).toHaveBeenCalled();
});
});
50 changes: 50 additions & 0 deletions packages/core/src/hooks/tests/useEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -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<any>;

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
})
);
});
});
2 changes: 1 addition & 1 deletion packages/core/src/hooks/useEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function useEditor<S>(collect?: any): useEditor<S> {
}, [EditorActions, setNodeEvent]);

return {
connectors: connectors,
connectors,
actions,
query,
...(collected as any)
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export * from "./nodes";
export * from "./render";
export * from "./interfaces";
export * from "./hooks";
export * from "./editor";
export * from "./events";
export * from "./hooks";
export * from "./interfaces";
export * from "./nodes";
export * from "./render";
1 change: 1 addition & 0 deletions packages/core/src/interfaces/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down
17 changes: 10 additions & 7 deletions packages/core/src/render/Frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,45 @@ 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;
};

/**
* A React Component that defines the editable area
*/
export const Frame: React.FC<Frame> = ({ children, json }) => {
export const Frame: React.FC<Frame> = ({ children, nodes, json }) => {
const { actions, query } = useInternalEditor();

const [render, setRender] = useState<React.ReactElement | null>(null);

const initialState = useRef({
initialChildren: children,
initialJson: json
initialNodes: nodes || (json && JSON.parse(json))
});

useEffect(() => {
const { replaceNodes, deserialize } = actions;
const { createNode } = query;

const {
initialChildren: children,
initialJson: json
initialNodes: nodes
} = initialState.current;
if (!json) {

if (nodes) {
deserialize(nodes);
} else if (children) {
const rootCanvas = React.Children.only(children) as React.ReactElement;
invariant(
rootCanvas.type && rootCanvas.type === Canvas,
ERROR_FRAME_IMMEDIATE_NON_CANVAS
);
const node = createNode(rootCanvas, { id: ROOT_NODE });
replaceNodes({ [ROOT_NODE]: node });
} else {
deserialize(json);
}

setRender(<NodeElement id={ROOT_NODE} />);
Expand Down
Loading

0 comments on commit 663958e

Please sign in to comment.