marcandrewb
2/22/2016 - 7:51 AM

reactjs: an example of a high-order component useful for simple pagination.

reactjs: an example of a high-order component useful for simple pagination.

PaginableWrapper

This is a high-order component for Components for pages with pagination.

Features

  • Pages (list pages) are easier to test!
  • Fetching the data is a no-brainer
  • DRY because it does the work for you to manage state (loading, etc)
  • Filters

Filter, query, and etc are inferred through the URL. Meaning, all you need to do is setup the links (all from react-router).

Usage

import React from 'react';
import makePaginable, {PropTypes} from 'gforce/components/PaginableWrapper';

class MyPage extends React.Component {
  render() {
    console.log(this.props);
  }
}

MyPage.propTypes = {
  paginable: PropTypes
};

const PaginableMyPage = makePaginable(MyPage, { url: 'my-endpoint/hhehe' });

//
export {
  PaginableMyPage as default,
  MyPage // To be able to test it out
}

API

makePaginable(Component: ReactComponent, url: string): ReactComponent

A high-order component that returns a ReactComponent with pagination props.

  • Component: The component (page / route handler) that will be wrapped
  • url: The url which will be requested to.

See [#usage].

props

The props which will be put under props.pagination

console.log(this.props.pagination);
{
  // The requested data
  collection: {
    total: int,
    per_page: int,
    current_page: int,
    last_page: int,
    from: int,
    to: int,
    data: array
  },

  isLoading: boolean, // Self-explanatory
  isError: boolean, // Self-explanatory
  isErrorMessage: string, // The default error message

  // Synactic sugar over
  // this.props.pagination.collection.current_page === 1
  isFirstPage: boolean,

  // Synactic sugar over
  // this.props.pagination.collection.current_page === this.props.pagination.collection.last_page
  isLastPage: boolean
}
import {expect} from 'chai';
import sinon from 'sinon';
import {shallow, mount} from 'enzyme';
import React from 'react';
import axios from 'axios';
import makePaginable from './';

const DummyPromise = new Promise(() => {});
const DummyComponent = () => <div />;
const DummyContext = { router: { location: { query: {} } } };

describe('PaginableWrapper', () => {
  let sandbox;
  beforeEach(() => sandbox = sinon.sandbox.create());
  afterEach(() => sandbox.restore());

  it('should throw when url is not provided.', () => {
    expect(() => {
      makePaginable(null)
    }).to.throw(/The url must be provided, none provided./);
  });

  it('should throw when url is not a string.', () => {
    expect(() => {
      makePaginable(null, {})
    }).to.throw(/The url must be a string, `object` provided./);
  });

  it('should request data on mount with the given url.', () => {
    sandbox.stub(axios, 'get').returns(DummyPromise);
    const Wrapped = makePaginable(DummyComponent, 'hehe');
    const wrapper = mount(<Wrapped />, { context: DummyContext });

    expect(axios.get.calledOnce, 'should request').to.be.true;
    expect(axios.get.getCall(0).args[0], 'should request to given url').to.contain('hehe');
  });

  it('should request data on query update.', () => {
    const Wrapped = makePaginable(DummyComponent, 'hehe');
    const handleRequest = sandbox.spy(Wrapped.prototype, 'handleRequest');
    const wrapper = mount(<Wrapped />, { context: DummyContext });

    expect(handleRequest.calledOnce, 'should request').to.be.true;
    wrapper.setContext({ router: { location: { query: { page: 2 }}}});
    expect(handleRequest.calledTwice, 'should requested again').to.be.true;
  });

  it('should not request data on query update when the page has no changes.', () => {
    const Wrapped = makePaginable(DummyComponent, 'hehe');
    const handleRequest = sandbox.spy(Wrapped.prototype, 'handleRequest');
    const wrapper = mount(<Wrapped />, { context: DummyContext });

    expect(handleRequest.calledOnce, 'should request').to.be.true;
    wrapper.update();
    expect(handleRequest.calledTwice, 'should not request again').to.be.false;
  });

  it('should load when data is requested.', () => {
    sandbox.spy(axios, 'get');
    const Wrapped = makePaginable(DummyComponent, 'hehe');
    const wrapper = shallow(<Wrapped />);

    wrapper.setState({ isLoading: true });
    wrapper.instance().handleRequest(DummyContext);
    expect(wrapper.state('isLoading'), 'should be loading before requesting').to.be.true;
  });

  it('should not request when data is still being requested.', () => {
    sandbox.spy(axios, 'get');
    const Wrapped = makePaginable(DummyComponent, 'hehe');
    const wrapper = shallow(<Wrapped />);

    wrapper.setState({ isLoading: true });
    wrapper.instance().handleRequest(DummyContext.router);
    expect(axios.get.called, 'should be requesting now').to.be.false;
  });

  it('should stop loading and set error when request errors.', (done) => {
    const rejected = new Promise((_, reject) => { reject({}) });
    sandbox.stub(axios, 'get').returns(rejected);
    const Wrapped = makePaginable(DummyComponent, 'hehe');
    const wrapper = shallow(<Wrapped />);

    // I'm pretty sure everything should be running `sync` now.
    // But we're chaining `done` in the next `then` just in case!
    wrapper.instance().handleRequest(DummyContext.router).catch(() => {
      expect(wrapper.state('isLoading'), 'should be out of loading state now').to.be.false;
      expect(wrapper.state('isError'), 'should have an error state now').to.be.true;
      expect(wrapper.state('isErrorMessage'), 'should set an error message').to.equal('Oops! The server is having issues right now. Try again!');
    }).then(done, done);
  });

  it('should check if current page is the first page after request.', (done) => {
    const resolved = new Promise((r) => r({ data: { current_page: 1 } }));
    sandbox.stub(axios, 'get').returns(resolved);
    const Wrapped = makePaginable(DummyComponent, 'hehe');
    const wrapper = shallow(<Wrapped />);

    // I'm pretty sure everything should be running `sync` now.
    // But we're chaining `done` in the next `then` just in case!
    wrapper.instance().handleRequest(DummyContext.router).then(() => {
      expect(wrapper.state('isFirstPage'), 'should be first page at this point').to.be.true;
    }).then(done, done);
  });

  it('should check if current page is the last page after request.', (done) => {
    const resolved = new Promise((r) => r({ data: { current_page: 10, last_page: 10 } }));
    sandbox.stub(axios, 'get').returns(resolved);
    const Wrapped = makePaginable(DummyComponent, 'hehe');
    const wrapper = shallow(<Wrapped />);

    // I'm pretty sure everything should be running `sync` now.
    // But we're chaining `done` in the next `then` just in case!
    wrapper.instance().handleRequest(DummyContext.router).then(() => {
      expect(wrapper.state('isLastPage'), 'should be last page at this point').to.be.true;
    }).then(done, done);
  });
});
import React, {PropTypes} from 'react';
import axios from 'axios';

/**
 * This is a high-order component for Components
 * for pages with pagination.
 *
 * #FEATURES:
 * - Pages (list pages) are easier to test!
 * - Fetching the data is a no-brainer
 * - DRY because it does the work for you to manage state (loading, etc)
 *
 * Filter, query, and etc are inferred through the URL.
 * Meaning, all you need to do is setup the links (all from react-router)
 *
 * @see README
 */

const paginablePropType = PropTypes.shape({
  pagination: PropTypes.shape({
    total: PropTypes.number.isRequired,
    per_page: PropTypes.number.isRequired,
    current_page: PropTypes.number.isRequired,
    last_page: PropTypes.number.isRequired,
    from: PropTypes.number.isRequired,
    to: PropTypes.number.isRequired,
    data: PropTypes.array.isRequired
  }),

  isLoading: PropTypes.bool.isRequired,
  isError: PropTypes.bool.isRequired,
  isErrorMessage: PropTypes.string.isRequired,
  isFirstPage: PropTypes.bool.isRequired,
  isLastPage: PropTypes.bool.isRequired
});

function makePaginable(Component, url) {
  if ( !url ) {
    throw new Error(`
      The url must be provided, none provided.
    `);
  }

  if ( typeof url !== 'string' ) {
    throw new Error(`
      The url must be a string, \`${typeof url}\` provided.
    `);
  }

  class PaginableComponent extends React.Component {
    componentDidMount() {
      this.handleRequest(this.context.router);
    }

    componentWillReceiveProps(_, nextContext) {
      if ( this.context.router.location.query.page === nextContext.router.location.query.page ) {
        return;
      }

      this.handleRequest(nextContext.router);
    }

    constructor(props, context) {
      super(props, context);

      this.state = {
        collection: {
          total: 1,
          per_page: 15,
          current_page: 1,
          last_page: 1,
          from: 1,
          to: 1,
          data: []
        },

        isLoading: false,
        isError: false,
        isErrorMessage: '',
        isFirstPage: false,
        isLastPage: false
      };
    }

    render() {
      const props = {
        ...this.props,
        pagination: this.state
      };

      return <Component {...props} />
    }

    handleRequest({location}) {
      if ( this.state.isLoading ) {
        return;
      }

      this.setState({
        isLoading: true,
        isError: false,
        isErrorMessage: ''
      });

      const page = location.query.page || 1;

      return axios.get(`${url}?page=${page}`)
        .then((res) => {
          this.setState({
            collection: res.data,
            isLoading: false,
            isFirstPage: res.data.current_page === 1,
            isLastPage: res.data.current_page === res.data.last_page
          });
        })
        .catch((res) => {
            this.setState({
              isLoading: false,
              isError: true,
              isErrorMessage: 'Oops! The server is having issues right now. Try again!'
            });

          return Promise.reject(res);
        });
    }
  }

  PaginableComponent.contextTypes = {
    router: PropTypes.object
  };

  return PaginableComponent;
}

export {
  makePaginable as default,
  paginablePropType as PropType
};