Repository: reactioncommerce/reaction-next-starterkit
Pull request #73 (2018-05-01 02:09:23)

feat: Product grid pagination
Issue: #14
Impact: **minor**
Type: **feat**

## Issue

Add pagination to product grid

## Solution

- Update `withCatalogItems` to support pagination
- Create a generic pagination component
- Create a generaic paganation helper in lib
- Add Paganation to product grid component

## Testing

1. load many products into a running copy of reaction using devtools or manually
2. load http://localhost:4000 and use the next / previous buttons to go through pages
3. Hard refresh the window, ensure your results reflect the page you think you're supposed to be on


Patch:
diff --git a/src/components/PageStepper/PageStepper.js b/src/components/PageStepper/PageStepper.js
new file mode 100644
index 00000000..0863c202
--- /dev/null
+++ b/src/components/PageStepper/PageStepper.js
@@ -0,0 +1,47 @@
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import Button from "material-ui/Button";
+import Grid from "material-ui/Grid";
+import withStyles from "material-ui/styles/withStyles";
+
+const styles = (theme) => ({
+  root: {
+    paddingTop: theme.spacing.unit * 2,
+    paddingBottom: theme.spacing.unit * 2
+  }
+});
+
+@withStyles(styles)
+export default class ProductGrid extends Component {
+  static propTypes = {
+    classes: PropTypes.object,
+    pageInfo: PropTypes.shape({
+      startCursor: PropTypes.string,
+      endCursor: PropTypes.string,
+      hasNextPage: PropTypes.bool,
+      hasPreviousPage: PropTypes.bool,
+      loadNextPage: PropTypes.func,
+      loadPreviousPage: PropTypes.func
+    }).isRequired,
+    theme: PropTypes.object
+  };
+
+  render() {
+    const { classes, pageInfo } = this.props;
+
+    return (
+      <Grid className={classes.root} container justify="space-between">
+        <Grid item>
+          {pageInfo.hasPreviousPage &&
+            <Button onClick={pageInfo.loadPreviousPage} variant="raised">{"Previous"}</Button>
+          }
+        </Grid>
+        <Grid item>
+          {pageInfo.hasNextPage &&
+            <Button onClick={pageInfo.loadNextPage} variant="raised">{"Next"}</Button>
+          }
+        </Grid>
+      </Grid>
+    );
+  }
+}
diff --git a/src/components/PageStepper/index.js b/src/components/PageStepper/index.js
new file mode 100644
index 00000000..326697ad
--- /dev/null
+++ b/src/components/PageStepper/index.js
@@ -0,0 +1,1 @@
+export { default } from "./PageStepper";
diff --git a/src/components/ProductGrid/ProductGrid.js b/src/components/ProductGrid/ProductGrid.js
index 0ee0df20..bb24a509 100644
--- a/src/components/ProductGrid/ProductGrid.js
+++ b/src/components/ProductGrid/ProductGrid.js
@@ -2,8 +2,8 @@ import React, { Component } from "react";
 import PropTypes from "prop-types";
 import Grid from "material-ui/Grid";
 import { withStyles } from "material-ui/styles";
-
 import ProductItem from "components/ProductItem";
+import PageStepper from "components/PageStepper";
 
 const styles = () => ({
   productGridContainer: {
@@ -17,7 +17,15 @@ const styles = () => ({
 export default class ProductGrid extends Component {
   static propTypes = {
     catalogItems: PropTypes.arrayOf(PropTypes.object),
-    classes: PropTypes.object
+    classes: PropTypes.object,
+    pageInfo: PropTypes.shape({
+      startCursor: PropTypes.string,
+      endCursor: PropTypes.string,
+      hasNextPage: PropTypes.bool,
+      hasPreviousPage: PropTypes.bool,
+      loadNextPage: PropTypes.func,
+      loadPreviousPage: PropTypes.func
+    })
   };
 
   renderProduct(edge) {
@@ -53,13 +61,17 @@ export default class ProductGrid extends Component {
   }
 
   render() {
-    const { catalogItems, classes } = this.props;
+    const { catalogItems, classes, pageInfo } = this.props;
+
+    if (!catalogItems) return null;
 
     return (
       <section className={classes.productGridContainer}>
         <Grid container spacing={24}>
           {(catalogItems && catalogItems.length) ? catalogItems.map(this.renderProduct) : null}
         </Grid>
+
+        { pageInfo && <PageStepper pageInfo={pageInfo} /> }
       </section>
     );
   }
diff --git a/src/containers/catalog/catalogItems.gql b/src/containers/catalog/catalogItems.gql
index e85af1a7..9c86c76d 100644
--- a/src/containers/catalog/catalogItems.gql
+++ b/src/containers/catalog/catalogItems.gql
@@ -1,5 +1,12 @@
-query catalogItemsQuery($shopId: ID!, $first: ConnectionLimitInt) { 
-  catalogItems(shopIds: [$shopId], first: $first) {
+query catalogItemsQuery($shopId: ID!, $first: ConnectionLimitInt, $last:  ConnectionLimitInt, $before: ConnectionCursor, $after: ConnectionCursor) {
+  catalogItems(shopIds: [$shopId], first: $first, last: $last, before: $before, after: $after) {
+    totalCount
+    pageInfo {
+      endCursor
+      startCursor
+      hasNextPage
+      hasPreviousPage
+    }
     edges {
       cursor
       node {
diff --git a/src/containers/catalog/withCatalogItems.js b/src/containers/catalog/withCatalogItems.js
index 87c56d82..13ffebea 100644
--- a/src/containers/catalog/withCatalogItems.js
+++ b/src/containers/catalog/withCatalogItems.js
@@ -1,34 +1,60 @@
 import React from "react";
+import PropTypes from "prop-types";
 import { inject, observer } from "mobx-react";
 import { Query } from "react-apollo";
+import { pagination, paginationVariablesFromUrlParams } from "lib/helpers/pagination";
 import catalogItemsQuery from "./catalogItems.gql";
 
+const PAGE_LIMIT = 24;
+
 /**
  * withCatalogItems higher order query component for fetching primaryShopId and catalog data
  * @name withCatalogItems
  * @param {React.Component} Component to decorate and apply
  * @returns {React.Component} - component decorated with primaryShopId and catalog as props
  */
-export default (Component) => (
+export default (Component) => {
   @inject("primaryShopId")
+  @inject("routingStore")
   @observer
   class CatalogItems extends React.Component {
+    static propTypes = {
+      primaryShopId: PropTypes.string.isRequired,
+      routingStore: PropTypes.object
+    }
+
     render() {
-      const { primaryShopId } = this.props || {};
+      const { primaryShopId, routingStore } = this.props || {};
+      const variables = {
+        shopId: primaryShopId,
+        ...paginationVariablesFromUrlParams(routingStore.query, { defaultPageLimit: PAGE_LIMIT })
+      };
 
       return (
-        <Query query={catalogItemsQuery} variables={{ shopId: primaryShopId, first: 25 }}>
-          {({ loading, data }) => {
+        <Query query={catalogItemsQuery} variables={variables}>
+          {({ loading, data, fetchMore }) => {
             if (loading) return null;
 
             const { catalogItems } = data || {};
 
             return (
-              <Component {...this.props} catalogItems={catalogItems.edges} />
+              <Component
+                {...this.props}
+                catalogItemsPageInfo={pagination({
+                  fetchMore,
+                  routingStore,
+                  data,
+                  queryName: "catalogItems",
+                  limit: PAGE_LIMIT
+                })}
+                catalogItems={catalogItems && catalogItems.edges}
+              />
             );
           }}
         </Query>
       );
     }
   }
-);
+
+  return CatalogItems;
+};
diff --git a/src/containers/tags/withNavigationTags.js b/src/containers/tags/withNavigationTags.js
index 89892740..8b3f3462 100644
--- a/src/containers/tags/withNavigationTags.js
+++ b/src/containers/tags/withNavigationTags.js
@@ -75,7 +75,7 @@ export default (Component) => (
     /**
      * Generates a tree from the given array of subTagIds.
      * @param {Function} fetchMore - Function used to fetch more data from a graphql endpoint
-     * @param {Object} data - Data from previous query, used for recustion to fetch all tags
+     * @param {Object} data - Data from previous query, used for recursion to fetch all tags
      * @return {undefined} no return value
      */
     fetchMoreDataIfNecessary = (fetchMore, data) => {
@@ -112,7 +112,7 @@ export default (Component) => (
               };
             }
 
-            // Send the previous result if the new result contians no additional data
+            // Send the previous result if the new result contains no additional data
             return previousResult;
           }
         }).catch(() => {
diff --git a/src/lib/helpers/pagination.js b/src/lib/helpers/pagination.js
new file mode 100644
index 00000000..749cc046
--- /dev/null
+++ b/src/lib/helpers/pagination.js
@@ -0,0 +1,139 @@
+/**
+ * Load next page of content for a Apollo GraphQL query
+ * @name loadNextPage
+ * @param {Object} args Args for pagination
+ * @param {String} args.queryName Name of the GraphQL whose result will be used to paginate
+ * @param {Object} args.data Full result from GraphQl
+ * @param {Object} args.limit Limit
+ * @param {Object} args.fetchMore fetchMore function
+ * @returns {Function} load next page function
+ */
+export const loadNextPage = ({ queryName, data, limit, fetchMore, routingStore }) => () => {
+  if (!queryName) throw new Error("queryName is required");
+
+  const cursor = data[queryName].pageInfo.endCursor;
+
+  // Set URL search params to allow for link sharing
+  if (routingStore) {
+    routingStore.setSearch(`first=${limit}&after=${cursor}`);
+  }
+
+  fetchMore({
+    variables: {
+      first: limit,
+      after: cursor,
+      last: null,
+      before: null
+    },
+    updateQuery: (previousResult, { fetchMoreResult }) => {
+      const { [queryName]: items } = fetchMoreResult;
+
+      // Return with additional results
+      if (items.edges.length) {
+        return fetchMoreResult;
+      }
+
+      // Send the previous result if the new result contains no additional data
+      return previousResult;
+    }
+  });
+};
+
+/**
+ * Load previous page of content for a Apollo GraphQL query
+ * @name loadPreviousPage
+ * @param {Object} args Args for pagination
+ * @param {String} args.queryName Name of the GraphQL whose result will be used to paginate
+ * @param {Object} args.data Full result from GraphQl
+ * @param {Object} args.limit Limit
+ * @param {Object} args.fetchMore fetchMore function
+ * @returns {Function} load next page function
+ */
+export const loadPreviousPage = ({ queryName, data, limit, fetchMore, routingStore }) => () => {
+  if (!queryName) throw new Error("queryName is required");
+
+  const cursor = data[queryName].pageInfo.startCursor;
+
+  // Set URL search params to allow for link sharing
+  if (routingStore) {
+    routingStore.setSearch(`last=${limit}&before=${cursor}`);
+  }
+
+  fetchMore({
+    variables: {
+      first: null,
+      after: null,
+      last: limit,
+      before: cursor
+    },
+    updateQuery: (previousResult, { fetchMoreResult }) => {
+      const { [queryName]: items } = fetchMoreResult;
+
+      // Return with additional results
+      if (items.edges.length) {
+        return fetchMoreResult;
+      }
+
+      // Send the previous result if the new result contains no additional data
+      return previousResult;
+    }
+  });
+};
+
+/**
+ * Create pagination functions for next and previous and page info data
+ * @name pagination
+ * @param {Object} args Args for pagination
+ * @param {String} args.queryName Name of the GraphQL whose result will be used to paginate
+ * @param {Object} args.data Full result from GraphQl
+ * @param {Object} args.limit Limit
+ * @param {Object} args.fetchMore fetchMore function
+ * @returns {Function} load next page function
+ */
+export const pagination = (args) => {
+  const { queryName, data } = args;
+
+  if (!queryName) throw new Error("queryName is required");
+
+  const pageInfo = (data && data[queryName] && data[queryName].pageInfo) || {};
+
+  return {
+    ...pageInfo,
+    loadNextPage: loadNextPage(args),
+    loadPreviousPage: loadPreviousPage(args)
+  };
+};
+
+/**
+ * Create an object of variables for pagination a GraphQL query.
+ * @name paginationVariablesFromUrlParams
+ * @param {Object} params Object of params to create query variables from
+ * @param {Number|String} params.first Limit starting at the beginning of the list
+ * @param {Number|String} params.last Limit starting at the end of the list
+ * @param {String} params.before Before Cursor
+ * @param {String} params.after After cursor
+ * @param {Options} options Additional options
+ * @param {Number} options.defaultLimit After cursor
+ * @returns {Object} Object of variables for GraphQL query
+ */
+export const paginationVariablesFromUrlParams = (params, options) => {
+  const { first, last, before, after } = params || {};
+  const { defaultPageLimit } = options || {};
+  const variables = {};
+
+  if (first) {
+    variables.first = parseInt(first, 10);
+  } else if (last) {
+    variables.last = parseInt(last, 10);
+  } else if (defaultPageLimit) {
+    variables.first = defaultPageLimit;
+  }
+
+  if (after) {
+    variables.after = after;
+  } else if (before) {
+    variables.before = before;
+  }
+
+  return variables;
+};
diff --git a/src/lib/stores/RoutingStore.js b/src/lib/stores/RoutingStore.js
index 8b1ff543..ce70ac0e 100644
--- a/src/lib/stores/RoutingStore.js
+++ b/src/lib/stores/RoutingStore.js
@@ -1,4 +1,5 @@
-import { observable, computed } from "mobx";
+import { action, observable, computed } from "mobx";
+import { Router } from "routes";
 
 /**
  * A mobx store for routing data
@@ -14,11 +15,11 @@ export default class RoutingStore {
   @observable _pathname = "";
 
   /**
-   * The query string of the current page (i.e. `shop`)
+   * The query params for the current page (i.e. `{shop: `1234', first: 24}`)
    *
-   * @type @observable
+   * @type Object
    */
-  @observable _query = "";
+  @observable _query = {};
 
   @computed
   get pathname() {
@@ -37,4 +38,16 @@ export default class RoutingStore {
   set query(value) {
     this._query = value;
   }
+
+  /**
+   * Set a query string for the current route
+   * @name setSearch
+   * @param {String} search Search query string first=1&after=123
+   * @returns {String} full url with query string
+   */
+  @action setSearch(search) {
+    const path = `${this._pathname}?${search}`;
+    Router.pushRoute(path, path, { shallow: true });
+    return path;
+  }
 }
diff --git a/src/pages/index.js b/src/pages/index.js
index 97662454..6bdda9d0 100644
--- a/src/pages/index.js
+++ b/src/pages/index.js
@@ -11,15 +11,16 @@ import Layout from "components/Layout";
 import ProductGrid from "components/ProductGrid";
 
 @withData
+@withRoot
 @withShop
 @withCatalogItems
-@withRoot
 @inject("shop")
 @inject("uiStore")
 @observer
 class Shop extends Component {
   static propTypes = {
     catalogItems: PropTypes.array.isRequired,
+    catalogItemsPageInfo: PropTypes.object,
     shop: PropTypes.object
   };
 
@@ -38,7 +39,10 @@ class Shop extends Component {
     return (
       <Layout title="Reaction Shop">
         {this.renderHelmet()}
-        <ProductGrid catalogItems={this.props.catalogItems}/>
+        <ProductGrid
+          catalogItems={this.props.catalogItems}
+          pageInfo={this.props.catalogItemsPageInfo}
+        />
       </Layout>
     );
   }
diff --git a/src/pages/tag.js b/src/pages/tag.js
index bd3509d1..d5fbe9fd 100644
--- a/src/pages/tag.js
+++ b/src/pages/tag.js
@@ -10,9 +10,9 @@ import Layout from "components/Layout";
 import ProductGrid from "components/ProductGrid";
 
 @withData
+@withRoot
 @withShop
 @withCatalogItems
-@withRoot
 @inject("shop")
 @inject("routingStore")
 @inject("uiStore")
@@ -20,6 +20,7 @@ import ProductGrid from "components/ProductGrid";
 export default class TagShop extends Component {
   static propTypes = {
     catalogItems: PropTypes.array.isRequired,
+    catalogItemsPageInfo: PropTypes.object,
     routingStore: PropTypes.object,
     shop: PropTypes.object
   };
@@ -38,12 +39,15 @@ export default class TagShop extends Component {
   }
 
   render() {
-    const { catalogItems } = this.props;
+    const { catalogItems, catalogItemsPageInfo } = this.props;
 
     return (
       <Layout title="Reaction Shop">
         {this.renderHelmet()}
-        <ProductGrid catalogItems={catalogItems} />
+        <ProductGrid
+          catalogItems={catalogItems}
+          pageInfo={catalogItemsPageInfo}
+        />
       </Layout>
     );
   }


Test patch:
diff --git a/src/components/PageStepper/PageStepper.test.js b/src/components/PageStepper/PageStepper.test.js
new file mode 100644
index 00000000..128dc68b
--- /dev/null
+++ b/src/components/PageStepper/PageStepper.test.js
@@ -0,0 +1,63 @@
+import React from "react";
+import renderer from "react-test-renderer";
+import { mount } from "enzyme";
+import PageStepper from "./PageStepper";
+
+test("shows next button, hides previous button", () => {
+  const pageInfo = {
+    hasNextPage: true,
+    hasPreviousPage: false
+  };
+
+  const component = renderer.create(<PageStepper pageInfo={pageInfo} />);
+  const tree = component.toJSON();
+  expect(tree).toMatchSnapshot();
+});
+
+test("hides next button, shows previous button", () => {
+  const pageInfo = {
+    hasNextPage: false,
+    hasPreviousPage: true
+  };
+
+  const component = renderer.create(<PageStepper pageInfo={pageInfo} />);
+  const tree = component.toJSON();
+  expect(tree).toMatchSnapshot();
+});
+
+test("shows both next and previous buttons", () => {
+  const pageInfo = {
+    hasNextPage: true,
+    hasPreviousPage: true
+  };
+
+  const component = renderer.create(<PageStepper pageInfo={pageInfo} />);
+  const tree = component.toJSON();
+  expect(tree).toMatchSnapshot();
+});
+
+test("click next button", () => {
+  const pageInfo = {
+    hasNextPage: true,
+    loadNextPage: jest.fn()
+  };
+
+  const wrapper = mount(<PageStepper pageInfo={pageInfo} />);
+
+  expect(wrapper.find("button").length).toBe(1);
+  wrapper.find("button").simulate("click");
+  expect(pageInfo.loadNextPage).toHaveBeenCalled();
+});
+
+test("click previous button", () => {
+  const pageInfo = {
+    hasPreviousPage: true,
+    loadPreviousPage: jest.fn()
+  };
+
+  const wrapper = mount(<PageStepper pageInfo={pageInfo} />);
+
+  expect(wrapper.find("button").length).toBe(1);
+  wrapper.find("button").simulate("click");
+  expect(pageInfo.loadPreviousPage).toHaveBeenCalled();
+});
diff --git a/src/components/PageStepper/__snapshots__/PageStepper.test.js.snap b/src/components/PageStepper/__snapshots__/PageStepper.test.js.snap
new file mode 100644
index 00000000..aab16256
--- /dev/null
+++ b/src/components/PageStepper/__snapshots__/PageStepper.test.js.snap
@@ -0,0 +1,148 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`hides next button, shows previous button 1`] = `
+<div
+  className="MuiGrid-container-2 MuiGrid-justify-xs-space-between-21 ProductGrid-root-1"
+>
+  <div
+    className="MuiGrid-item-3"
+  >
+    <button
+      className="MuiButtonBase-root-107 MuiButton-root-92 MuiButton-raised-97"
+      disabled={false}
+      onBlur={[Function]}
+      onClick={undefined}
+      onFocus={[Function]}
+      onKeyDown={[Function]}
+      onKeyUp={[Function]}
+      onMouseDown={[Function]}
+      onMouseLeave={[Function]}
+      onMouseUp={[Function]}
+      onTouchEnd={[Function]}
+      onTouchMove={[Function]}
+      onTouchStart={[Function]}
+      tabIndex="0"
+      type="button"
+    >
+      <span
+        className="MuiButton-label-93"
+      >
+        Previous
+      </span>
+      <span
+        className="MuiTouchRipple-root-110"
+      />
+    </button>
+  </div>
+  <div
+    className="MuiGrid-item-3"
+  />
+</div>
+`;
+
+exports[`shows both next and previous buttons 1`] = `
+<div
+  className="MuiGrid-container-2 MuiGrid-justify-xs-space-between-21 ProductGrid-root-1"
+>
+  <div
+    className="MuiGrid-item-3"
+  >
+    <button
+      className="MuiButtonBase-root-107 MuiButton-root-92 MuiButton-raised-97"
+      disabled={false}
+      onBlur={[Function]}
+      onClick={undefined}
+      onFocus={[Function]}
+      onKeyDown={[Function]}
+      onKeyUp={[Function]}
+      onMouseDown={[Function]}
+      onMouseLeave={[Function]}
+      onMouseUp={[Function]}
+      onTouchEnd={[Function]}
+      onTouchMove={[Function]}
+      onTouchStart={[Function]}
+      tabIndex="0"
+      type="button"
+    >
+      <span
+        className="MuiButton-label-93"
+      >
+        Previous
+      </span>
+      <span
+        className="MuiTouchRipple-root-110"
+      />
+    </button>
+  </div>
+  <div
+    className="MuiGrid-item-3"
+  >
+    <button
+      className="MuiButtonBase-root-107 MuiButton-root-92 MuiButton-raised-97"
+      disabled={false}
+      onBlur={[Function]}
+      onClick={undefined}
+      onFocus={[Function]}
+      onKeyDown={[Function]}
+      onKeyUp={[Function]}
+      onMouseDown={[Function]}
+      onMouseLeave={[Function]}
+      onMouseUp={[Function]}
+      onTouchEnd={[Function]}
+      onTouchMove={[Function]}
+      onTouchStart={[Function]}
+      tabIndex="0"
+      type="button"
+    >
+      <span
+        className="MuiButton-label-93"
+      >
+        Next
+      </span>
+      <span
+        className="MuiTouchRipple-root-110"
+      />
+    </button>
+  </div>
+</div>
+`;
+
+exports[`shows next button, hides previous button 1`] = `
+<div
+  className="MuiGrid-container-2 MuiGrid-justify-xs-space-between-21 ProductGrid-root-1"
+>
+  <div
+    className="MuiGrid-item-3"
+  />
+  <div
+    className="MuiGrid-item-3"
+  >
+    <button
+      className="MuiButtonBase-root-107 MuiButton-root-92 MuiButton-raised-97"
+      disabled={false}
+      onBlur={[Function]}
+      onClick={undefined}
+      onFocus={[Function]}
+      onKeyDown={[Function]}
+      onKeyUp={[Function]}
+      onMouseDown={[Function]}
+      onMouseLeave={[Function]}
+      onMouseUp={[Function]}
+      onTouchEnd={[Function]}
+      onTouchMove={[Function]}
+      onTouchStart={[Function]}
+      tabIndex="0"
+      type="button"
+    >
+      <span
+        className="MuiButton-label-93"
+      >
+        Next
+      </span>
+      <span
+        className="MuiTouchRipple-root-110"
+      />
+    </button>
+  </div>
+</div>
+`;
diff --git a/src/components/ProductGrid/ProductGrid.test.js b/src/components/ProductGrid/ProductGrid.test.js
index 40a2da54..ec5b1e70 100644
--- a/src/components/ProductGrid/ProductGrid.test.js
+++ b/src/components/ProductGrid/ProductGrid.test.js
@@ -15,10 +15,19 @@ const uiStore = {
 };
 
 test("basic snapshot", () => {
+  const pageInfo = {
+    hasNextPage: false,
+    hasPreviousPage: true,
+    loadNextPage: () => {},
+    loadPreviousPage: () => {},
+    startCursor: "",
+    endCursor: ""
+  };
+
   const component = renderer.create((
     <MuiThemeProvider theme={theme}>
       <Provider uiStore={uiStore}>
-        <ProductGrid catalogItems={products} primaryShopId="123" />
+        <ProductGrid catalogItems={products} pageInfo={pageInfo} primaryShopId="123" />
       </Provider>
     </MuiThemeProvider>
   ));
diff --git a/src/components/ProductGrid/__snapshots__/ProductGrid.test.js.snap b/src/components/ProductGrid/__snapshots__/ProductGrid.test.js.snap
index 1582ec60..271ecf89 100644
--- a/src/components/ProductGrid/__snapshots__/ProductGrid.test.js.snap
+++ b/src/components/ProductGrid/__snapshots__/ProductGrid.test.js.snap
@@ -60,7 +60,7 @@ exports[`basic snapshot 1`] = `
             >
               <svg
                 aria-hidden="true"
-                className="MuiSvgIcon-root-133 inject-ProductItem-with-uiStore-loadingIcon-97"
+                className="MuiSvgIcon-root-159 inject-ProductItem-with-uiStore-loadingIcon-97"
                 color={undefined}
                 focusable="false"
                 viewBox="0 0 24 24"
@@ -142,7 +142,7 @@ exports[`basic snapshot 1`] = `
             >
               <svg
                 aria-hidden="true"
-                className="MuiSvgIcon-root-133 inject-ProductItem-with-uiStore-loadingIcon-97"
+                className="MuiSvgIcon-root-159 inject-ProductItem-with-uiStore-loadingIcon-97"
                 color={undefined}
                 focusable="false"
                 viewBox="0 0 24 24"
@@ -181,5 +181,42 @@ exports[`basic snapshot 1`] = `
       </div>
     </div>
   </div>
+  <div
+    className="MuiGrid-container-2 MuiGrid-justify-xs-space-between-21 ProductGrid-root-133"
+  >
+    <div
+      className="MuiGrid-item-3"
+    >
+      <button
+        className="MuiButtonBase-root-149 MuiButton-root-134 MuiButton-raised-139"
+        disabled={false}
+        onBlur={[Function]}
+        onClick={[Function]}
+        onFocus={[Function]}
+        onKeyDown={[Function]}
+        onKeyUp={[Function]}
+        onMouseDown={[Function]}
+        onMouseLeave={[Function]}
+        onMouseUp={[Function]}
+        onTouchEnd={[Function]}
+        onTouchMove={[Function]}
+        onTouchStart={[Function]}
+        tabIndex="0"
+        type="button"
+      >
+        <span
+          className="MuiButton-label-135"
+        >
+          Previous
+        </span>
+        <span
+          className="MuiTouchRipple-root-152"
+        />
+      </button>
+    </div>
+    <div
+      className="MuiGrid-item-3"
+    />
+  </div>
 </section>
 `;
diff --git a/src/lib/helpers/pagination.test.js b/src/lib/helpers/pagination.test.js
new file mode 100644
index 00000000..ccba50f7
--- /dev/null
+++ b/src/lib/helpers/pagination.test.js
@@ -0,0 +1,115 @@
+import {
+  loadNextPage,
+  loadPreviousPage,
+  pagination,
+  paginationVariablesFromUrlParams
+} from "./pagination";
+
+const args = {
+  fetchMore: jest.fn(({ updateQuery }) => {
+    updateQuery(args.data, {
+      fetchMoreResult: {
+        test: {
+          edges: []
+        }
+      }
+    });
+  }),
+  routingStore: {
+    setSearch: jest.fn()
+  },
+  queryName: "test",
+  limit: 5,
+  data: {
+    test: {
+      pageInfo: {
+        hasNextPage: true,
+        hasPreviousPage: false,
+        startCursor: "1",
+        endCursor: "5"
+      },
+      edges: [
+        { cursor: "1", node: { title: "1" } },
+        { cursor: "2", node: { title: "2" } },
+        { cursor: "3", node: { title: "3" } },
+        { cursor: "4", node: { title: "4" } },
+        { cursor: "5", node: { title: "5" } }
+      ]
+    }
+  }
+};
+
+test("pagination helper creates helper function props", () => {
+  const props = pagination(args);
+
+  expect(typeof props.loadNextPage).toBe("function");
+  expect(typeof props.loadPreviousPage).toBe("function");
+});
+
+
+test("loadNextPage helper creates helper function", () => {
+  const func = loadNextPage(args);
+
+  // Call function to ensure methods it calls, are called
+  func();
+  expect(args.fetchMore).toHaveBeenCalled();
+  expect(args.routingStore.setSearch).toHaveBeenCalled();
+});
+
+test("loadPreviousPage helper creates helper function", () => {
+  const func = loadPreviousPage(args);
+
+  // Call function to ensure methods it calls, are called
+  func();
+  expect(args.fetchMore).toHaveBeenCalled();
+  expect(args.routingStore.setSearch).toHaveBeenCalled();
+});
+
+test("pagination variables from url params, (first, after)", () => {
+  const variables = paginationVariablesFromUrlParams({
+    first: 2,
+    after: "6"
+  });
+
+  expect(variables).toEqual({
+    first: 2,
+    after: "6"
+  });
+});
+
+test("pagination variables from url params, (before, last)", () => {
+  const variables = paginationVariablesFromUrlParams({
+    last: 10,
+    before: "3"
+  });
+
+  expect(variables).toEqual({
+    last: 10,
+    before: "3"
+  });
+});
+
+test("pagination variables from url params (first, after) supercede (last, before)", () => {
+  const variables = paginationVariablesFromUrlParams({
+    first: 2,
+    after: "6",
+    last: 10,
+    before: "3"
+  });
+
+  expect(variables).toEqual({
+    first: 2,
+    after: "6"
+  });
+});
+
+test("pagination variables from url params with defaultPageLimit", () => {
+  const variables = paginationVariablesFromUrlParams({
+    after: "6"
+  }, { defaultPageLimit: 10 });
+
+  expect(variables).toEqual({
+    first: 10,
+    after: "6"
+  });
+});