Skip to content

Instantly share code, notes, and snippets.

@srph
Created February 22, 2016 07:51
Show Gist options
  • Save srph/0ae7f2342f1caf8f0f8d to your computer and use it in GitHub Desktop.
Save srph/0ae7f2342f1caf8f0f8d to your computer and use it in GitHub Desktop.
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 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
};
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);
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment