diff --git a/.babelrc b/.babelrc index 86c445f..2e23624 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,4 @@ { + "plugins": ["transform-object-rest-spread"], "presets": ["es2015", "react"] } diff --git a/.editorconfig b/.editorconfig index 4eb6462..f05c22d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,6 +9,6 @@ end_of_line = lf insert_final_newline = true indent_style = tab -[package.json] +[{.babelrc,package.json}] indent_style = space indent_size = 2 diff --git a/.eslintignore b/.eslintignore index c64162c..0d9033d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ /coverage/* +/lib/* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f4410f0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Drew Keller + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c7530b --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# react-component-update + +[![Build Status](https://travis-ci.org/wimpyprogrammer/react-component-update.svg?branch=master)](https://travis-ci.org/wimpyprogrammer/react-component-update) +[![codecov](https://codecov.io/gh/wimpyprogrammer/react-component-update/branch/master/graph/badge.svg)](https://codecov.io/gh/wimpyprogrammer/react-component-update) + +Adds convenience lifecycle events to your React components. + + - `componentWillMountOrReceiveProps(nextProps)` - Combines the [`componentWillMount()`](https://facebook.github.io/react/docs/react-component.html#componentwillmount) and [`componentWillReceiveProps(nextProps)`](https://facebook.github.io/react/docs/react-component.html#componentwillreceiveprops) events. This allows you to consolidate all pre-`render()` logic. + + - `componentDidMountOrUpdate(prevProps, prevState)` - Combines the [`componentDidMount()`](https://facebook.github.io/react/docs/react-component.html#componentdidmount) and [`componentDidUpdate(prevProps, prevState)`](https://facebook.github.io/react/docs/react-component.html#componentdidupdate) events. This allows you to consolidate all post-`render()` logic. + +## Installation + +Published on `npm` as [`react-component-update`](https://www.npmjs.com/package/react-component-update). + +npm users: +``` +npm install --save react-component-update +``` + +yarn users: +``` +yarn add react-component-update +``` + +`react-component-update` does not include its own version of React. It will use whatever version is already installed in your project. + +## Usage + +To extend React's `Component` class: + +```js +import React from 'react'; +import { Component } from 'react-component-update'; + +class MyReactComponent extends Component { + componentWillMountOrReceiveProps(nextProps) { + // Code that runs before every render(). For example, check that the data + // used by this component has already loaded, otherwise trigger an AJAX + // request for it. nextProps contains the props that render() will receive. + } + + componentDidMountOrUpdate(prevProps, prevState) { + // Code that runs after every render(). For example, inspect the latest DOM + // to get the height of the rendered elements. prevProps and prevState + // contain the props and state that render() will receive. + } + + render() { + return
; + } +} +``` + +Or to extend React's `PureComponent` class (available in React v15.3.0+): +```js +import { PureComponent } from 'react-component-update'; +``` + +For compatibility with [`create-react-class`](https://www.npmjs.com/package/create-react-class), use the `withEvents()` higher-order component. + +```js +import createReactClass from 'create-react-class'; +import { withEvents } from 'react-component-update'; + +const MyReactComponent = createReactClass(withEvents({ + componentWillMountOrReceiveProps: function(nextProps) { + // Code that runs before every render(). + }, + + componentDidMountOrUpdate: function(prevProps, prevState) { + // Code that runs after every render(). + }, + + render: function() { + return
; + } +})); +``` + +## Mixing with your own lifecycle events + +`react-component-update` implements four lifecycle events of the React base classes: + - `componentWillMount()` + - `componentDidMount()` + - `componentWillReceiveProps()` + - `componentDidUpdate()` + +If you extend `Component` or `PureComponent` from `react-component-update` and you also implement these events in your component, you will need to call the corresponding `super()` method like so: + +```js +componentWillMount() { + super.componentWillMount(); +} + +componentDidMount() { + super.componentDidMount(); +} + +componentWillReceiveProps(nextProps) { + super.componentWillReceiveProps(nextProps); +} + +componentDidUpdate(prevProps, prevState) { + super.componentDidUpdate(prevProps, prevState); +} +``` + +The `super()` method can be called anywhere in your function to suit your needs. + +If you use the `withEvents()` higher-order component, you do not need to add any extra code to your events. The new event (ex. `componentDidMountOrUpdate()`) will always run after the related built-in event (ex. `componentDidUpdate()`). + +## License + +[MIT](/LICENSE.md) diff --git a/package.json b/package.json index ef42199..ed273e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-component-update", - "version": "1.0.0-alpha", + "version": "1.0.0", "description": "Extends the native React Component to streamline updates", "main": "lib/index.js", "scripts": { @@ -17,7 +17,7 @@ "url": "git+https://github.com/wimpyprogrammer/react-component-update.git" }, "author": "Drew Keller ", - "license": "ISC", + "license": "MIT", "bugs": { "url": "https://github.com/wimpyprogrammer/react-component-update/issues" }, @@ -25,10 +25,12 @@ "devDependencies": { "babel-cli": "^6.26.0", "babel-jest": "^20.0.3", + "babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-preset-es2015": "^6.24.1", "babel-preset-react": "^6.24.1", "chai": "^4.0.2", "codecov": "^2.3.0", + "create-react-class": "^15.6.0", "dirty-chai": "^2.0.0", "enzyme": "^2.8.2", "eslint": "^3.19.0", @@ -44,13 +46,20 @@ "react-dom": "*", "react-test-renderer": "*", "rimraf": "^2.6.1", - "sinon": "^2.3.4" + "sinon": "^3.2.1", + "sinon-chai": "^2.13.0" }, "peerDependencies": { "react": "*" }, "jest": { "coverageDirectory": "./coverage/", - "collectCoverage": true + "collectCoverage": true, + "testMatch": [ + "**/src/*.spec.js?(x)" + ] + }, + "dependencies": { + "lodash.wrap": "^4.1.1" } } diff --git a/src/component.spec.js b/src/component.spec.js index 67d7865..68e6b57 100644 --- a/src/component.spec.js +++ b/src/component.spec.js @@ -1,14 +1,20 @@ /* eslint-env jest */ +/* eslint-disable react/no-multi-comp */ import chai, { expect } from 'chai'; import dirtyChai from 'dirty-chai'; import { mount } from 'enzyme'; import uniqueId from 'lodash.uniqueid'; import React from 'react'; import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; import Component from './component'; chai.use(dirtyChai); +chai.use(sinonChai); + +const sandbox = sinon.createSandbox(); +let component; function getUniqueState() { return { [uniqueId('stateVarName')]: uniqueId('stateVarValue') }; @@ -30,140 +36,311 @@ describe('Component extension', () => { } } - const callbackWill = sinon.spy(TestComponent.prototype, 'componentWillMountOrReceiveProps'); - const callbackDid = sinon.spy(TestComponent.prototype, 'componentDidMountOrUpdate'); - const callbackRender = sinon.spy(TestComponent.prototype, 'render'); + const callbackWill = sandbox.spy(TestComponent.prototype, 'componentWillMountOrReceiveProps'); + const callbackDid = sandbox.spy(TestComponent.prototype, 'componentDidMountOrUpdate'); + const callbackRender = sandbox.spy(TestComponent.prototype, 'render'); - afterEach(() => { - callbackWill.reset(); - callbackDid.reset(); - callbackRender.reset(); + beforeEach(() => { + component = mount(); }); + afterEach(() => sandbox.reset()); + describe('componentWillMountOrReceiveProps()', () => { it('runs once on mount', () => { - mount(); - expect(callbackWill.calledOnce).to.be.true(); + expect(callbackWill).to.have.been.calledOnce(); }); it('runs on mount with first parameter of component props', () => { - const component = mount(); - expect(callbackWill.firstCall.args[0]).to.equal(component.props()); + expect(callbackWill.firstCall).to.have.been.calledWith(component.props()); + }); + + it('runs on mount with "this" context of component', () => { + expect(callbackWill.firstCall).to.have.been.calledOn(component.getNode()); }); it('runs on mount before render()', () => { - mount(); - expect(callbackWill.calledBefore(callbackRender)).to.be.true(); + expect(callbackWill).to.have.been.calledBefore(callbackRender); }); it('runs on props update', () => { - const component = mount(); component.setProps(getUniqueProps()); - expect(callbackWill.calledTwice).to.be.true(); + expect(callbackWill).to.have.been.calledTwice(); }); it('runs on props update when no props change', () => { - const component = mount(); component.setProps(component.props()); - expect(callbackWill.calledTwice).to.be.true(); + expect(callbackWill).to.have.been.calledTwice(); }); it('runs on props update with first parameter of component props', () => { - const component = mount(); component.setProps(getUniqueProps()); - expect(callbackWill.secondCall.args[0]).to.equal(component.props()); + expect(callbackWill.secondCall).to.have.been.calledWith(component.props()); + }); + + it('runs on props update with "this" context of component', () => { + component.setProps(getUniqueProps()); + expect(callbackWill.secondCall).to.have.been.calledOn(component.getNode()); }); it('runs on props update before render()', () => { - const component = mount(); component.setProps(getUniqueProps()); - expect(callbackWill.secondCall.calledBefore(callbackRender.secondCall)).to.be.true(); + expect(callbackWill.secondCall).to.have.been.calledBefore(callbackRender.secondCall); }); it('does not run on state update', () => { - const component = mount(); component.setState(getUniqueState()); - expect(callbackWill.calledOnce).to.be.true(); + expect(callbackWill).to.have.been.calledOnce(); }); }); describe('componentDidMountOrUpdate()', () => { it('runs once when mounted', () => { - mount(); - expect(callbackDid.calledOnce).to.be.true(); + expect(callbackDid).to.have.been.calledOnce(); }); it('runs on mount with first parameter of component props', () => { - const component = mount(); - expect(callbackDid.firstCall.args[0]).to.equal(component.props()); + expect(callbackDid.firstCall).to.have.been.calledWith(component.props()); }); it('runs on mount with second parameter of component state', () => { - const component = mount(); - expect(callbackDid.firstCall.args[1]).to.equal(component.state()); + expect(callbackDid.firstCall).to.have.been.calledWith(sinon.match.any, component.state()); + }); + + it('runs on mount with "this" context of component', () => { + expect(callbackDid.firstCall).to.have.been.calledOn(component.getNode()); }); it('runs after render()', () => { - mount(); - expect(callbackDid.calledAfter(callbackRender)).to.be.true(); + expect(callbackDid).to.have.been.calledAfter(callbackRender); }); it('runs on props update', () => { - const component = mount(); component.setProps(getUniqueProps()); - expect(callbackDid.calledTwice).to.be.true(); + expect(callbackDid).to.have.been.calledTwice(); }); it('runs on props update when no props change', () => { - const component = mount(); component.setProps(component.props()); - expect(callbackDid.calledTwice).to.be.true(); + expect(callbackDid).to.have.been.calledTwice(); }); it('runs on props update with first parameter of previous component props', () => { - const component = mount(); const initialProps = component.props(); component.setProps(getUniqueProps()); - expect(callbackDid.secondCall.args[0]).to.equal(initialProps); + expect(callbackDid.secondCall).to.have.been.calledWith(initialProps); }); it('runs on props update with second parameter of previous component state', () => { - const component = mount(); const initialState = component.state(); component.setProps(getUniqueProps()); - expect(callbackDid.secondCall.args[1]).to.equal(initialState); + expect(callbackDid.secondCall).to.have.been.calledWith(sinon.match.any, initialState); + }); + + it('runs on props update with "this" context of component', () => { + component.setProps(getUniqueProps()); + expect(callbackDid.secondCall).to.have.been.calledOn(component.getNode()); }); it('runs on state update', () => { - const component = mount(); component.setState(getUniqueState()); - expect(callbackDid.calledTwice).to.be.true(); + expect(callbackDid).to.have.been.calledTwice(); }); it('runs on state update when no state changes', () => { - const component = mount(); component.setState(component.state()); - expect(callbackDid.calledTwice).to.be.true(); + expect(callbackDid).to.have.been.calledTwice(); }); it('runs on state update with first parameter of previous component props', () => { - const component = mount(); const initialProps = component.props(); component.setState(getUniqueState()); - expect(callbackDid.secondCall.args[0]).to.equal(initialProps); + expect(callbackDid.secondCall).to.have.been.calledWith(initialProps); }); it('runs on state update with second parameter of previous component state', () => { - const component = mount(); const initialState = component.state(); component.setState(getUniqueState()); - expect(callbackDid.secondCall.args[1]).to.equal(initialState); + expect(callbackDid.secondCall).to.have.been.calledWith(sinon.match.any, initialState); + }); + + it('runs on state update with "this" context of component', () => { + component.setState(getUniqueState()); + expect(callbackDid.secondCall).to.have.been.calledOn(component.getNode()); }); it('runs on props update before render()', () => { - const component = mount(); component.setProps(getUniqueProps()); - expect(callbackDid.secondCall.calledAfter(callbackRender.secondCall)).to.be.true(); + expect(callbackDid.secondCall).to.have.been.calledAfter(callbackRender.secondCall); + }); + }); +}); + +describe('Component extension with overrides calling super()', () => { + const userComponentWillMountBefore = sandbox.spy(); + const userComponentWillMountAfter = sandbox.spy(); + const userComponentDidMountBefore = sandbox.spy(); + const userComponentDidMountAfter = sandbox.spy(); + const userComponentWillReceivePropsBefore = sandbox.spy(); + const userComponentWillReceivePropsAfter = sandbox.spy(); + const userComponentDidUpdateBefore = sandbox.spy(); + const userComponentDidUpdateAfter = sandbox.spy(); + + class TestComponentWithSuper extends Component { + componentWillMount() { + userComponentWillMountBefore(); + super.componentWillMount(); + userComponentWillMountAfter(); + } + + componentDidMount() { + userComponentDidMountBefore(); + super.componentDidMount(); + userComponentDidMountAfter(); + } + + componentWillReceiveProps() { + userComponentWillReceivePropsBefore(); + super.componentWillReceiveProps(); + userComponentWillReceivePropsAfter(); + } + + componentDidUpdate() { + userComponentDidUpdateBefore(); + super.componentDidUpdate(); + userComponentDidUpdateAfter(); + } + + render() { + return null; + } + } + + const callbackWill = sandbox.spy(TestComponentWithSuper.prototype, 'componentWillMountOrReceiveProps'); + const callbackDid = sandbox.spy(TestComponentWithSuper.prototype, 'componentDidMountOrUpdate'); + + beforeEach(() => { + component = mount(); + }); + + afterEach(() => sandbox.reset()); + + describe('componentWillMountOrReceiveProps()', () => { + it('runs once on mount', () => { + expect(callbackWill).to.have.been.calledOnce(); + }); + + it('runs user code in override on mount', () => { + sinon.assert.callOrder( + userComponentWillMountBefore, callbackWill, userComponentWillMountAfter, + ); + }); + + it('runs on props update', () => { + component.setProps(getUniqueProps()); + expect(callbackWill).to.have.been.calledTwice(); + }); + + it('runs user code in override on props update', () => { + component.setProps(getUniqueProps()); + sinon.assert.callOrder( + userComponentWillReceivePropsBefore, callbackWill, userComponentWillReceivePropsAfter, + ); + }); + + it('does not run on state update', () => { + component.setState(getUniqueState()); + expect(callbackWill).to.have.been.calledOnce(); + }); + }); + + describe('componentDidMountOrUpdate()', () => { + it('runs once when mounted', () => { + expect(callbackDid).to.have.been.calledOnce(); + }); + + it('runs user code in override on mount', () => { + sinon.assert.callOrder( + userComponentDidMountBefore, callbackDid, userComponentDidMountAfter, + ); + }); + + it('runs on props update', () => { + component.setProps(getUniqueProps()); + expect(callbackDid).to.have.been.calledTwice(); + }); + + it('runs user code in override on props update', () => { + component.setProps(getUniqueProps()); + sinon.assert.callOrder( + userComponentDidUpdateBefore, callbackDid, userComponentDidUpdateAfter, + ); + }); + + it('runs on state update', () => { + component.setState(getUniqueState()); + expect(callbackDid).to.have.been.calledTwice(); + }); + + it('runs user code in override on state update', () => { + component.setState(getUniqueState()); + sinon.assert.callOrder( + userComponentDidUpdateBefore, callbackDid, userComponentDidUpdateAfter, + ); + }); + }); +}); + +describe('Component extension with overrides not calling super()', () => { + class TestComponentWithoutSuper extends Component { + componentWillMount() {} + componentDidMount() {} + componentWillReceiveProps() {} + componentDidUpdate() {} + + render() { + return null; + } + } + + const callbackWill = sandbox.spy(TestComponentWithoutSuper.prototype, 'componentWillMountOrReceiveProps'); + const callbackDid = sandbox.spy(TestComponentWithoutSuper.prototype, 'componentDidMountOrUpdate'); + + beforeEach(() => { + component = mount(); + }); + + afterEach(() => sandbox.reset()); + + describe('componentWillMountOrReceiveProps()', () => { + it('does not run on mount', () => { + expect(callbackWill).not.to.have.been.called(); + }); + + it('does not run on props update', () => { + component.setProps(getUniqueProps()); + expect(callbackWill).not.to.have.been.called(); + }); + + it('does not run on state update', () => { + component.setState(getUniqueState()); + expect(callbackWill).not.to.have.been.called(); + }); + }); + + describe('componentDidMountOrUpdate()', () => { + it('does not run when mounted', () => { + expect(callbackDid).not.to.have.been.called(); + }); + + it('does not run on props update', () => { + component.setProps(getUniqueProps()); + expect(callbackDid).not.to.have.been.called(); + }); + + it('does not run on state update', () => { + component.setState(getUniqueState()); + expect(callbackDid).not.to.have.been.called(); }); }); }); diff --git a/src/index.js b/src/index.js index 27ded4d..06680e5 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,9 @@ import Component from './component'; import PureComponent from './pureComponent'; +import withEvents from './withEvents'; -export default { +module.exports = { Component, PureComponent, + withEvents, }; diff --git a/src/pureComponent.spec.js b/src/pureComponent.spec.js index a28e41b..820bb82 100644 --- a/src/pureComponent.spec.js +++ b/src/pureComponent.spec.js @@ -1,12 +1,18 @@ /* eslint-env jest */ +/* eslint-disable react/no-multi-comp */ import chai, { expect } from 'chai'; import dirtyChai from 'dirty-chai'; import { mount } from 'enzyme'; import uniqueId from 'lodash.uniqueid'; import React from 'react'; import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; chai.use(dirtyChai); +chai.use(sinonChai); + +const sandbox = sinon.createSandbox(); +let component; function getUniqueState() { return { [uniqueId('stateVarName')]: uniqueId('stateVarValue') }; @@ -20,7 +26,7 @@ function getUniqueProps() { const descriptor = React.PureComponent ? describe : describe.skip; descriptor('PureComponent extension', () => { - const { default: PureComponent } = require('./pureComponent'); // eslint-disable-line global-require + const { PureComponent } = require('./'); // eslint-disable-line global-require class TestComponent extends PureComponent { constructor(props) { @@ -33,128 +39,305 @@ descriptor('PureComponent extension', () => { } } - const callbackWill = sinon.spy(TestComponent.prototype, 'componentWillMountOrReceiveProps'); - const callbackDid = sinon.spy(TestComponent.prototype, 'componentDidMountOrUpdate'); - const callbackRender = sinon.spy(TestComponent.prototype, 'render'); + const callbackWill = sandbox.spy(TestComponent.prototype, 'componentWillMountOrReceiveProps'); + const callbackDid = sandbox.spy(TestComponent.prototype, 'componentDidMountOrUpdate'); + const callbackRender = sandbox.spy(TestComponent.prototype, 'render'); - afterEach(() => { - callbackWill.reset(); - callbackDid.reset(); - callbackRender.reset(); + beforeEach(() => { + component = mount(); }); + afterEach(() => sandbox.reset()); + describe('componentWillMountOrReceiveProps()', () => { it('runs once on mount', () => { - mount(); - expect(callbackWill.calledOnce).to.be.true(); + expect(callbackWill).to.have.been.calledOnce(); }); it('runs on mount with first parameter of component props', () => { - const component = mount(); - expect(callbackWill.firstCall.args[0]).to.equal(component.props()); + expect(callbackWill.firstCall).to.have.been.calledWith(component.props()); + }); + + it('runs on mount with "this" context of component', () => { + expect(callbackWill.firstCall).to.have.been.calledOn(component.getNode()); }); it('runs on mount before render()', () => { - mount(); - expect(callbackWill.calledBefore(callbackRender)).to.be.true(); + expect(callbackWill).to.have.been.calledBefore(callbackRender); }); it('runs on props update', () => { - const component = mount(); component.setProps(getUniqueProps()); - expect(callbackWill.calledTwice).to.be.true(); + expect(callbackWill).to.have.been.calledTwice(); }); it('runs on props update when no props change', () => { - const component = mount(); component.setProps(component.props()); - expect(callbackWill.calledTwice).to.be.true(); + expect(callbackWill).to.have.been.calledTwice(); }); it('runs on props update with first parameter of component props', () => { - const component = mount(); component.setProps(getUniqueProps()); - expect(callbackWill.secondCall.args[0]).to.equal(component.props()); + expect(callbackWill.secondCall).to.have.been.calledWith(component.props()); + }); + + it('runs on props update with "this" context of component', () => { + component.setProps(getUniqueProps()); + expect(callbackWill.secondCall).to.have.been.calledOn(component.getNode()); }); it('runs on props update before render()', () => { - const component = mount(); component.setProps(getUniqueProps()); - expect(callbackWill.secondCall.calledBefore(callbackRender.secondCall)).to.be.true(); + expect(callbackWill.secondCall).to.have.been.calledBefore(callbackRender.secondCall); }); it('does not run on state update', () => { - const component = mount(); component.setState(getUniqueState()); - expect(callbackWill.calledOnce).to.be.true(); + expect(callbackWill).to.have.been.calledOnce(); }); }); describe('componentDidMountOrUpdate()', () => { it('runs once when mounted', () => { - mount(); - expect(callbackDid.calledOnce).to.be.true(); + expect(callbackDid).to.have.been.calledOnce(); }); it('runs on mount with first parameter of component props', () => { - const component = mount(); - expect(callbackDid.firstCall.args[0]).to.equal(component.props()); + expect(callbackDid.firstCall).to.have.been.calledWith(component.props()); }); it('runs on mount with second parameter of component state', () => { - const component = mount(); - expect(callbackDid.firstCall.args[1]).to.equal(component.state()); + expect(callbackDid.firstCall).to.have.been.calledWith(sinon.match.any, component.state()); + }); + + it('runs on mount with "this" context of component', () => { + expect(callbackDid.firstCall).to.have.been.calledOn(component.getNode()); }); it('runs after render()', () => { - mount(); - expect(callbackDid.calledAfter(callbackRender)).to.be.true(); + expect(callbackDid).to.have.been.calledAfter(callbackRender); }); it('runs on props update', () => { - const component = mount(); component.setProps(getUniqueProps()); - expect(callbackDid.calledTwice).to.be.true(); + expect(callbackDid).to.have.been.calledTwice(); }); it('runs on props update with first parameter of previous component props', () => { - const component = mount(); const initialProps = component.props(); component.setProps(getUniqueProps()); - expect(callbackDid.secondCall.args[0]).to.equal(initialProps); + expect(callbackDid.secondCall).to.have.been.calledWith(initialProps); }); it('runs on props update with second parameter of previous component state', () => { - const component = mount(); const initialState = component.state(); component.setProps(getUniqueProps()); - expect(callbackDid.secondCall.args[1]).to.equal(initialState); + expect(callbackDid.secondCall).to.have.been.calledWith(sinon.match.any, initialState); + }); + + it('runs on props update with "this" context of component', () => { + component.setProps(getUniqueProps()); + expect(callbackDid.secondCall).to.have.been.calledOn(component.getNode()); }); it('runs on state update', () => { - const component = mount(); component.setState(getUniqueState()); - expect(callbackDid.calledTwice).to.be.true(); + expect(callbackDid).to.have.been.calledTwice(); }); it('runs on state update with first parameter of previous component props', () => { - const component = mount(); const initialProps = component.props(); component.setState(getUniqueState()); - expect(callbackDid.secondCall.args[0]).to.equal(initialProps); + expect(callbackDid.secondCall).to.have.been.calledWith(initialProps); }); it('runs on state update with second parameter of previous component state', () => { - const component = mount(); const initialState = component.state(); component.setState(getUniqueState()); - expect(callbackDid.secondCall.args[1]).to.equal(initialState); + expect(callbackDid.secondCall).to.have.been.calledWith(sinon.match.any, initialState); + }); + + it('runs on state update with "this" context of component', () => { + component.setState(getUniqueState()); + expect(callbackDid.secondCall).to.have.been.calledOn(component.getNode()); }); it('runs on props update before render()', () => { - const component = mount(); component.setProps(getUniqueProps()); - expect(callbackDid.secondCall.calledAfter(callbackRender.secondCall)).to.be.true(); + expect(callbackDid.secondCall).to.have.been.calledAfter(callbackRender.secondCall); + }); + }); +}); + +descriptor('PureComponent extension with overrides calling super()', () => { + const { PureComponent } = require('./'); // eslint-disable-line global-require + + const userComponentWillMountBefore = sandbox.spy(); + const userComponentWillMountAfter = sandbox.spy(); + const userComponentDidMountBefore = sandbox.spy(); + const userComponentDidMountAfter = sandbox.spy(); + const userComponentWillReceivePropsBefore = sandbox.spy(); + const userComponentWillReceivePropsAfter = sandbox.spy(); + const userComponentDidUpdateBefore = sandbox.spy(); + const userComponentDidUpdateAfter = sandbox.spy(); + + class TestComponentWithSuper extends PureComponent { + componentWillMount() { + userComponentWillMountBefore(); + super.componentWillMount(); + userComponentWillMountAfter(); + } + + componentDidMount() { + userComponentDidMountBefore(); + super.componentDidMount(); + userComponentDidMountAfter(); + } + + componentWillReceiveProps() { + userComponentWillReceivePropsBefore(); + super.componentWillReceiveProps(); + userComponentWillReceivePropsAfter(); + } + + componentDidUpdate() { + userComponentDidUpdateBefore(); + super.componentDidUpdate(); + userComponentDidUpdateAfter(); + } + + render() { + return null; + } + } + + const callbackWill = sandbox.spy(TestComponentWithSuper.prototype, 'componentWillMountOrReceiveProps'); + const callbackDid = sandbox.spy(TestComponentWithSuper.prototype, 'componentDidMountOrUpdate'); + + beforeEach(() => { + component = mount(); + }); + + afterEach(() => sandbox.reset()); + + describe('componentWillMountOrReceiveProps()', () => { + it('runs once on mount', () => { + expect(callbackWill).to.have.been.calledOnce(); + }); + + it('runs user code in override on mount', () => { + sinon.assert.callOrder( + userComponentWillMountBefore, callbackWill, userComponentWillMountAfter, + ); + }); + + it('runs on props update', () => { + component.setProps(getUniqueProps()); + expect(callbackWill).to.have.been.calledTwice(); + }); + + it('runs user code in override on props update', () => { + component.setProps(getUniqueProps()); + sinon.assert.callOrder( + userComponentWillReceivePropsBefore, callbackWill, userComponentWillReceivePropsAfter, + ); + }); + + it('does not run on state update', () => { + component.setState(getUniqueState()); + expect(callbackWill).to.have.been.calledOnce(); + }); + }); + + describe('componentDidMountOrUpdate()', () => { + it('runs once when mounted', () => { + expect(callbackDid).to.have.been.calledOnce(); + }); + + it('runs user code in override on mount', () => { + sinon.assert.callOrder( + userComponentDidMountBefore, callbackDid, userComponentDidMountAfter, + ); + }); + + it('runs on props update', () => { + component.setProps(getUniqueProps()); + expect(callbackDid).to.have.been.calledTwice(); + }); + + it('runs user code in override on props update', () => { + component.setProps(getUniqueProps()); + sinon.assert.callOrder( + userComponentDidUpdateBefore, callbackDid, userComponentDidUpdateAfter, + ); + }); + + it('runs on state update', () => { + component.setState(getUniqueState()); + expect(callbackDid).to.have.been.calledTwice(); + }); + + it('runs user code in override on state update', () => { + component.setState(getUniqueState()); + sinon.assert.callOrder( + userComponentDidUpdateBefore, callbackDid, userComponentDidUpdateAfter, + ); + }); + }); +}); + +descriptor('Component extension with overrides not calling super()', () => { + const { PureComponent } = require('./'); // eslint-disable-line global-require + + class TestComponentWithoutSuper extends PureComponent { + componentWillMount() {} + componentDidMount() {} + componentWillReceiveProps() {} + componentDidUpdate() {} + + render() { + return null; + } + } + + const callbackWill = sandbox.spy(TestComponentWithoutSuper.prototype, 'componentWillMountOrReceiveProps'); + const callbackDid = sandbox.spy(TestComponentWithoutSuper.prototype, 'componentDidMountOrUpdate'); + + beforeEach(() => { + component = mount(); + }); + + afterEach(() => sandbox.reset()); + + describe('componentWillMountOrReceiveProps()', () => { + it('does not run on mount', () => { + expect(callbackWill).not.to.have.been.called(); + }); + + it('does not run on props update', () => { + component.setProps(getUniqueProps()); + expect(callbackWill).not.to.have.been.called(); + }); + + it('does not run on state update', () => { + component.setState(getUniqueState()); + expect(callbackWill).not.to.have.been.called(); + }); + }); + + describe('componentDidMountOrUpdate()', () => { + it('does not run when mounted', () => { + expect(callbackDid).not.to.have.been.called(); + }); + + it('does not run on props update', () => { + component.setProps(getUniqueProps()); + expect(callbackDid).not.to.have.been.called(); + }); + + it('does not run on state update', () => { + component.setState(getUniqueState()); + expect(callbackDid).not.to.have.been.called(); }); }); }); diff --git a/src/withEvents.js b/src/withEvents.js new file mode 100644 index 0000000..bcd7787 --- /dev/null +++ b/src/withEvents.js @@ -0,0 +1,39 @@ +import wrap from 'lodash.wrap'; + +function noop() {} + +export default function withEvents(config) { + function willMountCustom(nativeFunc = noop, ...args) { + const result = nativeFunc(...args); + this.componentWillMountOrReceiveProps(this.props); + return result; + } + + function didMountCustom(nativeFunc = noop, ...args) { + const result = nativeFunc(...args); + this.componentDidMountOrUpdate(this.props, this.state); + return result; + } + + function willReceivePropsCustom(nativeFunc = noop, ...args) { + const result = nativeFunc(...args); + this.componentWillMountOrReceiveProps(...args); + return result; + } + + function didUpdateCustom(nativeFunc = noop, ...args) { + const result = nativeFunc(...args); + this.componentDidMountOrUpdate(...args); + return result; + } + + return { + componentWillMountOrReceiveProps: noop, + componentDidMountOrUpdate: noop, + ...config, + componentWillMount: wrap(config.componentWillMount, willMountCustom), + componentDidMount: wrap(config.componentDidMount, didMountCustom), + componentWillReceiveProps: wrap(config.componentWillReceiveProps, willReceivePropsCustom), + componentDidUpdate: wrap(config.componentDidUpdate, didUpdateCustom), + }; +} diff --git a/src/withEvents.spec.js b/src/withEvents.spec.js new file mode 100644 index 0000000..b1aa483 --- /dev/null +++ b/src/withEvents.spec.js @@ -0,0 +1,245 @@ +/* eslint-env jest */ +import chai, { expect } from 'chai'; +import createReactClass from 'create-react-class'; +import dirtyChai from 'dirty-chai'; +import { mount } from 'enzyme'; +import uniqueId from 'lodash.uniqueid'; +import React from 'react'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import withEvents from './withEvents'; + +chai.use(dirtyChai); +chai.use(sinonChai); + +const sandbox = sinon.createSandbox(); +let component; + +function getUniqueState() { + return { [uniqueId('stateVarName')]: uniqueId('stateVarValue') }; +} + +function getUniqueProps() { + return { [uniqueId('propName')]: uniqueId('propValue') }; +} + +describe('withEvents extension', () => { + const callbackWill = sandbox.spy(); + const callbackDid = sandbox.spy(); + + const TestComponent = createReactClass(withEvents({ + getInitialState: getUniqueState, + componentWillMountOrReceiveProps: callbackWill, + componentDidMountOrUpdate: callbackDid, + render: () => null, + })); + + const callbackRender = sandbox.spy(TestComponent.prototype, 'render'); + + beforeEach(() => { + component = mount(); + }); + + afterEach(() => sandbox.reset()); + + describe('componentWillMountOrReceiveProps()', () => { + it('runs once on mount', () => { + expect(callbackWill).to.have.been.calledOnce(); + }); + + it('runs on mount with first parameter of component props', () => { + expect(callbackWill.firstCall).to.have.been.calledWith(component.props()); + }); + + it('runs on mount with "this" context of component', () => { + expect(callbackWill.firstCall).to.have.been.calledOn(component.getNode()); + }); + + it('runs on mount before render()', () => { + expect(callbackWill).to.have.been.calledBefore(callbackRender); + }); + + it('runs on props update', () => { + component.setProps(getUniqueProps()); + expect(callbackWill).to.have.been.calledTwice(); + }); + + it('runs on props update when no props change', () => { + component.setProps(component.props()); + expect(callbackWill).to.have.been.calledTwice(); + }); + + it('runs on props update with first parameter of component props', () => { + component.setProps(getUniqueProps()); + expect(callbackWill.secondCall).to.have.been.calledWith(component.props()); + }); + + it('runs on props update with "this" context of component', () => { + component.setProps(getUniqueProps()); + expect(callbackWill.secondCall).to.have.been.calledOn(component.getNode()); + }); + + it('runs on props update before render()', () => { + component.setProps(getUniqueProps()); + expect(callbackWill.secondCall).to.have.been.calledBefore(callbackRender.secondCall); + }); + + it('does not run on state update', () => { + component.setState(getUniqueState()); + expect(callbackWill).to.have.been.calledOnce(); + }); + }); + + describe('componentDidMountOrUpdate()', () => { + it('runs once when mounted', () => { + expect(callbackDid).to.have.been.calledOnce(); + }); + + it('runs on mount with first parameter of component props', () => { + expect(callbackDid.firstCall).to.have.been.calledWith(component.props()); + }); + + it('runs on mount with second parameter of component state', () => { + expect(callbackDid.firstCall).to.have.been.calledWith(sinon.match.any, component.state()); + }); + + it('runs on mount with "this" context of component', () => { + expect(callbackDid.firstCall).to.have.been.calledOn(component.getNode()); + }); + + it('runs after render()', () => { + expect(callbackDid).to.have.been.calledAfter(callbackRender); + }); + + it('runs on props update', () => { + component.setProps(getUniqueProps()); + expect(callbackDid).to.have.been.calledTwice(); + }); + + it('runs on props update with first parameter of previous component props', () => { + const initialProps = component.props(); + component.setProps(getUniqueProps()); + expect(callbackDid.secondCall).to.have.been.calledWith(initialProps); + }); + + it('runs on props update with second parameter of previous component state', () => { + const initialState = component.state(); + component.setProps(getUniqueProps()); + expect(callbackDid.secondCall).to.have.been.calledWith(sinon.match.any, initialState); + }); + + it('runs on props update with "this" context of component', () => { + component.setProps(getUniqueProps()); + expect(callbackDid.secondCall).to.have.been.calledOn(component.getNode()); + }); + + it('runs on state update', () => { + component.setState(getUniqueState()); + expect(callbackDid).to.have.been.calledTwice(); + }); + + it('runs on state update with first parameter of previous component props', () => { + const initialProps = component.props(); + component.setState(getUniqueState()); + expect(callbackDid.secondCall).to.have.been.calledWith(initialProps); + }); + + it('runs on state update with second parameter of previous component state', () => { + const initialState = component.state(); + component.setState(getUniqueState()); + expect(callbackDid.secondCall).to.have.been.calledWith(sinon.match.any, initialState); + }); + + it('runs on state update with "this" context of component', () => { + component.setState(getUniqueState()); + expect(callbackDid.secondCall).to.have.been.calledOn(component.getNode()); + }); + + it('runs on props update before render()', () => { + component.setProps(getUniqueProps()); + expect(callbackDid.secondCall).to.have.been.calledAfter(callbackRender.secondCall); + }); + }); +}); + +describe('withEvents extension with overrides', () => { + const userComponentWillMount = sandbox.spy(); + const userComponentDidMount = sandbox.spy(); + const userComponentWillReceiveProps = sandbox.spy(); + const userComponentDidUpdate = sandbox.spy(); + + const callbackWill = sandbox.spy(); + const callbackDid = sandbox.spy(); + + const TestComponentWithOverrides = createReactClass(withEvents({ + getInitialState: getUniqueState, + componentWillMount: userComponentWillMount, + componentDidMount: userComponentDidMount, + componentWillReceiveProps: userComponentWillReceiveProps, + componentDidUpdate: userComponentDidUpdate, + componentWillMountOrReceiveProps: callbackWill, + componentDidMountOrUpdate: callbackDid, + render: () => null, + })); + + beforeEach(() => { + component = mount(); + }); + + afterEach(() => sandbox.reset()); + + describe('componentWillMountOrReceiveProps()', () => { + it('runs once on mount', () => { + expect(callbackWill).to.have.been.calledOnce(); + }); + + it('runs user code in override on mount', () => { + expect(userComponentWillMount).to.have.been.calledBefore(callbackWill); + }); + + it('runs on props update', () => { + component.setProps(getUniqueProps()); + expect(callbackWill).to.have.been.calledTwice(); + }); + + it('runs user code in override on props update', () => { + component.setProps(getUniqueProps()); + expect(userComponentWillReceiveProps).to.have.been.calledBefore(callbackWill.secondCall); + }); + + it('does not run on state update', () => { + component.setState(getUniqueState()); + expect(callbackWill).to.have.been.calledOnce(); + }); + }); + + describe('componentDidMountOrUpdate()', () => { + it('runs once when mounted', () => { + expect(callbackDid).to.have.been.calledOnce(); + }); + + it('runs user code in override on mount', () => { + expect(userComponentDidMount).to.have.been.calledBefore(callbackDid); + }); + + it('runs on props update', () => { + component.setProps(getUniqueProps()); + expect(callbackDid).to.have.been.calledTwice(); + }); + + it('runs user code in override on props update', () => { + component.setProps(getUniqueProps()); + expect(userComponentDidUpdate).to.have.been.calledBefore(callbackDid.secondCall); + }); + + it('runs on state update', () => { + component.setState(getUniqueState()); + expect(callbackDid).to.have.been.calledTwice(); + }); + + it('runs user code in override on state update', () => { + component.setState(getUniqueState()); + expect(userComponentDidUpdate).to.have.been.calledBefore(callbackDid.secondCall); + }); + }); +});