diff --git a/README.md b/README.md index 641deb8..458b1c3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,56 @@ -# react-component-tree [![Build Status](https://travis-ci.org/skidding/react-component-tree.svg?branch=master)](https://travis-ci.org/skidding/react-component-tree) [![Coverage Status](https://coveralls.io/repos/skidding/react-component-tree/badge.svg?branch=master)](https://coveralls.io/r/skidding/react-component-tree?branch=master) +# React ComponentTree [![Build Status](https://travis-ci.org/skidding/react-component-tree.svg?branch=master)](https://travis-ci.org/skidding/react-component-tree) [![Coverage Status](https://coveralls.io/repos/skidding/react-component-tree/badge.svg?branch=master)](https://coveralls.io/r/skidding/react-component-tree?branch=master) Serialize and reproduce the state of an entire tree of React components. + +A few examples where this can be useful: +- Using fixtures to load and test components in multiple supported states +- Extracting the app state when an error occurs in the page and reproducing +that exact state later on when debugging +- "Pausing" the app state and resuming it later (nice for games) + +## ComponentTree.serialize + +Generate a snapshot with the props and state of a component combined, including +the state of all nested child components. + +```js +var ComponentTree = require('react-component-tree'); + +myCompany.setProps({public: true}); +myCompany.setState({profitable: true}); +myCompany.refs.employee54.setState({bored: false}); + +var snapshot = ComponentTree.serialize(myCompany); +``` + +The snapshot looks like this: +```js +{ + public: true, + state: { + profitable: true, + children: { + employee54: { + bored: false + } + } + }, +} +``` + +## ComponentTree.render + +Render a component and reproduce a state snapshot by recursively injecting the +nested state into the component tree it generates. + +```js +var myOtherCompany = ComponentTree.render({ + component: CompanyClass, + snapshot: snapshot, + container: document.getElementById('content') +}); + +console.log(myOtherCompany.props.public); // returns true +console.log(myOtherCompany.state.profitable); // returns true +console.log(myOtherCompany.refs.employee54.state.bored); // returns false +``` diff --git a/entry.js b/entry.js index afd1257..8242c0c 100644 --- a/entry.js +++ b/entry.js @@ -1,5 +1,7 @@ module.exports = { Mixin: require('./src/load-child-mixin.js'), Component: require('./src/load-child-component.js'), - serialize: require('./src/serialize.js').serialize + serialize: require('./src/serialize.js').serialize, + render: require('./src/render.js').render, + injectState: require('./src/render.js').injectState }; diff --git a/src/render.js b/src/render.js new file mode 100644 index 0000000..ce57753 --- /dev/null +++ b/src/render.js @@ -0,0 +1,44 @@ +var _ = require('lodash'), + React = require('react'); + +exports.render = function(options) { + /** + * Render a component and reproduce a state snapshot by recursively injecting + * the nested state into the component tree it generates. + * + * @param {Object} options + * @param {ReactClass} options.component + * @param {Object} options.snapshot + * @param {DOMElement} options.container + * + * @returns {ReactComponent} Reference to the rendered component + */ + var props = _.omit(options.snapshot, 'state'), + state = options.snapshot.state; + + var element = React.createElement(options.component, props), + component = React.render(element, options.container); + + if (!_.isEmpty(state)) { + exports.injectState(component, state); + } + + return component; +}; + +exports.injectState = function(component, state) { + var rootState = _.omit(state, 'children'), + childrenStates = state.children; + + component.setState(rootState); + + if (_.isEmpty(childrenStates)) { + return; + } + + _.each(component.refs, function(child, ref) { + if (!_.isEmpty(childrenStates[ref])) { + exports.injectState(child, childrenStates[ref]); + } + }); +}; diff --git a/src/serialize.js b/src/serialize.js index 71c12dd..7462703 100644 --- a/src/serialize.js +++ b/src/serialize.js @@ -2,8 +2,12 @@ var _ = require('lodash'); exports.serialize = function(component) { /** - * Generate a snapshot with the the props and state of a component combined, + * Generate a snapshot with the props and state of a component combined, * including the state of all nested child components. + * + * @param {ReactComponent} component Rendered React component instance + * + * @returns {Object} Snapshot with component props and nested state */ var snapshot = _.clone(component.props), state = getComponentTreeState(component); diff --git a/tests/render-inject-state.js b/tests/render-inject-state.js new file mode 100644 index 0000000..1f624e3 --- /dev/null +++ b/tests/render-inject-state.js @@ -0,0 +1,87 @@ +var React = require('react/addons'), + renderIntoDocument = React.addons.TestUtils.renderIntoDocument, + render = require('../src/render.js').render; + +describe('Render and inject state', function() { + var component; + + class ChildComponent extends React.Component { + render() { + return React.DOM.span(); + } + } + + class ParentComponent extends React.Component { + render() { + return React.createElement(ChildComponent, {ref: 'child'}); + } + } + + class GrandparentComponent extends React.Component { + render() { + return React.createElement(ParentComponent, {ref: 'child'}); + } + } + + beforeEach(function() { + component = renderIntoDocument(React.createElement(GrandparentComponent)); + + sinon.spy(component, 'setState'); + sinon.spy(component.refs.child, 'setState'); + sinon.spy(component.refs.child.refs.child, 'setState'); + + sinon.stub(React, 'render').returns(component); + }); + + afterEach(function() { + React.render.restore(); + }); + + it('should set state on root component', function() { + render({ + component: GrandparentComponent, + snapshot: { + state: {foo: 'bar'} + } + }); + + var stateSet = component.setState.lastCall.args[0]; + expect(stateSet.foo).to.equal('bar'); + }); + + it('should set state on child component', function() { + render({ + component: GrandparentComponent, + snapshot: { + state: { + children: { + child: {foo: 'bar'} + } + } + } + }); + + var stateSet = component.refs.child.setState.lastCall.args[0]; + expect(stateSet.foo).to.equal('bar'); + }); + + it('should set state on grandchild component', function() { + render({ + component: GrandparentComponent, + snapshot: { + state: { + children: { + child: { + children: { + child: {foo: 'bar'} + } + } + } + } + } + }); + + var stateSet = component.refs.child.refs.child.setState.lastCall.args[0]; + expect(stateSet.foo).to.equal('bar'); + }); +}); diff --git a/tests/render.js b/tests/render.js new file mode 100644 index 0000000..60f2a7f --- /dev/null +++ b/tests/render.js @@ -0,0 +1,68 @@ +var React = require('react'), + render = require('../src/render.js').render; + +describe('Render', function() { + var domContainer; + + class Component extends React.Component { + render() { + return React.DOM.span(); + } + } + + beforeEach(function() { + sinon.spy(React, 'createElement'); + sinon.stub(React, 'render'); + + domContainer = document.createElement('div'); + }); + + afterEach(function() { + React.createElement.restore(); + React.render.restore(); + }); + + it('should create element for component', function() { + render({ + component: Component, + snapshot: {foo: 'bar'}, + container: domContainer + }); + + var args = React.createElement.lastCall.args; + expect(args[0]).to.equal(Component); + }); + + it('should create element with props', function() { + render({ + component: Component, + snapshot: {foo: 'bar'}, + container: domContainer + }); + + var args = React.createElement.lastCall.args; + expect(args[1].foo).to.equal('bar'); + }); + + it('should render created element', function() { + render({ + component: Component, + snapshot: {foo: 'bar'}, + container: domContainer + }); + + var args = React.render.lastCall.args; + expect(args[0]).to.equal(React.createElement.returnValues[0]); + }); + + it('should render in given container', function() { + render({ + component: Component, + snapshot: {foo: 'bar'}, + container: domContainer + }); + + var args = React.render.lastCall.args; + expect(args[1]).to.equal(domContainer); + }); +});