Browse Source

Update explorer for latest scope, UI, with tests

Janneke Janssen 8 years ago
parent
commit
2ff4a5aad1
100 changed files with 3427 additions and 2043 deletions
  1. 8 0
      .eslintrc
  2. 4 1
      client/scss/_components.scss
  3. 7 1
      client/scss/_objects.scss
  4. 14 0
      client/scss/_tools.breakpoints.scss
  5. 3 0
      client/scss/_utilities.scss
  6. 0 3
      client/scss/objects/_o.icon.scss
  7. 0 4
      client/scss/states/_animations.scss
  8. 0 0
      client/scss/states/_states.scss
  9. 3 4
      client/scss/styles.scss
  10. 0 0
      client/scss/themes/_themes.scss
  11. 0 9
      client/scss/utilities/_utilities.scss
  12. 21 0
      client/src/api/__snapshots__/client.test.js.snap
  13. 18 9
      client/src/api/admin.js
  14. 53 0
      client/src/api/admin.test.js
  15. 47 29
      client/src/api/client.js
  16. 43 0
      client/src/api/client.test.js
  17. 0 23
      client/src/components/AbsoluteDate/AbsoluteDate.js
  18. 0 18
      client/src/components/AbsoluteDate/AbsoluteDate.test.js
  19. 0 11
      client/src/components/AbsoluteDate/__snapshots__/AbsoluteDate.test.js.snap
  20. 80 79
      client/src/components/Button/Button.js
  21. 17 0
      client/src/components/Button/Button.test.js
  22. 34 6
      client/src/components/Button/__snapshots__/Button.test.js.snap
  23. 52 0
      client/src/components/Explorer/Explorer.js
  24. 124 0
      client/src/components/Explorer/Explorer.scss
  25. 46 3
      client/src/components/Explorer/Explorer.test.js
  26. 37 0
      client/src/components/Explorer/ExplorerHeader.js
  27. 36 0
      client/src/components/Explorer/ExplorerHeader.test.js
  28. 59 0
      client/src/components/Explorer/ExplorerItem.js
  29. 85 0
      client/src/components/Explorer/ExplorerItem.scss
  30. 30 8
      client/src/components/Explorer/ExplorerItem.test.js
  31. 175 0
      client/src/components/Explorer/ExplorerPanel.js
  32. 182 0
      client/src/components/Explorer/ExplorerPanel.test.js
  33. 36 0
      client/src/components/Explorer/ExplorerToggle.js
  34. 16 16
      client/src/components/Explorer/ExplorerToggle.test.js
  35. 18 15
      client/src/components/Explorer/PageCount.js
  36. 35 0
      client/src/components/Explorer/PageCount.test.js
  37. 106 0
      client/src/components/Explorer/__snapshots__/Explorer.test.js.snap
  38. 79 0
      client/src/components/Explorer/__snapshots__/ExplorerHeader.test.js.snap
  39. 235 66
      client/src/components/Explorer/__snapshots__/ExplorerItem.test.js.snap
  40. 325 0
      client/src/components/Explorer/__snapshots__/ExplorerPanel.test.js.snap
  41. 4 41
      client/src/components/Explorer/__snapshots__/ExplorerToggle.test.js.snap
  42. 0 11
      client/src/components/Explorer/__snapshots__/LoadingSpinner.test.js.snap
  43. 55 0
      client/src/components/Explorer/__snapshots__/PageCount.test.js.snap
  44. 90 0
      client/src/components/Explorer/__snapshots__/actions.test.js.snap
  45. 94 0
      client/src/components/Explorer/actions.js
  46. 99 0
      client/src/components/Explorer/actions.test.js
  47. 51 0
      client/src/components/Explorer/index.js
  48. 28 0
      client/src/components/Explorer/index.test.js
  49. 26 0
      client/src/components/Explorer/reducers/__snapshots__/explorer.test.js.snap
  50. 142 0
      client/src/components/Explorer/reducers/__snapshots__/nodes.test.js.snap
  51. 39 0
      client/src/components/Explorer/reducers/explorer.js
  52. 32 0
      client/src/components/Explorer/reducers/explorer.test.js
  53. 0 8
      client/src/components/Explorer/reducers/index.test.js
  54. 69 0
      client/src/components/Explorer/reducers/nodes.js
  55. 59 0
      client/src/components/Explorer/reducers/nodes.test.js
  56. 6 1
      client/src/components/Icon/Icon.js
  57. 21 10
      client/src/components/Icon/__snapshots__/Icon.test.js.snap
  58. 0 10
      client/src/components/LoadingIndicator/LoadingIndicator.js
  59. 0 14
      client/src/components/LoadingIndicator/LoadingIndicator.test.js
  60. 0 9
      client/src/components/LoadingIndicator/__snapshots__/LoadingIndicator.test.js.snap
  61. 14 0
      client/src/components/LoadingSpinner/LoadingSpinner.js
  62. 5 0
      client/src/components/LoadingSpinner/LoadingSpinner.scss
  63. 0 0
      client/src/components/LoadingSpinner/LoadingSpinner.test.js
  64. 12 0
      client/src/components/LoadingSpinner/__snapshots__/LoadingSpinner.test.js.snap
  65. 9 3
      client/src/components/PublicationStatus/PublicationStatus.js
  66. 6 0
      client/src/components/PublicationStatus/PublicationStatus.scss
  67. 0 4
      client/src/components/PublicationStatus/PublicationStatus.test.js
  68. 0 19
      client/src/components/PublicationStatus/README.md
  69. 6 4
      client/src/components/PublicationStatus/__snapshots__/PublicationStatus.test.js.snap
  70. 48 0
      client/src/components/Transition/Transition.js
  71. 85 0
      client/src/components/Transition/Transition.scss
  72. 14 0
      client/src/components/Transition/Transition.test.js
  73. 14 0
      client/src/components/Transition/__snapshots__/Transition.test.js.snap
  74. 0 116
      client/src/components/explorer/Explorer.js
  75. 0 49
      client/src/components/explorer/ExplorerHeader.js
  76. 0 51
      client/src/components/explorer/ExplorerItem.js
  77. 0 193
      client/src/components/explorer/ExplorerPanel.js
  78. 0 41
      client/src/components/explorer/ExplorerToggle.js
  79. 0 11
      client/src/components/explorer/LoadingSpinner.js
  80. 0 99
      client/src/components/explorer/actions/index.js
  81. 0 92
      client/src/components/explorer/reducers/explorer.js
  82. 0 55
      client/src/components/explorer/reducers/explorer.test.js
  83. 0 13
      client/src/components/explorer/reducers/index.js
  84. 0 93
      client/src/components/explorer/reducers/nodes.js
  85. 0 72
      client/src/components/explorer/reducers/nodes.test.js
  86. 0 23
      client/src/components/explorer/reducers/transport.js
  87. 0 36
      client/src/components/explorer/reducers/transport.test.js
  88. 0 323
      client/src/components/explorer/style.scss
  89. 0 23
      client/src/components/icon/README.md
  90. 0 3
      client/src/config/config.js
  91. 0 19
      client/src/config/config.test.js
  92. 2 1
      client/src/config/wagtailConfig.js
  93. 5 5
      client/src/config/wagtailConfig.test.js
  94. 11 6
      client/src/index.js
  95. 35 32
      client/src/index.test.js
  96. 38 0
      client/tests/mock-fetch.js
  97. 8 3
      client/tests/stubs.js
  98. 39 21
      client/webpack/base.config.js
  99. 226 216
      npm-shrinkwrap.json
  100. 7 8
      package.json

+ 8 - 0
.eslintrc

@@ -3,5 +3,13 @@
 
   "env": {
     "jest": true
+  },
+
+  "settings": {
+    "import/resolver": {
+      "webpack": {
+        "config": "client/webpack/prod.config.js"
+      }
+    }
   }
 }

+ 4 - 1
client/scss/_components.scss

@@ -1 +1,4 @@
-@import '../src/components/explorer/style';
+@import '../src/components/Transition/Transition';
+@import '../src/components/LoadingSpinner/LoadingSpinner';
+@import '../src/components/PublicationStatus/PublicationStatus';
+@import '../src/components/Explorer/Explorer';

+ 7 - 1
client/scss/_objects.scss

@@ -1 +1,7 @@
-@import 'objects/o.icon';
+.o-pill {
+    display: inline-block;
+    padding: .2em .5em;
+    border-radius: .25em;
+    vertical-align: middle;
+    line-height: 1.5;
+}

+ 14 - 0
client/scss/_tools.breakpoints.scss

@@ -0,0 +1,14 @@
+$breakpoint-small: $breakpoint-mobile - 0.0625em;
+$breakpoint-medium: $breakpoint-mobile;
+
+@mixin small {
+    @media only screen and (max-width: $breakpoint-small) {
+        @content;
+    }
+}
+
+@mixin medium {
+    @media only screen and (min-width: $breakpoint-medium) {
+        @content;
+    }
+}

+ 3 - 0
client/scss/_utilities.scss

@@ -0,0 +1,3 @@
+.u-hidden {
+    display: none;
+}

+ 0 - 3
client/scss/objects/_o.icon.scss

@@ -1,3 +0,0 @@
-.o-icon {
-    display: inline-block;
-}

+ 0 - 4
client/scss/states/_animations.scss

@@ -1,4 +0,0 @@
-.is-spinning {
-
-}
-

+ 0 - 0
client/scss/states/_states.scss


+ 3 - 4
client/scss/style.scss → client/scss/styles.scss

@@ -2,9 +2,8 @@
 // Wagtail CMS main stylesheet
 // =============================================================================
 
+
+@import 'tools.breakpoints';
 @import 'objects';
 @import 'components';
-@import 'states/states';
-@import 'states/animations';
-@import 'utilities/utilities';
-@import 'themes/themes';
+@import 'utilities';

+ 0 - 0
client/scss/themes/_themes.scss


+ 0 - 9
client/scss/utilities/_utilities.scss

@@ -1,9 +0,0 @@
-.u-text-center {
-  text-align: center;
-}
-
-@media screen and (min-width: 15em) {
-  .u-text-center\@sm {
-    text-align: center;
-  }
-}

+ 21 - 0
client/src/api/__snapshots__/client.test.js.snap

@@ -0,0 +1,21 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`client API should crash fetching 1`] = `
+Object {
+  "status": 500,
+  "statusText": "Internal Error",
+}
+`;
+
+exports[`client API should fail fetching 1`] = `[Error: Internal Error]`;
+
+exports[`client API should succeed fetching 1`] = `
+Object {
+  "items": Array [],
+  "meta": Object {
+    "total_count": 1,
+  },
+}
+`;
+
+exports[`client API should timeout fetching 1`] = `[Error: Response timeout]`;

+ 18 - 9
client/src/api/admin.js

@@ -1,22 +1,31 @@
 import { get } from '../api/client';
 
-import { ADMIN_API } from '../config/wagtail';
+import { ADMIN_API } from '../config/wagtailConfig';
 
-export const getChildPages = (id, options = {}) => {
+
+export const getPage = (id) => {
+  const url = `${ADMIN_API.PAGES}${id}/`;
+
+  return get(url);
+};
+
+export const getPageChildren = (id, options = {}) => {
   let url = `${ADMIN_API.PAGES}?child_of=${id}`;
 
   if (options.fields) {
     url += `&fields=${global.encodeURIComponent(options.fields.join(','))}`;
   }
 
-  // Only show pages that have children for now
-  url += `&has_children=1`;
+  if (options.onlyWithChildren) {
+    url += '&has_children=1';
+  }
 
-  return get(url).then(res => res.body);
-};
+  if (options.offset) {
+    url += `&offset=${options.offset}`;
+  }
 
-export const getPage = (id) => {
-  const url = `${ADMIN_API.PAGES}${id}/`;
+  // TODO To remove once we are done testing this.
+  url += ADMIN_API.EXTRA_CHILDREN_PARAMETERS;
 
-  return get(url).then(res => res.body);
+  return get(url);
 };

+ 53 - 0
client/src/api/admin.test.js

@@ -0,0 +1,53 @@
+import { ADMIN_API } from '../config/wagtailConfig';
+import { getPageChildren, getPage } from './admin';
+import * as client from './client';
+
+const stubResult = {
+  __types: {
+    test: {
+      verbose_name: 'Test',
+    },
+  },
+  items: [
+    { meta: { type: 'test' } },
+    { meta: { type: 'foo' } },
+  ],
+};
+
+client.get = jest.fn(() => Promise.resolve(stubResult));
+
+describe('admin API', () => {
+  describe('getPageChildren', () => {
+    it('works', () => {
+      getPageChildren(3);
+      expect(client.get).toBeCalledWith(`${ADMIN_API.PAGES}?child_of=3`);
+    });
+
+    it('#fields', () => {
+      getPageChildren(3, { fields: ['title', 'latest_revision_created_at'] });
+      // eslint-disable-next-line max-len
+      expect(client.get).toBeCalledWith(`${ADMIN_API.PAGES}?child_of=3&fields=title%2Clatest_revision_created_at`);
+    });
+
+    it('#onlyWithChildren', () => {
+      getPageChildren(3, { onlyWithChildren: true });
+      expect(client.get).toBeCalledWith(`${ADMIN_API.PAGES}?child_of=3&has_children=1`);
+    });
+
+    it('#offset', () => {
+      getPageChildren(3, { offset: 5 });
+      expect(client.get).toBeCalledWith(`${ADMIN_API.PAGES}?child_of=3&offset=5`);
+    });
+  });
+
+  describe('getPage', () => {
+    it('should return a result by with a default id argument', () => {
+      getPage(3);
+      expect(client.get).toBeCalledWith(`${ADMIN_API.PAGES}3/`);
+    });
+  });
+
+  afterEach(() => {
+    client.get.mockClear();
+  });
+});

+ 47 - 29
client/src/api/client.js

@@ -1,39 +1,57 @@
-import _ from 'lodash';
-
 const fetch = global.fetch;
 const Headers = global.Headers;
 
-// fetch wrapper for JSON APIs.
-export const get = (url) => {
-  const headers = new Headers({
-    'Accept': 'application/json',
-    'Content-Type': 'application/json',
+const REQUEST_TIMEOUT = 15000;
+
+const checkStatus = (response) =>  {
+  if (response.status >= 200 && response.status < 300) {
+    return response;
+  }
+
+  const error = new Error(response.statusText);
+
+  throw error;
+};
+
+const parseJSON = response => response.json();
+
+// Response timeout cancelling the promise (not the request).
+// See https://github.com/github/fetch/issues/175#issuecomment-216791333.
+const timeout = (ms, promise) => {
+  const race = new Promise((resolve, reject) => {
+    const timeoutId = setTimeout(() => {
+      reject(new Error('Response timeout'));
+    }, ms);
+
+    promise.then((res) => {
+      clearTimeout(timeoutId);
+      resolve(res);
+    }, (err) => {
+      clearTimeout(timeoutId);
+      reject(err);
+    });
   });
 
+  return race;
+};
+
+/**
+ * Wrapper around fetch with sane defaults for behavior in the face of
+ * errors.
+ */
+const request = (method, url) => {
   const options = {
     credentials: 'same-origin',
-    headers: headers,
-    method: 'GET'
+    headers: new Headers({
+      'Accept': 'application/json',
+      'Content-Type': 'application/json',
+    }),
+    method: method
   };
 
-  return fetch(url, options)
-    .then((res) => {
-      const response = {
-        status: res.status,
-        statusText: res.statusText,
-        headers: res.headers
-      };
-
-      let ret;
-      if (response.status >= 200 && response.status < 300) {
-        ret = res.json().then(json => _.assign(response, { body: json }));
-      } else {
-        ret =  res.text().then((text) => {
-          const err = _.assign(new Error(response.statusText), response, { body: text });
-          throw err;
-        });
-      }
-
-      return ret;
-    });
+  return timeout(REQUEST_TIMEOUT, fetch(url, options))
+    .then(checkStatus)
+    .then(parseJSON);
 };
+
+export const get = url => request('GET', url);

+ 43 - 0
client/src/api/client.test.js

@@ -0,0 +1,43 @@
+import * as client from './client';
+
+describe('client API', () => {
+  it('should succeed fetching', (done) => {
+    const response = '{"meta":{"total_count":1},"items":[]}';
+    fetch.mockResponseSuccess(response);
+
+    client.get('/example/url').then((result) => {
+      expect(result).toMatchSnapshot();
+      done();
+    });
+  });
+
+  it('should fail fetching', (done) => {
+    fetch.mockResponseFailure();
+
+    client.get('/example/url').catch((result) => {
+      expect(result).toMatchSnapshot();
+      done();
+    });
+  });
+
+  it('should crash fetching', (done) => {
+    fetch.mockResponseCrash();
+
+    client.get('/example/url').catch((result) => {
+      expect(result).toMatchSnapshot();
+      done();
+    });
+  });
+
+  it('should timeout fetching', (done) => {
+    jest.useFakeTimers();
+    fetch.mockResponseTimeout();
+
+    client.get('/example/url').catch((result) => {
+      expect(result).toMatchSnapshot();
+      done();
+    });
+
+    jest.runOnlyPendingTimers();
+  });
+});

+ 0 - 23
client/src/components/AbsoluteDate/AbsoluteDate.js

@@ -1,23 +0,0 @@
-import React from 'react';
-import moment from 'moment';
-
-import { DATE_FORMAT, STRINGS } from '../../config/wagtail';
-
-const AbsoluteDate = ({ time }) => {
-  const date = moment(time);
-  const text = time ?  date.format(DATE_FORMAT) : STRINGS.NO_DATE;
-
-  return (
-    <span>{text}</span>
-  );
-};
-
-AbsoluteDate.propTypes = {
-  time: React.PropTypes.string,
-};
-
-AbsoluteDate.defaultProps = {
-  time: '',
-};
-
-export default AbsoluteDate;

+ 0 - 18
client/src/components/AbsoluteDate/AbsoluteDate.test.js

@@ -1,18 +0,0 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-
-import AbsoluteDate from './AbsoluteDate';
-
-describe('AbsoluteDate', () => {
-  it('exists', () => {
-    expect(AbsoluteDate).toBeDefined();
-  });
-
-  it('basic', () => {
-    expect(shallow(<AbsoluteDate />)).toMatchSnapshot();
-  });
-
-  it('#time', () => {
-    expect(shallow(<AbsoluteDate time="2016-09-19T20:22:33.356623Z" />)).toMatchSnapshot();
-  });
-});

+ 0 - 11
client/src/components/AbsoluteDate/__snapshots__/AbsoluteDate.test.js.snap

@@ -1,11 +0,0 @@
-exports[`AbsoluteDate #time 1`] = `
-<span>
-  Sep. 19, 2016
-</span>
-`;
-
-exports[`AbsoluteDate basic 1`] = `
-<span>
-  No date
-</span>
-`;

+ 80 - 79
client/src/components/Button/Button.js

@@ -1,88 +1,89 @@
 import React from 'react';
-import _ from 'lodash';
 
-/**
- * A reusable button. Uses a <a> tag underneath.
- */
-export default React.createClass({
-  propTypes: {
-    href: React.PropTypes.string,
-    className: React.PropTypes.string,
-    icon: React.PropTypes.string,
-    target: React.PropTypes.string,
-    children: React.PropTypes.node,
-    accessibleLabel: React.PropTypes.string,
-    onClick: React.PropTypes.func,
-    isLoading: React.PropTypes.bool,
-    preventDefault: React.PropTypes.bool,
-  },
-
-  getDefaultProps() {
-    return {
-      href: '#',
-      className: '',
-      icon: '',
-      target: null,
-      children: null,
-      accessibleLabel: null,
-      onClick: null,
-      isLoading: false,
-      preventDefault: true,
-    };
-  },
+const getClassName = (className, icon) => {
+  const hasIcon = icon !== '';
+  let iconName = '';
+  if (hasIcon) {
+    if (typeof icon === 'string') {
+      iconName = ` icon-${icon}`;
+    } else {
+      iconName = icon.map(val => ` icon-${val}`).join('');
+    }
+  }
+  return `${className} ${hasIcon ? 'icon' : ''}${iconName}`;
+};
 
-  handleClick(e) {
-    const { href, onClick, preventDefault } = this.props;
+const handleClick = (href, onClick, preventDefault, e) => {
+  if (preventDefault && href === '#') {
+    e.preventDefault();
+    e.stopPropagation();
+  }
 
-    if (preventDefault && href === '#') {
-      e.preventDefault();
-      e.stopPropagation();
-    }
+  if (onClick) {
+    onClick(e);
+  }
+};
 
-    if (onClick) {
-      onClick(e);
-    }
-  },
+/**
+ * A reusable button. Uses a <a> tag underneath.
+ */
+const Button = ({
+  className,
+  icon,
+  children,
+  accessibleLabel,
+  isLoading,
+  href,
+  target,
+  preventDefault,
+  onClick,
+}) => {
+  const hasText = children !== null;
+  const iconName = isLoading ? 'spinner' : icon;
+  const accessibleElt = accessibleLabel ? (
+    <span className="visuallyhidden">
+      {accessibleLabel}
+    </span>
+  ) : null;
 
-  render() {
-    const {
-      className,
-      icon,
-      children,
-      accessibleLabel,
-      isLoading,
-      target,
-    } = this.props;
+  return (
+    <a
+      className={getClassName(className, iconName)}
+      onClick={handleClick.bind(null, href, onClick, preventDefault)}
+      rel={target === '_blank' ? 'noopener noreferrer' : null}
+      href={href}
+      target={target}
+    >
+      {hasText ? children : accessibleElt}
+    </a>
+  );
+};
 
-    const props = _.omit(this.props, [
-      'className',
-      'icon',
-      'iconClassName',
-      'children',
-      'accessibleLabel',
-      'isLoading',
-      'onClick',
-      'preventDefault',
-    ]);
+Button.propTypes = {
+  href: React.PropTypes.string,
+  className: React.PropTypes.string,
+  icon: React.PropTypes.oneOfType([
+    React.PropTypes.string,
+    React.PropTypes.arrayOf(React.PropTypes.string),
+  ]),
+  target: React.PropTypes.string,
+  children: React.PropTypes.node,
+  accessibleLabel: React.PropTypes.string,
+  onClick: React.PropTypes.func,
+  isLoading: React.PropTypes.bool,
+  preventDefault: React.PropTypes.bool,
+};
 
-    const hasIcon = icon !== '';
-    const hasText = children !== null;
-    const iconName = isLoading ? 'spinner' : icon;
-    const accessibleElt = accessibleLabel ? (
-      <span className="visuallyhidden">
-        {accessibleLabel}
-      </span>
-    ) : null;
+Button.defaultProps = {
+  href: '#',
+  className: '',
+  icon: '',
+  target: null,
+  children: null,
+  accessibleLabel: null,
+  onClick: null,
+  isLoading: false,
+  preventDefault: true,
+};
 
-    return (
-      <a
-        className={`${className} ${hasIcon ? 'icon icon-' : ''}${iconName}`}
-        onClick={this.handleClick}
-        rel={target === '_blank' ? 'noopener' : null}
-        {...props}
-      >
-        {hasText ? children : accessibleElt}
-      </a>
-    );
-  },
-});
+export default Button;

+ 17 - 0
client/src/components/Button/Button.test.js

@@ -24,6 +24,14 @@ describe('Button', () => {
     expect(shallow(<Button icon="test-icon" />)).toMatchSnapshot();
   });
 
+  it('#target', () => {
+    expect(shallow(<Button target="_blank" />)).toMatchSnapshot();
+  });
+
+  it('#multiple icons', () => {
+    expect(shallow(<Button icon={['test-icon', 'secondary-icon']} />)).toMatchSnapshot();
+  });
+
   it('#icon changes with #isLoading', () => {
     expect(shallow(<Button icon="test-icon" isLoading={true} />)).toMatchSnapshot();
   });
@@ -36,4 +44,13 @@ describe('Button', () => {
     });
     expect(onClick).toHaveBeenCalledTimes(1);
   });
+
+  it('dismisses clicks', () => {
+    const preventDefault = jest.fn();
+    shallow(<Button />).simulate('click', {
+      preventDefault,
+      stopPropagation() {},
+    });
+    expect(preventDefault).toHaveBeenCalledTimes(1);
+  });
 });

+ 34 - 6
client/src/components/Button/__snapshots__/Button.test.js.snap

@@ -1,12 +1,16 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
 exports[`Button #accessibleLabel 1`] = `
 <a
   className=" "
   href="#"
   onClick={[Function]}
   rel={null}
-  target={null}>
+  target={null}
+>
   <span
-    className="visuallyhidden">
+    className="visuallyhidden"
+  >
     I am here in the shadows
   </span>
 </a>
@@ -18,7 +22,8 @@ exports[`Button #children 1`] = `
   href="#"
   onClick={[Function]}
   rel={null}
-  target={null}>
+  target={null}
+>
   To infinity and beyond!
 </a>
 `;
@@ -29,7 +34,8 @@ exports[`Button #icon 1`] = `
   href="#"
   onClick={[Function]}
   rel={null}
-  target={null} />
+  target={null}
+/>
 `;
 
 exports[`Button #icon changes with #isLoading 1`] = `
@@ -38,7 +44,28 @@ exports[`Button #icon changes with #isLoading 1`] = `
   href="#"
   onClick={[Function]}
   rel={null}
-  target={null} />
+  target={null}
+/>
+`;
+
+exports[`Button #multiple icons 1`] = `
+<a
+  className=" icon icon-test-icon icon-secondary-icon"
+  href="#"
+  onClick={[Function]}
+  rel={null}
+  target={null}
+/>
+`;
+
+exports[`Button #target 1`] = `
+<a
+  className=" "
+  href="#"
+  onClick={[Function]}
+  rel="noopener noreferrer"
+  target="_blank"
+/>
 `;
 
 exports[`Button basic 1`] = `
@@ -47,5 +74,6 @@ exports[`Button basic 1`] = `
   href="#"
   onClick={[Function]}
   rel={null}
-  target={null} />
+  target={null}
+/>
 `;

+ 52 - 0
client/src/components/Explorer/Explorer.js

@@ -0,0 +1,52 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import * as actions from './actions';
+
+import ExplorerPanel from './ExplorerPanel';
+
+const Explorer = ({
+  isVisible,
+  nodes,
+  path,
+  pushPage,
+  popPage,
+  onClose,
+}) => {
+  const page = nodes[path[path.length - 1]];
+
+  return isVisible ? (
+    <ExplorerPanel
+      path={path}
+      page={page}
+      nodes={nodes}
+      onClose={onClose}
+      popPage={popPage}
+      pushPage={pushPage}
+    />
+  ) : null;
+};
+
+Explorer.propTypes = {
+  isVisible: React.PropTypes.bool.isRequired,
+  path: React.PropTypes.array.isRequired,
+  nodes: React.PropTypes.object.isRequired,
+
+  pushPage: React.PropTypes.func.isRequired,
+  popPage: React.PropTypes.func.isRequired,
+  onClose: React.PropTypes.func.isRequired,
+};
+
+const mapStateToProps = (state) => ({
+  isVisible: state.explorer.isVisible,
+  path: state.explorer.path,
+  nodes: state.nodes,
+});
+
+const mapDispatchToProps = (dispatch) => ({
+  pushPage: (id) => dispatch(actions.pushPage(id)),
+  popPage: () => dispatch(actions.popPage()),
+  onClose: () => dispatch(actions.closeExplorer()),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Explorer);

+ 124 - 0
client/src/components/Explorer/Explorer.scss

@@ -0,0 +1,124 @@
+$c-explorer-bg: #4C4E4D;
+$c-explorer-bg-dark: $color-grey-1;
+$c-explorer-bg-active: rgba(0,0,0,0.425);
+$c-explorer-secondary: #a5a5a5;
+$c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
+
+@import 'ExplorerItem';
+
+.c-explorer,
+.c-explorer * {
+    box-sizing: border-box;
+}
+
+.c-explorer {
+    position: relative;
+    overflow: hidden;
+    height: 100vh;
+    background: $c-explorer-bg;
+
+    @include medium {
+        box-shadow: 2px 2px 5px $c-explorer-bg-active;
+    }
+}
+
+.c-explorer > .c-transition-group {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    z-index: 150;
+}
+
+.c-explorer__close {
+    padding: 1em;
+    color: $c-explorer-secondary;
+    border-bottom: 1px solid rgba(200, 200, 200, 0.1);
+    cursor: pointer;
+
+    &:focus {
+        background-color: $c-explorer-bg-active;
+        color: $color-white;
+        outline: none;
+    }
+
+    // Overrides for default link hover.
+    &:hover {
+        color: $c-explorer-secondary;
+    }
+
+    @include small {
+        .explorer-open & {
+            display: block;
+        }
+    }
+}
+
+.c-explorer__drawer {
+    flex: 1;
+    overflow-y: auto;
+    -webkit-overflow-scrolling: touch;
+}
+
+.c-explorer__header {
+    display: block;
+    height: 50px;
+    background-color: $c-explorer-bg-dark;
+    border-bottom: 1px solid $c-explorer-bg-dark;
+    color: $color-white;
+
+    &:focus {
+        background-color: $c-explorer-bg-active;
+        color: $color-white;
+        outline: none;
+    }
+
+    // Overrides for default link hover.
+    &:hover {
+        color: $color-white;
+    }
+
+    @include hover {
+        background-color: $c-explorer-bg-active;
+    }
+}
+
+.c-explorer__header__inner {
+    padding: 1rem;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+
+    .icon {
+        color: $c-explorer-secondary;
+        margin-right: .25rem;
+        font-size: 1rem;
+    }
+}
+
+.c-explorer__placeholder {
+    padding: 1rem;
+    color: $color-white;
+}
+
+.c-explorer__see-more {
+    display: block;
+    padding: 1rem;
+    height: 50px;
+    background: rgba(0,0,0,0.3);
+    color: $color-white;
+
+    &:focus {
+        color: $c-explorer-secondary;
+        background: $c-explorer-bg-active;
+        outline: none;
+    }
+
+    // Overrides for default link hover.
+    &:hover {
+        color: $color-white;
+    }
+
+    @include hover {
+        background: $c-explorer-bg-active;
+    }
+}

+ 46 - 3
client/src/components/Explorer/Explorer.test.js

@@ -1,14 +1,57 @@
 import React from 'react';
 import { shallow } from 'enzyme';
-
+import { Provider } from 'react-redux';
+import { createStore, applyMiddleware, combineReducers } from 'redux';
+import thunkMiddleware from 'redux-thunk';
+import * as actions from './actions';
+import explorer from './reducers/explorer';
+import nodes from './reducers/nodes';
 import Explorer from './Explorer';
 
-const mockProps = {
+const rootReducer = combineReducers({
+  explorer,
+  nodes,
+});
 
-};
+const store = createStore(rootReducer, {}, applyMiddleware(thunkMiddleware));
 
 describe('Explorer', () => {
   it('exists', () => {
     expect(Explorer).toBeDefined();
   });
+
+  it('renders', () => {
+    expect(shallow(<Explorer store={store} />)).toMatchSnapshot();
+    expect(shallow(<Provider store={store}><Explorer /></Provider>)).toMatchSnapshot();
+  });
+
+  it('visible', () => {
+    store.dispatch(actions.toggleExplorer(1));
+    expect(shallow(<Explorer store={store} />)).toMatchSnapshot();
+    expect(shallow(<Explorer store={store} />).dive()).toMatchSnapshot();
+  });
+
+  describe('actions', () => {
+    let wrapper;
+
+    beforeEach(() => {
+      store.dispatch = jest.fn();
+      wrapper = shallow(<Explorer store={store} />);
+    });
+
+    it('pushPage', () => {
+      wrapper.prop('pushPage')();
+      expect(store.dispatch).toHaveBeenCalled();
+    });
+
+    it('popPage', () => {
+      wrapper.prop('popPage')();
+      expect(store.dispatch).toHaveBeenCalled();
+    });
+
+    it('onClose', () => {
+      wrapper.prop('onClose')();
+      expect(store.dispatch).toHaveBeenCalled();
+    });
+  });
 });

+ 37 - 0
client/src/components/Explorer/ExplorerHeader.js

@@ -0,0 +1,37 @@
+import React from 'react';
+import { ADMIN_URLS, STRINGS } from '../../config/wagtailConfig';
+
+import Button from '../../components/Button/Button';
+import Icon from '../../components/Icon/Icon';
+
+/**
+ * The bar at the top of the explorer, displaying the current level
+ * and allowing access back to the parent level.
+ */
+const ExplorerHeader = ({ page, depth, onClick }) => {
+  const isRoot = depth === 1;
+
+  return (
+    <Button
+      href={page.id ? `${ADMIN_URLS.PAGES}${page.id}/` : ADMIN_URLS.PAGES}
+      className="c-explorer__header"
+      onClick={onClick}
+    >
+      <div className="c-explorer__header__inner">
+        <Icon name={isRoot ? 'home' : 'arrow-left'} />
+        <span>{page.title || STRINGS.PAGES}</span>
+      </div>
+    </Button>
+  );
+};
+
+ExplorerHeader.propTypes = {
+  page: React.PropTypes.shape({
+    id: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]),
+    title: React.PropTypes.string,
+  }).isRequired,
+  depth: React.PropTypes.number.isRequired,
+  onClick: React.PropTypes.func.isRequired,
+};
+
+export default ExplorerHeader;

+ 36 - 0
client/src/components/Explorer/ExplorerHeader.test.js

@@ -0,0 +1,36 @@
+import React from 'react';
+import { mount, shallow } from 'enzyme';
+
+import ExplorerHeader from './ExplorerHeader';
+
+const mockProps = {
+  page: {},
+  depth: 2,
+  transitionName: 'pop',
+  onClick: jest.fn(),
+};
+
+describe('ExplorerHeader', () => {
+  it('exists', () => {
+    expect(ExplorerHeader).toBeDefined();
+  });
+
+  it('basic', () => {
+    expect(shallow(<ExplorerHeader {...mockProps} />)).toMatchSnapshot();
+  });
+
+  it('#depth at root', () => {
+    expect(shallow(<ExplorerHeader {...mockProps} depth={1} />)).toMatchSnapshot();
+  });
+
+  it('#page', () => {
+    expect(shallow(<ExplorerHeader {...mockProps} page={{ id: 'a', title: 'test' }} />)).toMatchSnapshot();
+  });
+
+  it('#onClick', () => {
+    const wrapper = mount(<ExplorerHeader {...mockProps} />);
+    wrapper.find('Button').simulate('click');
+
+    expect(mockProps.onClick).toHaveBeenCalledTimes(1);
+  });
+});

+ 59 - 0
client/src/components/Explorer/ExplorerItem.js

@@ -0,0 +1,59 @@
+import React from 'react';
+
+import { ADMIN_URLS, STRINGS } from '../../config/wagtailConfig';
+import Icon from '../../components/Icon/Icon';
+import Button from '../../components/Button/Button';
+import PublicationStatus from '../../components/PublicationStatus/PublicationStatus';
+
+const ExplorerItem = ({ item, onClick }) => {
+  const { id, title, meta } = item;
+  const hasChildren = meta.children.count > 0;
+  const isPublished = meta.status.live && !meta.status.has_unpublished_changes;
+
+  return (
+    <div className="c-explorer__item">
+      <Button href={`${ADMIN_URLS.PAGES}${id}/`} className="c-explorer__item__link">
+        {hasChildren ? (
+          <Icon name="folder-inverse" className={'c-explorer__children'} />
+        ) : null}
+
+        <h3 className="c-explorer__item__title">
+          {title}
+        </h3>
+
+        {!isPublished ? (
+          <span className="c-explorer__meta">
+            <PublicationStatus status={meta.status} />
+          </span>
+        ) : null}
+      </Button>
+      <Button
+        href={`${ADMIN_URLS.PAGES}${id}/edit/`}
+        className="c-explorer__item__action c-explorer__item__action--small"
+      >
+        <Icon name="edit" title={`${STRINGS.EDIT} '${title}'`} />
+      </Button>
+      {hasChildren ? (
+        <Button
+          className="c-explorer__item__action"
+          onClick={onClick}
+        >
+          <Icon name="arrow-right" title={STRINGS.SEE_CHILDREN} />
+        </Button>
+      ) : null}
+    </div>
+  );
+};
+
+ExplorerItem.propTypes = {
+  item: React.PropTypes.shape({
+    id: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]).isRequired,
+    title: React.PropTypes.string.isRequired,
+    meta: React.PropTypes.shape({
+      status: React.PropTypes.object.isRequired,
+    }).isRequired,
+  }).isRequired,
+  onClick: React.PropTypes.func.isRequired,
+};
+
+export default ExplorerItem;

+ 85 - 0
client/src/components/Explorer/ExplorerItem.scss

@@ -0,0 +1,85 @@
+.c-explorer__item {
+    display: flex;
+    flex-flow: row nowrap;
+    border-bottom: 1px solid $c-explorer-bg-dark;
+}
+
+.c-explorer__item__link {
+    display: inline-flex;
+    align-items: center;
+    flex-grow: 1;
+    padding: 1.45em 1.75em;
+    cursor: pointer;
+
+    &:focus {
+        background: $c-explorer-bg-active;
+        color: $color-white;
+        outline: none;
+    }
+
+    // Overrides for default link hover.
+    &:hover {
+        color: $color-white;
+    }
+
+    @include hover {
+        background: $c-explorer-bg-active;
+    }
+}
+
+.c-explorer__item__link .icon {
+    font-size: 2em;
+    color: $c-explorer-secondary;
+    margin-right: 0.5rem;
+}
+
+.c-explorer__item__title {
+    margin: 0;
+    color: $color-white;
+    display: inline-block;
+}
+
+.c-explorer__item__action {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    flex-shrink: 0;
+    width: 50px;
+    padding: 0 .5em;
+    line-height: 1;
+    font-size: 2em;
+    cursor: pointer;
+    color: $c-explorer-secondary;
+    border: 0;
+    border-left: solid 1px $c-explorer-bg-dark;
+
+    &:focus {
+        background: $c-explorer-bg-active;
+        color: $color-white;
+        outline: none;
+    }
+
+    // Overrides for default link hover.
+    &:hover {
+        color: $c-explorer-secondary;
+    }
+
+    @include hover {
+        background: $c-explorer-bg-active;
+        color: $color-white;
+    }
+
+    .icon:before {
+        margin-right: 0;
+    }
+}
+
+.c-explorer__item__action--small {
+    font-size: 1.2em;
+}
+
+.c-explorer__meta {
+    margin-left: 0.5rem;
+    color: $c-explorer-secondary;
+    font-size: 12px;
+}

+ 30 - 8
client/src/components/Explorer/ExplorerItem.test.js

@@ -4,13 +4,25 @@ import { shallow } from 'enzyme';
 import ExplorerItem from './ExplorerItem';
 
 const mockProps = {
-  data: {
+  item: {
+    id: 5,
+    title: 'test',
     meta: {
+      latest_revision_created_at: null,
+      status: {
+        live: true,
+        status: 'test',
+        has_unpublished_changes: false,
+      },
+      descendants: {
+        count: 0,
+      },
       children: {
         count: 0,
       }
-    }
+    },
   },
+  onClick: () => {},
 };
 
 describe('ExplorerItem', () => {
@@ -18,15 +30,25 @@ describe('ExplorerItem', () => {
     expect(ExplorerItem).toBeDefined();
   });
 
-  it('basic', () => {
-    expect(shallow(<ExplorerItem />)).toMatchSnapshot();
+  it('renders', () => {
+    expect(shallow(<ExplorerItem {...mockProps} />)).toMatchSnapshot();
   });
 
-  it('#data', () => {
-    expect(shallow(<ExplorerItem {...mockProps} />)).toMatchSnapshot();
+  it('children', () => {
+    const props = Object.assign({}, mockProps);
+    props.item.meta.children.count = 5;
+    expect(shallow(<ExplorerItem {...props} />)).toMatchSnapshot();
+  });
+
+  it('should show a publication status with unpublished changes', () => {
+    const props = Object.assign({}, mockProps);
+    props.item.meta.status.has_unpublished_changes = true;
+    expect(shallow(<ExplorerItem {...props} />)).toMatchSnapshot();
   });
 
-  it('#typeName', () => {
-    expect(shallow(<ExplorerItem {...mockProps} typeName="Foo" />)).toMatchSnapshot();
+  it('should show a publication status if not live', () => {
+    const props = Object.assign({}, mockProps);
+    props.item.meta.status.live = false;
+    expect(shallow(<ExplorerItem {...props} />)).toMatchSnapshot();
   });
 });

+ 175 - 0
client/src/components/Explorer/ExplorerPanel.js

@@ -0,0 +1,175 @@
+import React from 'react';
+import FocusTrap from 'focus-trap-react';
+
+import { STRINGS, MAX_EXPLORER_PAGES } from '../../config/wagtailConfig';
+
+import Button from '../Button/Button';
+import LoadingSpinner from '../LoadingSpinner/LoadingSpinner';
+import Transition, { PUSH, POP, FADE } from '../Transition/Transition';
+import ExplorerHeader from './ExplorerHeader';
+import ExplorerItem from './ExplorerItem';
+import PageCount from './PageCount';
+
+export default class ExplorerPanel extends React.Component {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      transition: PUSH,
+      paused: false,
+    };
+
+    this.onItemClick = this.onItemClick.bind(this);
+    this.onHeaderClick = this.onHeaderClick.bind(this);
+    this.clickOutside = this.clickOutside.bind(this);
+  }
+
+  componentWillReceiveProps(newProps) {
+    const { path } = this.props;
+    const isPush = newProps.path.length > path.length;
+
+    this.setState({
+      transition: isPush ? PUSH : POP,
+    });
+  }
+
+  componentDidMount() {
+    document.querySelector('[data-explorer-menu-item]').classList.add('submenu-active');
+    document.body.classList.add('explorer-open');
+    document.addEventListener('mousedown', this.clickOutside);
+    document.addEventListener('touchstart', this.clickOutside);
+  }
+
+  componentWillUnmount() {
+    document.querySelector('[data-explorer-menu-item]').classList.remove('submenu-active');
+    document.body.classList.remove('explorer-open');
+    document.removeEventListener('mousedown', this.clickOutside);
+    document.removeEventListener('touchstart', this.clickOutside);
+  }
+
+  clickOutside(e) {
+    const { onClose } = this.props;
+    const explorer = document.querySelector('[data-explorer-menu]');
+    const toggle = document.querySelector('[data-explorer-menu-item]');
+
+    const isInside = explorer.contains(e.target) || toggle.contains(e.target);
+    if (!isInside) {
+      onClose();
+    }
+
+    if (toggle.contains(e.target)) {
+      this.setState({
+        paused: true,
+      });
+    }
+  }
+
+  onItemClick(id, e) {
+    const { pushPage } = this.props;
+
+    e.preventDefault();
+    e.stopPropagation();
+
+    pushPage(id);
+  }
+
+  onHeaderClick(e) {
+    const { path, popPage } = this.props;
+    const hasBack = path.length > 1;
+
+    if (hasBack) {
+      e.preventDefault();
+      e.stopPropagation();
+
+      popPage();
+    }
+  }
+
+  renderChildren() {
+    const { page, nodes } = this.props;
+    let children;
+
+    if (!page.isFetching && !page.children.items) {
+      children = (
+        <div key="empty" className="c-explorer__placeholder">
+          {STRINGS.NO_RESULTS}
+        </div>
+      );
+    } else {
+      children = (
+        <div key="children">
+          {page.children.items.map((id) => (
+            <ExplorerItem
+              key={id}
+              item={nodes[id]}
+              onClick={this.onItemClick.bind(null, id)}
+            />
+          ))}
+        </div>
+      );
+    }
+
+    return (
+      <div className="c-explorer__drawer">
+        {children}
+        {page.isFetching ? (
+          <div key="fetching" className="c-explorer__placeholder">
+            <LoadingSpinner />
+          </div>
+        ) : null}
+        {page.isError ? (
+          <div key="error" className="c-explorer__placeholder">
+            {STRINGS.SERVER_ERROR}
+          </div>
+        ) : null}
+      </div>
+    );
+  }
+
+  render() {
+    const { page, onClose, path } = this.props;
+    const { transition, paused } = this.state;
+
+    return (
+      <FocusTrap
+        tag="nav"
+        className="explorer"
+        paused={paused || !page || page.isFetching}
+        focusTrapOptions={{ onDeactivate: onClose }}
+      >
+        <Button className="c-explorer__close u-hidden" onClick={onClose}>
+          {STRINGS.CLOSE_EXPLORER}
+        </Button>
+        <Transition name={transition} className="c-explorer">
+          <div key={path.length} className="c-transition-group">
+            <ExplorerHeader
+              depth={path.length}
+              page={page}
+              onClick={this.onHeaderClick}
+            />
+
+            {this.renderChildren()}
+
+            {page.isError || page.children.items && page.children.count > MAX_EXPLORER_PAGES ? (
+              <PageCount page={page} />
+            ) : null}
+          </div>
+        </Transition>
+      </FocusTrap>
+    );
+  }
+}
+
+ExplorerPanel.propTypes = {
+  nodes: React.PropTypes.object.isRequired,
+  path: React.PropTypes.array,
+  page: React.PropTypes.shape({
+    isFetching: React.PropTypes.bool,
+    children: React.PropTypes.shape({
+      items: React.PropTypes.array,
+    }),
+  }),
+  onClose: React.PropTypes.func.isRequired,
+  popPage: React.PropTypes.func.isRequired,
+  pushPage: React.PropTypes.func.isRequired,
+};

+ 182 - 0
client/src/components/Explorer/ExplorerPanel.test.js

@@ -0,0 +1,182 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import ExplorerPanel from './ExplorerPanel';
+
+const mockProps = {
+  page: {
+    children: {
+      items: [],
+    },
+  },
+  onClose: jest.fn(),
+  path: [],
+  popPage: jest.fn(),
+  pushPage: jest.fn(),
+  nodes: {},
+};
+
+describe('ExplorerPanel', () => {
+  it('exists', () => {
+    expect(ExplorerPanel).toBeDefined();
+  });
+
+  it('renders', () => {
+    expect(shallow(<ExplorerPanel {...mockProps} />)).toMatchSnapshot();
+  });
+
+  it('#isFetching', () => {
+    expect(shallow((
+      <ExplorerPanel
+        {...mockProps}
+        page={Object.assign({ isFetching: true }, mockProps.page)}
+      />
+    ))).toMatchSnapshot();
+  });
+
+  it('#isError', () => {
+    expect(shallow((
+      <ExplorerPanel
+        {...mockProps}
+        page={Object.assign({ isError: true }, mockProps.page)}
+      />
+    ))).toMatchSnapshot();
+  });
+
+  it('no children', () => {
+    expect(shallow((
+      <ExplorerPanel
+        {...mockProps}
+        page={{ children: {} }}
+      />
+    ))).toMatchSnapshot();
+  });
+
+  it('#items', () => {
+    expect(shallow((
+      <ExplorerPanel
+        {...mockProps}
+        page={{ children: { items: [1, 2] } }}
+        nodes={{
+          1: { id: 1, title: 'Test', meta: { status: {}, type: 'test' } },
+          2: { id: 2, title: 'Foo', meta: { status: {}, type: 'foo' } },
+        }}
+      />
+    ))).toMatchSnapshot();
+  });
+
+  describe('onHeaderClick', () => {
+    beforeEach(() => {
+      mockProps.popPage.mockReset();
+    });
+
+    it('calls popPage', () => {
+      shallow((
+        <ExplorerPanel {...mockProps} path={[1, 2, 3]} />
+      )).find('ExplorerHeader').prop('onClick')({
+        preventDefault() {},
+        stopPropagation() {},
+      });
+
+      expect(mockProps.popPage).toHaveBeenCalled();
+    });
+
+    it('does not call popPage for first page', () => {
+      shallow((
+        <ExplorerPanel {...mockProps} path={[1]} />
+      )).find('ExplorerHeader').prop('onClick')({
+        preventDefault() {},
+        stopPropagation() {},
+      });
+
+      expect(mockProps.popPage).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('onItemClick', () => {
+    beforeEach(() => {
+      mockProps.pushPage.mockReset();
+    });
+
+    it('calls pushPage', () => {
+      shallow((
+        <ExplorerPanel
+          {...mockProps}
+          path={[1]}
+          page={{ children: { items: [1] } }}
+          nodes={{ 1: { id: 1, title: 'Test', meta: { status: {}, type: 'test' } } }}
+        />
+      )).find('ExplorerItem').prop('onClick')({
+        preventDefault() {},
+        stopPropagation() {},
+      });
+
+      expect(mockProps.pushPage).toHaveBeenCalled();
+    });
+  });
+
+  describe('hooks', () => {
+    let wrapper;
+
+    beforeEach(() => {
+      wrapper = shallow(<ExplorerPanel {...mockProps} />);
+    });
+
+    it('componentWillReceiveProps push', () => {
+      expect(wrapper.setProps({ path: [1] }).state('transition')).toBe('push');
+    });
+
+    it('componentWillReceiveProps pop', () => {
+      expect(wrapper.setProps({ path: [] }).state('transition')).toBe('pop');
+    });
+
+    it('componentDidMount', () => {
+      document.body.innerHTML = '<div data-explorer-menu-item></div>';
+      wrapper.instance().componentDidMount();
+      expect(document.querySelector('[data-explorer-menu-item]').classList.contains('submenu-active')).toBe(true);
+      expect(document.body.classList.contains('explorer-open')).toBe(true);
+    });
+
+    it('componentWillUnmount', () => {
+      document.body.innerHTML = '<div class="submenu-active" data-explorer-menu-item></div>';
+      wrapper.instance().componentWillUnmount();
+      expect(document.querySelector('[data-explorer-menu-item]').classList.contains('submenu-active')).toBe(false);
+      expect(document.body.classList.contains('explorer-open')).toBe(false);
+    });
+  });
+
+  describe('clickOutside', () => {
+    let wrapper;
+
+    beforeEach(() => {
+      wrapper = shallow(<ExplorerPanel {...mockProps} />);
+    });
+
+    afterEach(() => {
+      mockProps.onClose.mockReset();
+    });
+
+    it('triggers onClose when click is outside', () => {
+      document.body.innerHTML = '<div data-explorer-menu-item></div><div data-explorer-menu></div><div id="target"></div>';
+      wrapper.instance().clickOutside({
+        target: document.querySelector('#target'),
+      });
+      expect(mockProps.onClose).toHaveBeenCalled();
+    });
+
+    it('does not trigger onClose when click is inside', () => {
+      document.body.innerHTML = '<div data-explorer-menu-item></div><div data-explorer-menu><div id="target"></div></div>';
+      wrapper.instance().clickOutside({
+        target: document.querySelector('#target'),
+      });
+      expect(mockProps.onClose).not.toHaveBeenCalled();
+    });
+
+    it('pauses focus trap inside toggle', () => {
+      document.body.innerHTML = '<div data-explorer-menu-item><div id="target"></div></div><div data-explorer-menu></div>';
+      wrapper.instance().clickOutside({
+        target: document.querySelector('#target'),
+      });
+      expect(wrapper.state('paused')).toEqual(true);
+    });
+  });
+});

+ 36 - 0
client/src/components/Explorer/ExplorerToggle.js

@@ -0,0 +1,36 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import * as actions from './actions';
+
+import Button from '../../components/Button/Button';
+
+/**
+ * A Button which toggles the explorer.
+ */
+const ExplorerToggle = ({ children, onToggle }) => (
+  <Button
+    icon={['folder-open-inverse', 'arrow-right-after']}
+    onClick={onToggle}
+  >
+    {children}
+  </Button>
+);
+
+ExplorerToggle.propTypes = {
+  onToggle: React.PropTypes.func.isRequired,
+  children: React.PropTypes.node.isRequired,
+};
+
+const mapStateToProps = () => ({});
+
+const mapDispatchToProps = (dispatch) => ({
+  onToggle: (page) => dispatch(actions.toggleExplorer(page)),
+});
+
+const mergeProps = (stateProps, dispatchProps, ownProps) => ({
+  children: ownProps.children,
+  onToggle: dispatchProps.onToggle.bind(null, ownProps.startPage),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(ExplorerToggle);

+ 16 - 16
client/src/components/Explorer/ExplorerToggle.test.js

@@ -1,11 +1,10 @@
 import React from 'react';
-import { createStore } from 'redux';
 import { shallow } from 'enzyme';
+import configureMockStore from 'redux-mock-store';
 
 import ExplorerToggle from './ExplorerToggle';
-import rootReducer from './reducers';
 
-const store = createStore(rootReducer);
+const store = configureMockStore()({});
 
 describe('ExplorerToggle', () => {
   it('exists', () => {
@@ -13,19 +12,6 @@ describe('ExplorerToggle', () => {
   });
 
   it('basic', () => {
-    expect(shallow(<ExplorerToggle store={store} />)).toMatchSnapshot();
-  });
-
-  it('loading state', (done) => {
-    store.subscribe(() => {
-      expect(shallow(<ExplorerToggle store={store} />)).toMatchSnapshot();
-      done();
-    });
-
-    store.dispatch({ type: 'FETCH_START' });
-  });
-
-  it('#children', () => {
     expect(shallow((
       <ExplorerToggle store={store}>
         <span>
@@ -34,4 +20,18 @@ describe('ExplorerToggle', () => {
       </ExplorerToggle>
     ))).toMatchSnapshot();
   });
+
+  describe('actions', () => {
+    let wrapper;
+
+    beforeEach(() => {
+      store.dispatch = jest.fn();
+      wrapper = shallow(<ExplorerToggle store={store}>Test</ExplorerToggle>);
+    });
+
+    it('onToggle', () => {
+      wrapper.prop('onToggle')();
+      expect(store.dispatch).toHaveBeenCalled();
+    });
+  });
 });

+ 18 - 15
client/src/components/Explorer/PageCount.js

@@ -1,23 +1,26 @@
 import React from 'react';
 
-import { ADMIN_URLS, STRINGS } from '../../config/wagtail';
+import { ADMIN_URLS, STRINGS } from '../../config/wagtailConfig';
+import Icon from '../Icon/Icon';
 
-const PageCount = ({ id, count, title }) => (
-  <a
-    href={`${ADMIN_URLS.PAGES}${id}/`}
-    className="c-explorer__see-more"
-    tabIndex={0}
-  >
-    {STRINGS.EXPLORE_ALL_IN}{' '}
-    <span className="c-explorer__see-more__title">{title}</span>{' '}
-    ({count} {count !== 1 ? STRINGS.PAGES : STRINGS.PAGE})
-  </a>
-);
+const PageCount = ({ page }) => {
+  const count = page.children.count;
+
+  return (
+    <a
+      href={`${ADMIN_URLS.PAGES}${page.id}/`}
+      className="c-explorer__see-more"
+      tabIndex={0}
+    >
+      {STRINGS.SEE_ALL}
+      <span>{` ${count} ${count === 1 ? STRINGS.PAGE.toLowerCase() : STRINGS.PAGES.toLowerCase()}`}</span>
+      <Icon name="arrow-right" />
+    </a>
+  );
+};
 
 PageCount.propTypes = {
-  id: React.PropTypes.number.isRequired,
-  count: React.PropTypes.number.isRequired,
-  title: React.PropTypes.string.isRequired,
+  page: React.PropTypes.object.isRequired,
 };
 
 export default PageCount;

+ 35 - 0
client/src/components/Explorer/PageCount.test.js

@@ -0,0 +1,35 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import PageCount from './PageCount';
+
+const mockProps = {
+  page: {
+    id: 1,
+    children: {
+      count: 1,
+    },
+  },
+};
+
+describe('PageCount', () => {
+  it('exists', () => {
+    expect(PageCount).toBeDefined();
+  });
+
+  it('works', () => {
+    expect(shallow(<PageCount {...mockProps} />)).toMatchSnapshot();
+  });
+
+  it('plural', () => {
+    const props = Object.assign({}, mockProps);
+    props.page.children.count = 5;
+    expect(shallow(<PageCount {...props} />)).toMatchSnapshot();
+  });
+
+  it('#title', () => {
+    const props = Object.assign({}, mockProps);
+    props.page.title = 'This is an example';
+    expect(shallow(<PageCount {...props} />)).toMatchSnapshot();
+  });
+});

+ 106 - 0
client/src/components/Explorer/__snapshots__/Explorer.test.js.snap

@@ -0,0 +1,106 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Explorer renders 1`] = `
+<Explorer
+  isVisible={false}
+  nodes={Object {}}
+  onClose={[Function]}
+  path={Array []}
+  popPage={[Function]}
+  pushPage={[Function]}
+  store={
+    Object {
+      "dispatch": [Function],
+      "getState": [Function],
+      "replaceReducer": [Function],
+      "subscribe": [Function],
+    }
+  }
+/>
+`;
+
+exports[`Explorer renders 2`] = `<Connect(Explorer) />`;
+
+exports[`Explorer visible 1`] = `
+<Explorer
+  isVisible={true}
+  nodes={
+    Object {
+      "1": Object {
+        "children": Object {
+          "count": 0,
+          "isFetching": true,
+          "items": Array [],
+        },
+        "isError": false,
+        "isFetching": true,
+        "isLoaded": true,
+        "meta": Object {
+          "children": Object {},
+        },
+      },
+    }
+  }
+  onClose={[Function]}
+  path={
+    Array [
+      1,
+    ]
+  }
+  popPage={[Function]}
+  pushPage={[Function]}
+  store={
+    Object {
+      "dispatch": [Function],
+      "getState": [Function],
+      "replaceReducer": [Function],
+      "subscribe": [Function],
+    }
+  }
+/>
+`;
+
+exports[`Explorer visible 2`] = `
+<ExplorerPanel
+  nodes={
+    Object {
+      "1": Object {
+        "children": Object {
+          "count": 0,
+          "isFetching": true,
+          "items": Array [],
+        },
+        "isError": false,
+        "isFetching": true,
+        "isLoaded": true,
+        "meta": Object {
+          "children": Object {},
+        },
+      },
+    }
+  }
+  onClose={[Function]}
+  page={
+    Object {
+      "children": Object {
+        "count": 0,
+        "isFetching": true,
+        "items": Array [],
+      },
+      "isError": false,
+      "isFetching": true,
+      "isLoaded": true,
+      "meta": Object {
+        "children": Object {},
+      },
+    }
+  }
+  path={
+    Array [
+      1,
+    ]
+  }
+  popPage={[Function]}
+  pushPage={[Function]}
+/>
+`;

+ 79 - 0
client/src/components/Explorer/__snapshots__/ExplorerHeader.test.js.snap

@@ -0,0 +1,79 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ExplorerHeader #depth at root 1`] = `
+<Button
+  accessibleLabel={null}
+  className="c-explorer__header"
+  href="/admin/pages/"
+  icon=""
+  isLoading={false}
+  onClick={[Function]}
+  preventDefault={true}
+  target={null}
+>
+  <div
+    className="c-explorer__header__inner"
+  >
+    <Icon
+      className=""
+      name="home"
+      title={null}
+    />
+    <span>
+      Pages
+    </span>
+  </div>
+</Button>
+`;
+
+exports[`ExplorerHeader #page 1`] = `
+<Button
+  accessibleLabel={null}
+  className="c-explorer__header"
+  href="/admin/pages/a/"
+  icon=""
+  isLoading={false}
+  onClick={[Function]}
+  preventDefault={true}
+  target={null}
+>
+  <div
+    className="c-explorer__header__inner"
+  >
+    <Icon
+      className=""
+      name="arrow-left"
+      title={null}
+    />
+    <span>
+      test
+    </span>
+  </div>
+</Button>
+`;
+
+exports[`ExplorerHeader basic 1`] = `
+<Button
+  accessibleLabel={null}
+  className="c-explorer__header"
+  href="/admin/pages/"
+  icon=""
+  isLoading={false}
+  onClick={[Function]}
+  preventDefault={true}
+  target={null}
+>
+  <div
+    className="c-explorer__header__inner"
+  >
+    <Icon
+      className=""
+      name="arrow-left"
+      title={null}
+    />
+    <span>
+      Pages
+    </span>
+  </div>
+</Button>
+`;

+ 235 - 66
client/src/components/Explorer/__snapshots__/ExplorerItem.test.js.snap

@@ -1,77 +1,246 @@
-exports[`ExplorerItem #data 1`] = `
-<Button
-  accessibleLabel={null}
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ExplorerItem children 1`] = `
+<div
   className="c-explorer__item"
-  href="/admin/pages/undefined"
-  icon=""
-  isLoading={false}
-  onClick={null}
-  preventDefault={true}
-  target={null}>
-  <h3
-    className="c-explorer__title" />
-  <p
-    className="c-explorer__meta">
-    <span
-      className="c-explorer__meta__type" />
-     |
-    <AbsoluteDate
-      time="" />
-     |
-    <PublicationStatus />
-  </p>
-</Button>
+>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__item__link"
+    href="/admin/pages/5/"
+    icon=""
+    isLoading={false}
+    onClick={null}
+    preventDefault={true}
+    target={null}
+  >
+    <Icon
+      className="c-explorer__children"
+      name="folder-inverse"
+      title={null}
+    />
+    <h3
+      className="c-explorer__item__title"
+    >
+      test
+    </h3>
+  </Button>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__item__action c-explorer__item__action--small"
+    href="/admin/pages/5/edit/"
+    icon=""
+    isLoading={false}
+    onClick={null}
+    preventDefault={true}
+    target={null}
+  >
+    <Icon
+      className=""
+      name="edit"
+      title="Edit 'test'"
+    />
+  </Button>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__item__action"
+    href="#"
+    icon=""
+    isLoading={false}
+    onClick={[Function]}
+    preventDefault={true}
+    target={null}
+  >
+    <Icon
+      className=""
+      name="arrow-right"
+      title="See children"
+    />
+  </Button>
+</div>
+`;
+
+exports[`ExplorerItem renders 1`] = `
+<div
+  className="c-explorer__item"
+>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__item__link"
+    href="/admin/pages/5/"
+    icon=""
+    isLoading={false}
+    onClick={null}
+    preventDefault={true}
+    target={null}
+  >
+    <h3
+      className="c-explorer__item__title"
+    >
+      test
+    </h3>
+  </Button>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__item__action c-explorer__item__action--small"
+    href="/admin/pages/5/edit/"
+    icon=""
+    isLoading={false}
+    onClick={null}
+    preventDefault={true}
+    target={null}
+  >
+    <Icon
+      className=""
+      name="edit"
+      title="Edit 'test'"
+    />
+  </Button>
+</div>
 `;
 
-exports[`ExplorerItem #typeName 1`] = `
-<Button
-  accessibleLabel={null}
+exports[`ExplorerItem should show a publication status if not live 1`] = `
+<div
   className="c-explorer__item"
-  href="/admin/pages/undefined"
-  icon=""
-  isLoading={false}
-  onClick={null}
-  preventDefault={true}
-  target={null}>
-  <h3
-    className="c-explorer__title" />
-  <p
-    className="c-explorer__meta">
+>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__item__link"
+    href="/admin/pages/5/"
+    icon=""
+    isLoading={false}
+    onClick={null}
+    preventDefault={true}
+    target={null}
+  >
+    <Icon
+      className="c-explorer__children"
+      name="folder-inverse"
+      title={null}
+    />
+    <h3
+      className="c-explorer__item__title"
+    >
+      test
+    </h3>
     <span
-      className="c-explorer__meta__type">
-      Foo
+      className="c-explorer__meta"
+    >
+      <PublicationStatus
+        status={
+          Object {
+            "has_unpublished_changes": true,
+            "live": false,
+            "status": "test",
+          }
+        }
+      />
     </span>
-     |
-    <AbsoluteDate
-      time="" />
-     |
-    <PublicationStatus />
-  </p>
-</Button>
+  </Button>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__item__action c-explorer__item__action--small"
+    href="/admin/pages/5/edit/"
+    icon=""
+    isLoading={false}
+    onClick={null}
+    preventDefault={true}
+    target={null}
+  >
+    <Icon
+      className=""
+      name="edit"
+      title="Edit 'test'"
+    />
+  </Button>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__item__action"
+    href="#"
+    icon=""
+    isLoading={false}
+    onClick={[Function]}
+    preventDefault={true}
+    target={null}
+  >
+    <Icon
+      className=""
+      name="arrow-right"
+      title="See children"
+    />
+  </Button>
+</div>
 `;
 
-exports[`ExplorerItem basic 1`] = `
-<Button
-  accessibleLabel={null}
+exports[`ExplorerItem should show a publication status with unpublished changes 1`] = `
+<div
   className="c-explorer__item"
-  href="/admin/pages/undefined"
-  icon=""
-  isLoading={false}
-  onClick={null}
-  preventDefault={true}
-  target={null}>
-  <h3
-    className="c-explorer__title" />
-  <p
-    className="c-explorer__meta">
+>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__item__link"
+    href="/admin/pages/5/"
+    icon=""
+    isLoading={false}
+    onClick={null}
+    preventDefault={true}
+    target={null}
+  >
+    <Icon
+      className="c-explorer__children"
+      name="folder-inverse"
+      title={null}
+    />
+    <h3
+      className="c-explorer__item__title"
+    >
+      test
+    </h3>
     <span
-      className="c-explorer__meta__type" />
-     |
-    <AbsoluteDate
-      time={null} />
-     |
-    <PublicationStatus
-      status={null} />
-  </p>
-</Button>
+      className="c-explorer__meta"
+    >
+      <PublicationStatus
+        status={
+          Object {
+            "has_unpublished_changes": true,
+            "live": true,
+            "status": "test",
+          }
+        }
+      />
+    </span>
+  </Button>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__item__action c-explorer__item__action--small"
+    href="/admin/pages/5/edit/"
+    icon=""
+    isLoading={false}
+    onClick={null}
+    preventDefault={true}
+    target={null}
+  >
+    <Icon
+      className=""
+      name="edit"
+      title="Edit 'test'"
+    />
+  </Button>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__item__action"
+    href="#"
+    icon=""
+    isLoading={false}
+    onClick={[Function]}
+    preventDefault={true}
+    target={null}
+  >
+    <Icon
+      className=""
+      name="arrow-right"
+      title="See children"
+    />
+  </Button>
+</div>
 `;

+ 325 - 0
client/src/components/Explorer/__snapshots__/ExplorerPanel.test.js.snap

@@ -0,0 +1,325 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ExplorerPanel #isError 1`] = `
+<Component
+  active={true}
+  className="explorer"
+  focusTrapOptions={
+    Object {
+      "onDeactivate": [Function],
+    }
+  }
+  paused={false}
+  tag="nav"
+>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__close u-hidden"
+    href="#"
+    icon=""
+    isLoading={false}
+    onClick={[Function]}
+    preventDefault={true}
+    target={null}
+  >
+    Close explorer
+  </Button>
+  <Transition
+    className="c-explorer"
+    component="div"
+    duration={210}
+    name="push"
+  >
+    <div
+      className="c-transition-group"
+    >
+      <ExplorerHeader
+        depth={0}
+        onClick={[Function]}
+        page={
+          Object {
+            "children": Object {
+              "items": Array [],
+            },
+            "isError": true,
+          }
+        }
+      />
+      <div
+        className="c-explorer__drawer"
+      >
+        <div />
+        <div
+          className="c-explorer__placeholder"
+        >
+          Server Error
+        </div>
+      </div>
+      <PageCount
+        page={
+          Object {
+            "children": Object {
+              "items": Array [],
+            },
+            "isError": true,
+          }
+        }
+      />
+    </div>
+  </Transition>
+</Component>
+`;
+
+exports[`ExplorerPanel #isFetching 1`] = `
+<Component
+  active={true}
+  className="explorer"
+  focusTrapOptions={
+    Object {
+      "onDeactivate": [Function],
+    }
+  }
+  paused={true}
+  tag="nav"
+>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__close u-hidden"
+    href="#"
+    icon=""
+    isLoading={false}
+    onClick={[Function]}
+    preventDefault={true}
+    target={null}
+  >
+    Close explorer
+  </Button>
+  <Transition
+    className="c-explorer"
+    component="div"
+    duration={210}
+    name="push"
+  >
+    <div
+      className="c-transition-group"
+    >
+      <ExplorerHeader
+        depth={0}
+        onClick={[Function]}
+        page={
+          Object {
+            "children": Object {
+              "items": Array [],
+            },
+            "isFetching": true,
+          }
+        }
+      />
+      <div
+        className="c-explorer__drawer"
+      >
+        <div />
+        <div
+          className="c-explorer__placeholder"
+        >
+          <LoadingSpinner />
+        </div>
+      </div>
+    </div>
+  </Transition>
+</Component>
+`;
+
+exports[`ExplorerPanel #items 1`] = `
+<Component
+  active={true}
+  className="explorer"
+  focusTrapOptions={
+    Object {
+      "onDeactivate": [Function],
+    }
+  }
+  paused={false}
+  tag="nav"
+>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__close u-hidden"
+    href="#"
+    icon=""
+    isLoading={false}
+    onClick={[Function]}
+    preventDefault={true}
+    target={null}
+  >
+    Close explorer
+  </Button>
+  <Transition
+    className="c-explorer"
+    component="div"
+    duration={210}
+    name="push"
+  >
+    <div
+      className="c-transition-group"
+    >
+      <ExplorerHeader
+        depth={0}
+        onClick={[Function]}
+        page={
+          Object {
+            "children": Object {
+              "items": Array [
+                1,
+                2,
+              ],
+            },
+          }
+        }
+      />
+      <div
+        className="c-explorer__drawer"
+      >
+        <div>
+          <ExplorerItem
+            item={
+              Object {
+                "id": 1,
+                "meta": Object {
+                  "status": Object {},
+                  "type": "test",
+                },
+                "title": "Test",
+              }
+            }
+            onClick={[Function]}
+          />
+          <ExplorerItem
+            item={
+              Object {
+                "id": 2,
+                "meta": Object {
+                  "status": Object {},
+                  "type": "foo",
+                },
+                "title": "Foo",
+              }
+            }
+            onClick={[Function]}
+          />
+        </div>
+      </div>
+    </div>
+  </Transition>
+</Component>
+`;
+
+exports[`ExplorerPanel no children 1`] = `
+<Component
+  active={true}
+  className="explorer"
+  focusTrapOptions={
+    Object {
+      "onDeactivate": [Function],
+    }
+  }
+  paused={false}
+  tag="nav"
+>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__close u-hidden"
+    href="#"
+    icon=""
+    isLoading={false}
+    onClick={[Function]}
+    preventDefault={true}
+    target={null}
+  >
+    Close explorer
+  </Button>
+  <Transition
+    className="c-explorer"
+    component="div"
+    duration={210}
+    name="push"
+  >
+    <div
+      className="c-transition-group"
+    >
+      <ExplorerHeader
+        depth={0}
+        onClick={[Function]}
+        page={
+          Object {
+            "children": Object {},
+          }
+        }
+      />
+      <div
+        className="c-explorer__drawer"
+      >
+        <div
+          className="c-explorer__placeholder"
+        >
+          No results
+        </div>
+      </div>
+    </div>
+  </Transition>
+</Component>
+`;
+
+exports[`ExplorerPanel renders 1`] = `
+<Component
+  active={true}
+  className="explorer"
+  focusTrapOptions={
+    Object {
+      "onDeactivate": [Function],
+    }
+  }
+  paused={false}
+  tag="nav"
+>
+  <Button
+    accessibleLabel={null}
+    className="c-explorer__close u-hidden"
+    href="#"
+    icon=""
+    isLoading={false}
+    onClick={[Function]}
+    preventDefault={true}
+    target={null}
+  >
+    Close explorer
+  </Button>
+  <Transition
+    className="c-explorer"
+    component="div"
+    duration={210}
+    name="push"
+  >
+    <div
+      className="c-transition-group"
+    >
+      <ExplorerHeader
+        depth={0}
+        onClick={[Function]}
+        page={
+          Object {
+            "children": Object {
+              "items": Array [],
+            },
+          }
+        }
+      />
+      <div
+        className="c-explorer__drawer"
+      >
+        <div />
+      </div>
+    </div>
+  </Transition>
+</Component>
+`;

+ 4 - 41
client/src/components/Explorer/__snapshots__/ExplorerToggle.test.js.snap

@@ -1,48 +1,11 @@
-exports[`ExplorerToggle #children 1`] = `
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ExplorerToggle basic 1`] = `
 <ExplorerToggle
-  isFetching={true}
-  isVisible={false}
   onToggle={[Function]}
-  store={
-    Object {
-      "dispatch": [Function],
-      "getState": [Function],
-      "replaceReducer": [Function],
-      "subscribe": [Function],
-    }
-  }>
+>
   <span>
     To infinity and beyond!
   </span>
 </ExplorerToggle>
 `;
-
-exports[`ExplorerToggle basic 1`] = `
-<ExplorerToggle
-  isFetching={false}
-  isVisible={false}
-  onToggle={[Function]}
-  store={
-    Object {
-      "dispatch": [Function],
-      "getState": [Function],
-      "replaceReducer": [Function],
-      "subscribe": [Function],
-    }
-  } />
-`;
-
-exports[`ExplorerToggle loading state 1`] = `
-<ExplorerToggle
-  isFetching={true}
-  isVisible={false}
-  onToggle={[Function]}
-  store={
-    Object {
-      "dispatch": [Function],
-      "getState": [Function],
-      "replaceReducer": [Function],
-      "subscribe": [Function],
-    }
-  } />
-`;

+ 0 - 11
client/src/components/Explorer/__snapshots__/LoadingSpinner.test.js.snap

@@ -1,11 +0,0 @@
-exports[`LoadingSpinner basic 1`] = `
-<div
-  className="c-explorer__loading">
-  <Icon
-    className="c-explorer__spinner"
-    name="spinner"
-    title={null} />
-
-  Loading...
-</div>
-`;

+ 55 - 0
client/src/components/Explorer/__snapshots__/PageCount.test.js.snap

@@ -0,0 +1,55 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PageCount #title 1`] = `
+<a
+  className="c-explorer__see-more"
+  href="/admin/pages/1/"
+  tabIndex={0}
+>
+  See all
+  <span>
+     5 pages
+  </span>
+  <Icon
+    className=""
+    name="arrow-right"
+    title={null}
+  />
+</a>
+`;
+
+exports[`PageCount plural 1`] = `
+<a
+  className="c-explorer__see-more"
+  href="/admin/pages/1/"
+  tabIndex={0}
+>
+  See all
+  <span>
+     5 pages
+  </span>
+  <Icon
+    className=""
+    name="arrow-right"
+    title={null}
+  />
+</a>
+`;
+
+exports[`PageCount works 1`] = `
+<a
+  className="c-explorer__see-more"
+  href="/admin/pages/1/"
+  tabIndex={0}
+>
+  See all
+  <span>
+     1 page
+  </span>
+  <Icon
+    className=""
+    name="arrow-right"
+    title={null}
+  />
+</a>
+`;

+ 90 - 0
client/src/components/Explorer/__snapshots__/actions.test.js.snap

@@ -0,0 +1,90 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`actions pushPage creates action 1`] = `
+Array [
+  Object {
+    "payload": Object {
+      "id": 5,
+    },
+    "type": "PUSH_PAGE",
+  },
+]
+`;
+
+exports[`actions pushPage triggers getChildren 1`] = `
+Array [
+  Object {
+    "payload": Object {
+      "id": 5,
+    },
+    "type": "PUSH_PAGE",
+  },
+  Object {
+    "payload": Object {
+      "id": 5,
+    },
+    "type": "GET_CHILDREN_START",
+  },
+]
+`;
+
+exports[`actions toggleExplorer close 1`] = `
+Array [
+  Object {
+    "type": "CLOSE_EXPLORER",
+  },
+]
+`;
+
+exports[`actions toggleExplorer open 1`] = `
+Array [
+  Object {
+    "payload": Object {
+      "id": 5,
+    },
+    "type": "OPEN_EXPLORER",
+  },
+  Object {
+    "payload": 5,
+    "type": "GET_PAGE_START",
+  },
+]
+`;
+
+exports[`actions toggleExplorer open at root 1`] = `
+Array [
+  Object {
+    "payload": Object {
+      "id": 1,
+    },
+    "type": "OPEN_EXPLORER",
+  },
+  Object {
+    "payload": Object {
+      "id": 1,
+    },
+    "type": "GET_CHILDREN_START",
+  },
+]
+`;
+
+exports[`actions toggleExplorer open first time 1`] = `
+Array [
+  Object {
+    "payload": Object {
+      "id": 5,
+    },
+    "type": "OPEN_EXPLORER",
+  },
+  Object {
+    "payload": Object {
+      "id": 5,
+    },
+    "type": "GET_CHILDREN_START",
+  },
+  Object {
+    "payload": 5,
+    "type": "GET_PAGE_START",
+  },
+]
+`;

+ 94 - 0
client/src/components/Explorer/actions.js

@@ -0,0 +1,94 @@
+import { createAction } from 'redux-actions';
+
+import * as admin from '../../api/admin';
+import { MAX_EXPLORER_PAGES } from '../../config/wagtailConfig';
+
+const getPageStart = createAction('GET_PAGE_START');
+const getPageSuccess = createAction('GET_PAGE_SUCCESS', (id, data) => ({ id, data }));
+const getPageFailure = createAction('GET_PAGE_FAILURE', (id, error) => ({ id, error }));
+
+/**
+ * Gets a page from the API.
+ */
+function getPage(id) {
+  return (dispatch) => {
+    dispatch(getPageStart(id));
+
+    return admin.getPage(id).then((data) => {
+      dispatch(getPageSuccess(id, data));
+    }, (error) => {
+      dispatch(getPageFailure(id, error));
+    });
+  };
+}
+
+const getChildrenStart = createAction('GET_CHILDREN_START', id => ({ id }));
+const getChildrenSuccess = createAction('GET_CHILDREN_SUCCESS', (id, items, meta) => ({ id, items, meta }));
+const getChildrenFailure = createAction('GET_CHILDREN_FAILURE', (id, error) => ({ id, error }));
+
+/**
+ * Gets the children of a node from the API.
+ */
+function getChildren(id, offset = 0) {
+  return (dispatch) => {
+    dispatch(getChildrenStart(id));
+
+    return admin.getPageChildren(id, {
+      offset: offset,
+    }).then(({ items, meta }) => {
+      const nbPages = offset + items.length;
+      dispatch(getChildrenSuccess(id, items, meta));
+
+      // Load more pages if necessary. Only one request is created even though
+      // more might be needed, thus naturally throttling the loading.
+      if (nbPages < meta.total_count && nbPages < MAX_EXPLORER_PAGES) {
+        dispatch(getChildren(id, nbPages));
+      }
+    }, (error) => {
+      dispatch(getChildrenFailure(id, error));
+    });
+  };
+}
+
+const openExplorer = createAction('OPEN_EXPLORER', id => ({ id }));
+export const closeExplorer = createAction('CLOSE_EXPLORER');
+
+export function toggleExplorer(id) {
+  return (dispatch, getState) => {
+    const { explorer, nodes } = getState();
+
+    if (explorer.isVisible) {
+      dispatch(closeExplorer());
+    } else {
+      const page = nodes[id];
+
+      dispatch(openExplorer(id));
+
+      if (!page) {
+        dispatch(getChildren(id));
+      }
+
+      // We need to get the title of the starting page, only if it is not the site's root.
+      const isNotRoot = id !== 1;
+      if (isNotRoot) {
+        dispatch(getPage(id));
+      }
+    }
+  };
+}
+
+export const popPage = createAction('POP_PAGE');
+const pushPagePrivate = createAction('PUSH_PAGE', id => ({ id }));
+
+export function pushPage(id) {
+  return (dispatch, getState) => {
+    const { nodes } = getState();
+    const page = nodes[id];
+
+    dispatch(pushPagePrivate(id));
+
+    if (page && !page.children.isFetching && !page.children.isLoaded) {
+      dispatch(getChildren(id));
+    }
+  };
+}

+ 99 - 0
client/src/components/Explorer/actions.test.js

@@ -0,0 +1,99 @@
+import configureMockStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+
+import * as actions from './actions';
+
+const middlewares = [thunk];
+const mockStore = configureMockStore(middlewares);
+
+const stubState = {
+  explorer: {
+    isVisible: true,
+  },
+  nodes: {
+    5: {
+      children: {
+        isFetching: false,
+        isLoaded: true,
+      },
+    },
+  },
+};
+
+describe('actions', () => {
+  describe('closeExplorer', () => {
+    it('exists', () => {
+      expect(actions.closeExplorer).toBeDefined();
+    });
+
+    it('creates action', () => {
+      expect(actions.closeExplorer().type).toEqual('CLOSE_EXPLORER');
+    });
+  });
+
+  describe('toggleExplorer', () => {
+    it('exists', () => {
+      expect(actions.toggleExplorer).toBeDefined();
+    });
+
+    it('close', () => {
+      const store = mockStore(stubState);
+      store.dispatch(actions.toggleExplorer(5));
+      expect(store.getActions()).toMatchSnapshot();
+    });
+
+    it('open', () => {
+      const stub = Object.assign({}, stubState);
+      stub.explorer.isVisible = false;
+      const store = mockStore(stub);
+      store.dispatch(actions.toggleExplorer(5));
+      expect(store.getActions()).toMatchSnapshot();
+    });
+
+    it('open first time', () => {
+      const stub = { explorer: stubState.explorer, nodes: {} };
+      stub.explorer.isVisible = false;
+      const store = mockStore(stub);
+      store.dispatch(actions.toggleExplorer(5));
+      expect(store.getActions()).toMatchSnapshot();
+    });
+
+    it('open at root', () => {
+      const stub = Object.assign({}, stubState);
+      stub.explorer.isVisible = false;
+      const store = mockStore(stub);
+      store.dispatch(actions.toggleExplorer(1));
+      expect(store.getActions()).toMatchSnapshot();
+    });
+  });
+
+  describe('popPage', () => {
+    it('exists', () => {
+      expect(actions.popPage).toBeDefined();
+    });
+
+    it('works', () => {
+      expect(actions.popPage().type).toEqual('POP_PAGE');
+    });
+  });
+
+  describe('pushPage', () => {
+    it('exists', () => {
+      expect(actions.pushPage).toBeDefined();
+    });
+
+    it('creates action', () => {
+      const store = mockStore(stubState);
+      store.dispatch(actions.pushPage(5));
+      expect(store.getActions()).toMatchSnapshot();
+    });
+
+    it('triggers getChildren', () => {
+      const stub = Object.assign({}, stubState);
+      stub.nodes[5].children.isLoaded = false;
+      const store = mockStore(stub);
+      store.dispatch(actions.pushPage(5));
+      expect(store.getActions()).toMatchSnapshot();
+    });
+  });
+});

+ 51 - 0
client/src/components/Explorer/index.js

@@ -0,0 +1,51 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Provider } from 'react-redux';
+import { createStore, combineReducers, applyMiddleware, compose } from 'redux';
+import thunkMiddleware from 'redux-thunk';
+
+import Explorer from './Explorer';
+import ExplorerToggle from './ExplorerToggle';
+import explorer from './reducers/explorer';
+import nodes from './reducers/nodes';
+
+/**
+ * Initialises the explorer component on the given nodes.
+ */
+const initExplorer = (explorerNode, toggleNode) => {
+  const rootReducer = combineReducers({
+    explorer,
+    nodes,
+  });
+
+  const middleware = [
+    thunkMiddleware,
+  ];
+
+  const store = createStore(rootReducer, {}, compose(
+    applyMiddleware(...middleware),
+    // Expose store to Redux DevTools extension.
+    window.devToolsExtension ? window.devToolsExtension() : func => func
+  ));
+
+  const startPage = parseInt(toggleNode.getAttribute('data-explorer-start-page'), 10);
+
+  ReactDOM.render((
+    <Provider store={store}>
+      <ExplorerToggle startPage={startPage}>{toggleNode.textContent}</ExplorerToggle>
+    </Provider>
+  ), toggleNode.parentNode);
+
+  ReactDOM.render((
+    <Provider store={store}>
+      <Explorer />
+    </Provider>
+  ), explorerNode);
+};
+
+export default Explorer;
+
+export {
+  ExplorerToggle,
+  initExplorer,
+};

+ 28 - 0
client/src/components/Explorer/index.test.js

@@ -0,0 +1,28 @@
+import Explorer, { ExplorerToggle, initExplorer } from './index';
+
+describe('Explorer index', () => {
+  it('exists', () => {
+    expect(Explorer).toBeDefined();
+  });
+
+  describe('ExplorerToggle', () => {
+    it('exists', () => {
+      expect(ExplorerToggle).toBeDefined();
+    });
+  });
+
+  describe('initExplorer', () => {
+    it('exists', () => {
+      expect(initExplorer).toBeInstanceOf(Function);
+    });
+
+    it('works', () => {
+      document.body.innerHTML = '<div><div id="e"></div><div id="t">Test</div></div>';
+      const explorerNode = document.querySelector('#e');
+      const toggleNode = document.querySelector('#t');
+
+      initExplorer(explorerNode, toggleNode);
+      expect(document.body.innerHTML).toContain('data-reactroot');
+    });
+  });
+});

+ 26 - 0
client/src/components/Explorer/reducers/__snapshots__/explorer.test.js.snap

@@ -0,0 +1,26 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`explorer OPEN_EXPLORER 1`] = `
+Object {
+  "isVisible": true,
+  "path": Array [
+    1,
+  ],
+}
+`;
+
+exports[`explorer POP_PAGE 1`] = `
+Object {
+  "isVisible": false,
+  "path": Array [],
+}
+`;
+
+exports[`explorer PUSH_PAGE 1`] = `
+Object {
+  "isVisible": false,
+  "path": Array [
+    100,
+  ],
+}
+`;

+ 142 - 0
client/src/components/Explorer/reducers/__snapshots__/nodes.test.js.snap

@@ -0,0 +1,142 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`nodes GET_CHILDREN_FAILURE 1`] = `
+Object {
+  "1": Object {
+    "children": Object {
+      "count": 0,
+      "isFetching": false,
+      "items": Array [],
+    },
+    "isError": true,
+    "isFetching": false,
+    "isLoaded": true,
+    "meta": Object {
+      "children": Object {},
+    },
+  },
+}
+`;
+
+exports[`nodes GET_CHILDREN_START 1`] = `
+Object {
+  "1": Object {
+    "children": Object {
+      "isFetching": true,
+    },
+    "isFetching": true,
+  },
+}
+`;
+
+exports[`nodes GET_CHILDREN_SUCCESS 1`] = `
+Object {
+  "1": Object {
+    "children": Object {
+      "count": 3,
+      "isError": false,
+      "isFetching": false,
+      "isLoaded": true,
+      "items": Array [
+        3,
+        4,
+        5,
+      ],
+    },
+    "isError": false,
+    "isFetching": false,
+    "isLoaded": true,
+    "meta": Object {
+      "children": Object {},
+    },
+  },
+  "3": Object {
+    "children": Object {
+      "count": 0,
+      "isFetching": false,
+      "items": Array [],
+    },
+    "id": 3,
+    "isError": false,
+    "isFetching": false,
+    "isLoaded": true,
+    "meta": Object {
+      "children": Object {},
+    },
+  },
+  "4": Object {
+    "children": Object {
+      "count": 0,
+      "isFetching": false,
+      "items": Array [],
+    },
+    "id": 4,
+    "isError": false,
+    "isFetching": false,
+    "isLoaded": true,
+    "meta": Object {
+      "children": Object {},
+    },
+  },
+  "5": Object {
+    "children": Object {
+      "count": 0,
+      "isFetching": false,
+      "items": Array [],
+    },
+    "id": 5,
+    "isError": false,
+    "isFetching": false,
+    "isLoaded": true,
+    "meta": Object {
+      "children": Object {},
+    },
+  },
+}
+`;
+
+exports[`nodes GET_PAGE_FAILURE 1`] = `
+Object {
+  "1": Object {
+    "children": Object {
+      "count": 0,
+      "isFetching": false,
+      "items": Array [],
+    },
+    "isError": true,
+    "isFetching": false,
+    "isLoaded": true,
+    "meta": Object {
+      "children": Object {},
+    },
+  },
+}
+`;
+
+exports[`nodes GET_PAGE_SUCCESS 1`] = `
+Object {
+  "1": Object {
+    "isError": false,
+  },
+}
+`;
+
+exports[`nodes OPEN_EXPLORER 1`] = `
+Object {
+  "1": Object {
+    "children": Object {
+      "count": 0,
+      "isFetching": false,
+      "items": Array [],
+    },
+    "isError": false,
+    "isFetching": false,
+    "isLoaded": true,
+    "meta": Object {
+      "children": Object {},
+    },
+  },
+}
+`;
+
+exports[`nodes empty state 1`] = `Object {}`;

+ 39 - 0
client/src/components/Explorer/reducers/explorer.js

@@ -0,0 +1,39 @@
+const defaultState = {
+  isVisible: false,
+  path: [],
+};
+
+/**
+ * Oversees the state of the explorer. Defines:
+ * - Where in the page tree the explorer is at.
+ * - Whether the explorer is open or not.
+ */
+export default function explorer(prevState = defaultState, { type, payload }) {
+  const state = Object.assign({}, prevState);
+
+  switch (type) {
+  case 'OPEN_EXPLORER':
+    // Provide a starting page when opening the explorer.
+    state.path = [payload.id];
+    state.isVisible = true;
+    break;
+
+  case 'CLOSE_EXPLORER':
+    state.path = [];
+    state.isVisible = false;
+    break;
+
+  case 'PUSH_PAGE':
+    state.path = state.path.concat([payload.id]);
+    break;
+
+  case 'POP_PAGE':
+    state.path = state.path.slice(0, -1);
+    break;
+
+  default:
+    break;
+  }
+
+  return state;
+}

+ 32 - 0
client/src/components/Explorer/reducers/explorer.test.js

@@ -0,0 +1,32 @@
+import explorer from './explorer';
+
+describe('explorer', () => {
+  const initialState = explorer(undefined, {});
+
+  it('exists', () => {
+    expect(explorer).toBeDefined();
+  });
+
+  it('returns the initial state if no input is provided', () =>  {
+    expect(explorer(undefined, {})).toEqual(initialState);
+  });
+
+  it('OPEN_EXPLORER', () => {
+    const action = { type: 'OPEN_EXPLORER', payload: { id: 1 } };
+    expect(explorer(initialState, action)).toMatchSnapshot();
+  });
+
+  it('CLOSE_EXPLORER', () => {
+    expect(explorer(initialState, { type: 'CLOSE_EXPLORER' })).toEqual(initialState);
+  });
+
+  it('PUSH_PAGE', () => {
+    expect(explorer(initialState, { type: 'PUSH_PAGE', payload: { id: 100 } })).toMatchSnapshot();
+  });
+
+  it('POP_PAGE', () => {
+    const state = explorer(initialState, { type: 'PUSH_PAGE', payload: { id: 100 } });
+    const action = { type: 'POP_PAGE', payload: { id: 100 } };
+    expect(explorer(state, action)).toMatchSnapshot();
+  });
+});

+ 0 - 8
client/src/components/Explorer/reducers/index.test.js

@@ -1,8 +0,0 @@
-import * as actions from '../actions';
-import rootReducer from './index';
-
-describe('root', () => {
-  it('exists', () => {
-    expect(rootReducer).toBeDefined();
-  });
-});

+ 69 - 0
client/src/components/Explorer/reducers/nodes.js

@@ -0,0 +1,69 @@
+const defaultState = {};
+
+const defaultPageState = {
+  isFetching: false,
+  isLoaded: true,
+  isError: false,
+  children: {
+    items: [],
+    count: 0,
+    isFetching: false,
+  },
+  meta: {
+    children: {},
+  },
+};
+
+export default function nodes(prevState = defaultState, { type, payload }) {
+  const state = Object.assign({}, prevState);
+
+  switch (type) {
+  case 'OPEN_EXPLORER':
+    state[payload.id] = Object.assign({}, defaultPageState, state[payload.id]);
+    break;
+
+  case 'GET_PAGE_SUCCESS':
+    state[payload.id] = Object.assign({}, state[payload.id], payload.data);
+    state[payload.id].isError = false;
+    break;
+
+  case 'GET_CHILDREN_START':
+    state[payload.id] = Object.assign({}, state[payload.id]);
+    state[payload.id].isFetching = true;
+    state[payload.id].children = Object.assign({}, state[payload.id].children);
+    state[payload.id].children.isFetching = true;
+    break;
+
+  case 'GET_CHILDREN_SUCCESS':
+    state[payload.id] = Object.assign({}, state[payload.id]);
+    state[payload.id].isFetching = false;
+    state[payload.id].isError = false;
+    state[payload.id].children = Object.assign({}, state[payload.id].children, {
+      items: state[payload.id].children.items.slice(),
+      count: payload.meta.total_count,
+      isFetching: false,
+      isLoaded: true,
+      isError: false,
+    });
+
+    payload.items.forEach((item) => {
+      state[item.id] = Object.assign({}, defaultPageState, state[item.id], item);
+
+      state[payload.id].children.items.push(item.id);
+    });
+    break;
+
+  case 'GET_PAGE_FAILURE':
+  case 'GET_CHILDREN_FAILURE':
+    state[payload.id] = Object.assign({}, state[payload.id]);
+    state[payload.id].isFetching = false;
+    state[payload.id].isError = true;
+    state[payload.id].children.isFetching = false;
+    break;
+
+  default:
+    break;
+  }
+
+  return state;
+}

+ 59 - 0
client/src/components/Explorer/reducers/nodes.test.js

@@ -0,0 +1,59 @@
+import nodes from './nodes';
+
+describe('nodes', () => {
+  const initialState = nodes(undefined, {});
+
+  it('exists', () => {
+    expect(nodes).toBeDefined();
+  });
+
+  it('empty state', () => {
+    expect(initialState).toMatchSnapshot();
+  });
+
+  it('OPEN_EXPLORER', () => {
+    const action = { type: 'OPEN_EXPLORER', payload: { id: 1 } };
+    expect(nodes(initialState, action)).toMatchSnapshot();
+  });
+
+  it('GET_PAGE_SUCCESS', () => {
+    const action = { type: 'GET_PAGE_SUCCESS', payload: { id: 1, data: {} } };
+    expect(nodes(initialState, action)).toMatchSnapshot();
+  });
+
+  it('GET_PAGE_FAILURE', () => {
+    const state = nodes(initialState, { type: 'OPEN_EXPLORER', payload: { id: 1 } });
+    const action = { type: 'GET_PAGE_FAILURE', payload: { id: 1 } };
+    expect(nodes(state, action)).toMatchSnapshot();
+  });
+
+  it('GET_CHILDREN_START', () => {
+    const action = { type: 'GET_CHILDREN_START', payload: { id: 1 } };
+    expect(nodes(initialState, action)).toMatchSnapshot();
+  });
+
+  it('GET_CHILDREN_SUCCESS', () => {
+    const state = nodes(initialState, { type: 'OPEN_EXPLORER', payload: { id: 1 } });
+    const action = {
+      type: 'GET_CHILDREN_SUCCESS',
+      payload: {
+        id: 1,
+        items: [
+          { id: 3 },
+          { id: 4 },
+          { id: 5 },
+        ],
+        meta: {
+          total_count: 3,
+        },
+      },
+    };
+    expect(nodes(state, action)).toMatchSnapshot();
+  });
+
+  it('GET_CHILDREN_FAILURE', () => {
+    const state = nodes(initialState, { type: 'OPEN_EXPLORER', payload: { id: 1 } });
+    const action = { type: 'GET_CHILDREN_FAILURE', payload: { id: 1 } };
+    expect(nodes(state, action)).toMatchSnapshot();
+  });
+});

+ 6 - 1
client/src/components/icon/Icon.js → client/src/components/Icon/Icon.js

@@ -1,7 +1,12 @@
 import React from 'react';
 
+/**
+ * Abstracts away the actual icon implementation (font icons, SVG icons, CSS sprite).
+ * Provide a `title` as an accessible label intended for screen readers.
+ */
 const Icon = ({ name, className, title }) => (
-  <span className={`icon icon-${name} ${className}`} aria-hidden={!title}>
+  <span>
+    <span className={`icon icon-${name} ${className}`} aria-hidden></span>
     {title ? (
       <span className="visuallyhidden">
         {title}

+ 21 - 10
client/src/components/Icon/__snapshots__/Icon.test.js.snap

@@ -1,21 +1,32 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
 exports[`Icon #className 1`] = `
-<span
-  aria-hidden={true}
-  className="icon icon-test u-test" />
+<span>
+  <span
+    aria-hidden={true}
+    className="icon icon-test u-test"
+  />
+</span>
 `;
 
 exports[`Icon #name 1`] = `
-<span
-  aria-hidden={true}
-  className="icon icon-test " />
+<span>
+  <span
+    aria-hidden={true}
+    className="icon icon-test "
+  />
+</span>
 `;
 
 exports[`Icon #title 1`] = `
-<span
-  aria-hidden={false}
-  className="icon icon-test ">
+<span>
+  <span
+    aria-hidden={true}
+    className="icon icon-test "
+  />
   <span
-    className="visuallyhidden">
+    className="visuallyhidden"
+  >
     Test title
   </span>
 </span>

+ 0 - 10
client/src/components/LoadingIndicator/LoadingIndicator.js

@@ -1,10 +0,0 @@
-import React from 'react';
-import { STRINGS } from '../../config/wagtail';
-
-const LoadingIndicator = () => (
-  <div className="o-icon c-indicator is-spinning">
-    <span ariaRole="presentation">{STRINGS.LOADING}</span>
-  </div>
-);
-
-export default LoadingIndicator;

+ 0 - 14
client/src/components/LoadingIndicator/LoadingIndicator.test.js

@@ -1,14 +0,0 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-
-import LoadingIndicator from './LoadingIndicator';
-
-describe('LoadingIndicator', () => {
-  it('exists', () => {
-    expect(LoadingIndicator).toBeDefined();
-  });
-
-  it('basic', () => {
-    expect(shallow(<LoadingIndicator />)).toMatchSnapshot();
-  });
-});

+ 0 - 9
client/src/components/LoadingIndicator/__snapshots__/LoadingIndicator.test.js.snap

@@ -1,9 +0,0 @@
-exports[`LoadingIndicator basic 1`] = `
-<div
-  className="o-icon c-indicator is-spinning">
-  <span
-    ariaRole="presentation">
-    Loading...
-  </span>
-</div>
-`;

+ 14 - 0
client/src/components/LoadingSpinner/LoadingSpinner.js

@@ -0,0 +1,14 @@
+import React from 'react';
+import { STRINGS } from '../../config/wagtailConfig';
+import Icon from '../../components/Icon/Icon';
+
+/**
+ * A loading indicator with a text label next to it.
+ */
+const LoadingSpinner = () => (
+  <span>
+    <Icon name="spinner" className="c-spinner" />{` ${STRINGS.LOADING}`}
+  </span>
+);
+
+export default LoadingSpinner;

+ 5 - 0
client/src/components/LoadingSpinner/LoadingSpinner.scss

@@ -0,0 +1,5 @@
+.c-spinner:after {
+    display: inline-block;
+    animation: spin 0.5s infinite linear;
+    line-height: 1;
+}

+ 0 - 0
client/src/components/Explorer/LoadingSpinner.test.js → client/src/components/LoadingSpinner/LoadingSpinner.test.js


+ 12 - 0
client/src/components/LoadingSpinner/__snapshots__/LoadingSpinner.test.js.snap

@@ -0,0 +1,12 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`LoadingSpinner basic 1`] = `
+<span>
+  <Icon
+    className="c-spinner"
+    name="spinner"
+    title={null}
+  />
+   Loading...
+</span>
+`;

+ 9 - 3
client/src/components/PublicationStatus/PublicationStatus.js

@@ -1,13 +1,19 @@
 import React from 'react';
 
-const PublicationStatus = ({ status }) => (status ? (
+/**
+ * Displays the publication status of a page in a pill.
+ */
+const PublicationStatus = ({ status }) => (
   <span className={`o-pill c-status${status.live ? ' c-status--live' : ''}`}>
     {status.status}
   </span>
-) : null);
+);
 
 PublicationStatus.propTypes = {
-  status: React.PropTypes.object,
+  status: React.PropTypes.shape({
+    live: React.PropTypes.bool.isRequired,
+    status: React.PropTypes.string.isRequired,
+  }).isRequired,
 };
 
 export default PublicationStatus;

+ 6 - 0
client/src/components/PublicationStatus/PublicationStatus.scss

@@ -0,0 +1,6 @@
+.c-status {
+    background: $color-grey-1;
+    text-transform: uppercase;
+    letter-spacing: .03rem;
+    font-size: 10px;
+}

+ 0 - 4
client/src/components/PublicationStatus/PublicationStatus.test.js

@@ -8,10 +8,6 @@ describe('PublicationStatus', () => {
     expect(PublicationStatus).toBeDefined();
   });
 
-  it('basic', () => {
-    expect(shallow(<PublicationStatus />)).toMatchSnapshot();
-  });
-
   it('#status live', () => {
     expect(shallow((
       <PublicationStatus

+ 0 - 19
client/src/components/PublicationStatus/README.md

@@ -1,19 +0,0 @@
-# PublicationStatus
-
-Displays the publication status of a page in a pill.
-
-## Usage
-
-```javascript
-import { PublicationStatus } from 'wagtail';
-
-render(
-    <PublicationStatus
-        status={status}
-    />
-);
-```
-
-### Available props
-
-- `status`: status object coming from the admin API. If no status is given, component renders to null.

+ 6 - 4
client/src/components/PublicationStatus/__snapshots__/PublicationStatus.test.js.snap

@@ -1,15 +1,17 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
 exports[`PublicationStatus #status live 1`] = `
 <span
-  className="o-pill c-status c-status--live">
+  className="o-pill c-status c-status--live"
+>
   live + draft
 </span>
 `;
 
 exports[`PublicationStatus #status not live 1`] = `
 <span
-  className="o-pill c-status">
+  className="o-pill c-status"
+>
   live + draft
 </span>
 `;
-
-exports[`PublicationStatus basic 1`] = `null`;

+ 48 - 0
client/src/components/Transition/Transition.js

@@ -0,0 +1,48 @@
+import React from 'react';
+
+import CSSTransitionGroup from 'react-addons-css-transition-group';
+
+const TRANSITION_DURATION = 210;
+
+// The available transitions. Must match the class names in CSS.
+export const PUSH = 'push';
+export const POP = 'pop';
+export const FADE = 'fade';
+
+/**
+ * Wrapper arround react-addons-css-transition-group with default values.
+ */
+const Transition = ({
+  name,
+  component,
+  className,
+  duration,
+  children,
+}) => (
+  <CSSTransitionGroup
+    component={component}
+    transitionEnterTimeout={duration}
+    transitionLeaveTimeout={duration}
+    transitionName={`c-transition-${name}`}
+    className={className}
+  >
+    {children}
+  </CSSTransitionGroup>
+);
+
+Transition.propTypes = {
+  name: React.PropTypes.oneOf([PUSH, POP, FADE]).isRequired,
+  component: React.PropTypes.string,
+  className: React.PropTypes.string,
+  duration: React.PropTypes.number,
+  children: React.PropTypes.node,
+};
+
+Transition.defaultProps = {
+  component: 'div',
+  children: null,
+  className: null,
+  duration: TRANSITION_DURATION,
+};
+
+export default Transition;

+ 85 - 0
client/src/components/Transition/Transition.scss

@@ -0,0 +1,85 @@
+// =============================================================================
+// Transitions
+// =============================================================================
+
+$c-transition-duration: 200ms;
+
+.c-transition-group {
+    position: absolute;
+    width: 100%;
+    top: 0;
+}
+
+.c-transition-push-enter {
+    transform: translateX(100%);
+    transition: transform $c-transition-duration ease, opacity $c-transition-duration linear;
+    opacity: 0;
+}
+
+.c-transition-push-enter-active {
+    transform: translateX(0);
+    opacity: 1;
+}
+
+.c-transition-push-leave {
+    transform: translateX(0);
+    transition: transform $c-transition-duration ease, opacity $c-transition-duration linear;
+    opacity: 1;
+}
+
+.c-transition-push-leave-active {
+    transform: translateX(-100%);
+    opacity: 0;
+}
+
+// =============================================================================
+// Pop transition
+// =============================================================================
+
+.c-transition-pop-enter {
+    transform: translateX(-100%);
+    transition: transform $c-transition-duration ease, opacity $c-transition-duration linear;
+    opacity: 0;
+}
+
+.c-transition-pop-enter-active {
+    transform: translateX(0);
+    opacity: 1;
+}
+
+.c-transition-pop-leave {
+    transform: translateX(0);
+    transition: transform $c-transition-duration ease, opacity $c-transition-duration linear;
+    opacity: 1;
+}
+
+.c-transition-pop-leave-active {
+    transform: translateX(100%);
+    opacity: 0;
+}
+
+// =============================================================================
+// Fade transition
+// =============================================================================
+
+.c-transition-fade-enter {
+    position: absolute;
+    width: 100%;
+    opacity: 0;
+    transition: opacity $c-transition-duration ease $c-transition-duration;
+}
+
+.c-transition-fade-enter-active {
+    opacity: 1;
+}
+
+.c-transition-fade-leave {
+    position: absolute;
+    width: 100%;
+    opacity: 1;
+    transition: opacity $c-transition-duration ease;
+}
+
+.c-transition-fade-leave-active {
+    opacity: 0;
+}

+ 14 - 0
client/src/components/Transition/Transition.test.js

@@ -0,0 +1,14 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import Transition, { PUSH } from './Transition';
+
+describe('Transition', () => {
+  it('exists', () => {
+    expect(Transition).toBeDefined();
+  });
+
+  it('basic', () => {
+    expect(shallow(<Transition name={PUSH} />)).toMatchSnapshot();
+  });
+});

+ 14 - 0
client/src/components/Transition/__snapshots__/Transition.test.js.snap

@@ -0,0 +1,14 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Transition basic 1`] = `
+<ReactCSSTransitionGroup
+  className={null}
+  component="div"
+  transitionAppear={false}
+  transitionEnter={true}
+  transitionEnterTimeout={210}
+  transitionLeave={true}
+  transitionLeaveTimeout={210}
+  transitionName="c-transition-push"
+/>
+`;

+ 0 - 116
client/src/components/explorer/Explorer.js

@@ -1,116 +0,0 @@
-import React from 'react';
-import CSSTransitionGroup from 'react-addons-css-transition-group';
-import { connect } from 'react-redux';
-
-import * as actions from './actions';
-import { EXPLORER_ANIM_DURATION } from '../../config/config';
-import ExplorerPanel from './ExplorerPanel';
-
-// TODO To refactor.
-class Explorer extends React.Component {
-  constructor(props) {
-    super(props);
-    this.init = this.init.bind(this);
-  }
-
-  componentDidMount() {
-    const { defaultPage, setDefaultPage } = this.props;
-
-    if (defaultPage) {
-      setDefaultPage(defaultPage);
-    }
-  }
-
-  init() {
-    const { page, defaultPage, onShow } = this.props;
-    if (page && page.isLoaded) {
-      return;
-    }
-
-    onShow(page ? page : defaultPage);
-  }
-
-  getPage() {
-    const { nodes, path } = this.props;
-    const id = path[path.length - 1];
-    return nodes[id];
-  }
-
-  render() {
-    const { isVisible, nodes, path, pageTypes, type, fetching, resolved, onPop, onClose, transport, getChildren, loadItemWithChildren, pushPage } = this.props;
-
-    return (
-      <CSSTransitionGroup
-        component="div"
-        transitionEnterTimeout={EXPLORER_ANIM_DURATION}
-        transitionLeaveTimeout={EXPLORER_ANIM_DURATION}
-        transitionName="explorer-toggle"
-      >
-        {isVisible ? (
-          <ExplorerPanel
-            path={path}
-            pageTypes={pageTypes}
-            page={this.getPage()}
-            type={type}
-            fetching={fetching}
-            nodes={nodes}
-            resolved={resolved}
-            onPop={onPop}
-            onClose={onClose}
-            transport={transport}
-            getChildren={getChildren}
-            loadItemWithChildren={loadItemWithChildren}
-            pushPage={pushPage}
-            init={this.init}
-          />
-        ) : null}
-      </CSSTransitionGroup>
-    );
-  }
-}
-
-Explorer.propTypes = {
-  isVisible: React.PropTypes.bool.isRequired,
-  fetching: React.PropTypes.bool.isRequired,
-  resolved: React.PropTypes.bool.isRequired,
-  path: React.PropTypes.array,
-  type: React.PropTypes.string.isRequired,
-  nodes: React.PropTypes.object.isRequired,
-  transport: React.PropTypes.object.isRequired,
-  page: React.PropTypes.any,
-  defaultPage: React.PropTypes.number,
-  onPop: React.PropTypes.func.isRequired,
-  setDefaultPage: React.PropTypes.func.isRequired,
-  onShow: React.PropTypes.func.isRequired,
-  onClose: React.PropTypes.func.isRequired,
-  getChildren: React.PropTypes.func.isRequired,
-  loadItemWithChildren: React.PropTypes.func.isRequired,
-  pushPage: React.PropTypes.func.isRequired,
-  pageTypes: React.PropTypes.object.isRequired,
-};
-
-const mapStateToProps = (state) => ({
-  isVisible: state.explorer.isVisible,
-  page: state.explorer.currentPage,
-  fetching: state.explorer.isFetching,
-  resolved: state.explorer.isResolved,
-  path: state.explorer.path,
-  pageTypes: state.explorer.pageTypes,
-  // page: state.explorer.page
-  // indexes: state.entities.indexes,
-  nodes: state.nodes,
-  animation: state.explorer.animation,
-  transport: state.transport
-});
-
-const mapDispatchToProps = (dispatch) => ({
-  setDefaultPage: (id) => dispatch(actions.setDefaultPage(id)),
-  getChildren: (id) => dispatch(actions.fetchChildren(id)),
-  onShow: () => dispatch(actions.fetchRoot()),
-  loadItemWithChildren: (id) => dispatch(actions.fetchPage(id)),
-  pushPage: (id) => dispatch(actions.pushPage(id)),
-  onPop: () => dispatch(actions.popPage()),
-  onClose: () => dispatch(actions.toggleExplorer()),
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(Explorer);

+ 0 - 49
client/src/components/explorer/ExplorerHeader.js

@@ -1,49 +0,0 @@
-import React from 'react';
-import CSSTransitionGroup from 'react-addons-css-transition-group';
-import { EXPLORER_ANIM_DURATION } from '../../config/config';
-import { STRINGS } from '../../config/wagtail';
-
-import Icon from '../../components/Icon/Icon';
-
-const ExplorerHeader = ({ page, depth, onPop, transName }) => {
-  const title = depth < 2 || !page ? STRINGS.PAGES : page.title;
-
-  return (
-    <div className="c-explorer__header">
-      <button
-        role="button"
-        className={`c-explorer__trigger${depth > 1 ? ' c-explorer__trigger--enabled' : ''}`}
-        onClick={onPop}
-        tabIndex={depth === 1 ? -1 : 0}
-      >
-        <span className="u-overflow c-explorer__overflow">
-          <CSSTransitionGroup
-            component="span"
-            transitionEnterTimeout={EXPLORER_ANIM_DURATION}
-            transitionLeaveTimeout={EXPLORER_ANIM_DURATION}
-            transitionName={`explorer-${transName}`}
-            className="c-explorer__rel"
-          >
-            <span className="c-explorer__parent-name" key={depth}>
-              {depth > 1 ? (
-                <span className="c-explorer__back">
-                  <Icon name="arrow-left" />
-                </span>
-              ) : null}
-              {title}
-            </span>
-          </CSSTransitionGroup>
-        </span>
-      </button>
-    </div>
-  );
-};
-
-ExplorerHeader.propTypes = {
-  page: React.PropTypes.object,
-  depth: React.PropTypes.number,
-  onPop: React.PropTypes.func,
-  transName: React.PropTypes.string,
-};
-
-export default ExplorerHeader;

+ 0 - 51
client/src/components/explorer/ExplorerItem.js

@@ -1,51 +0,0 @@
-import React from 'react';
-
-import { ADMIN_URLS, STRINGS } from '../../config/wagtail';
-import Icon from '../../components/Icon/Icon';
-import Button from '../../components/Button/Button';
-import PublicationStatus from '../../components/PublicationStatus/PublicationStatus';
-import AbsoluteDate from '../../components/AbsoluteDate/AbsoluteDate';
-
-const ExplorerItem = ({ title, typeName, data, onItemClick }) => {
-  const { id, meta } = data;
-  const status = meta ? meta.status : null;
-  const time = meta ? meta.latest_revision_created_at : null;
-
-  // TODO Use meta.children.count once we drop the has_children filter.
-  // const hasChildren = meta ? meta.children.count > 0 : false;
-  const hasChildren = meta ? meta.descendants.count - meta.children.count > 0 : false;
-
-  return (
-    <div className="c-explorer__item">
-      <Button href={`${ADMIN_URLS.PAGES}${id}`} className="c-explorer__item__link">
-        <h3 className="c-explorer__title">{title}</h3>
-        <p className="c-explorer__meta">
-          <span className="c-explorer__meta__type">{typeName}</span> | <AbsoluteDate time={time} /> | <PublicationStatus status={status} />
-        </p>
-      </Button>
-      {hasChildren ? (
-        <Button
-          href={`${ADMIN_URLS.PAGES}${id}`}
-          className="c-explorer__item__children"
-          onClick={onItemClick.bind(null, id)}
-        >
-          <Icon name="arrow-right" title={STRINGS.SEE_CHILDREN} />
-        </Button>
-      ) : null}
-    </div>
-  );
-};
-
-ExplorerItem.propTypes = {
-  title: React.PropTypes.string,
-  data: React.PropTypes.object,
-  typeName: React.PropTypes.string,
-  onItemClick: React.PropTypes.func,
-};
-
-ExplorerItem.defaultProps = {
-  data: {},
-  onItemClick: () => {},
-};
-
-export default ExplorerItem;

+ 0 - 193
client/src/components/explorer/ExplorerPanel.js

@@ -1,193 +0,0 @@
-import React from 'react';
-import CSSTransitionGroup from 'react-addons-css-transition-group';
-import FocusTrap from 'focus-trap-react'
-
-import { EXPLORER_ANIM_DURATION } from '../../config/config';
-import { STRINGS } from '../../config/wagtail';
-
-
-import ExplorerHeader from './ExplorerHeader';
-import ExplorerItem from './ExplorerItem';
-import LoadingSpinner from './LoadingSpinner';
-import PageCount from './PageCount';
-
-export default class ExplorerPanel extends React.Component {
-  constructor(props) {
-    super(props);
-    this.onItemClick = this.onItemClick.bind(this);
-
-    this.state = {
-      // TODO Refactor value to constant.
-      animation: 'push',
-    };
-  }
-
-  componentWillReceiveProps(newProps) {
-    const { path } = this.props;
-
-    if (path) {
-      const isPush = newProps.path.length > path.length;
-      const animation = isPush ? 'push' : 'pop';
-
-      this.setState({
-        animation: animation,
-      });
-    }
-  }
-
-  loadChildren() {
-    const { page, getChildren } = this.props;
-
-    if (page && !page.children.isFetching) {
-      if (page.meta.children.count && !page.children.length && !page.children.isFetching && !page.children.isLoaded) {
-        getChildren(page.id);
-      }
-    }
-  }
-
-  componentDidUpdate() {
-    this.loadChildren();
-  }
-
-  componentDidMount() {
-    const { init } = this.props;
-
-    init();
-    document.body.classList.add('explorer-open');
-  }
-
-  componentWillUnmount() {
-    document.body.classList.remove('explorer-open');
-  }
-
-  onItemClick(id, e) {
-    const { nodes, pushPage, loadItemWithChildren } = this.props;
-    const node = nodes[id];
-
-    e.preventDefault();
-    e.stopPropagation();
-
-    if (node.isLoaded) {
-      pushPage(id);
-    } else {
-      loadItemWithChildren(id);
-    }
-  }
-
-  renderChildren(page) {
-    const { nodes, pageTypes } = this.props;
-
-    if (!page || !page.children.items) {
-      return [];
-    }
-
-    return page.children.items
-      .map(index => nodes[index])
-      .map((item) => {
-        const typeName = pageTypes[item.meta.type] ? pageTypes[item.meta.type].verbose_name : item.meta.type;
-
-        return (
-          <ExplorerItem
-            onItemClick={this.onItemClick}
-            parent={page}
-            key={item.id}
-            title={item.title}
-            typeName={typeName}
-            data={item}
-          />
-        );
-      });
-  }
-
-  getContents() {
-    const { page } = this.props;
-    let ret;
-
-    if (page) {
-      if (page.children.items.length) {
-        ret = this.renderChildren(page);
-      } else {
-        ret = (
-          <div className="c-explorer__placeholder">
-            {STRINGS.NO_RESULTS}
-          </div>
-        );
-      }
-    }
-
-    return ret;
-  }
-
-  render() {
-    const { type, page, onPop, onClose, path, resolved } = this.props;
-    const { animation } = this.state;
-
-    return !resolved ? (
-      <div />
-    ) : (
-      <FocusTrap
-        paused={page.isFetching}
-        focusTrapOptions={{
-          onDeactivate: onClose,
-          clickOutsideDeactivates: true,
-        }}
-      >
-        {/* FocusTrap gets antsy while the page is loading, so we give it something to focus on. */}
-        {page.isFetching && <div tabIndex={0} />}
-        <div className={`c-explorer ${type ? 'c-explorer--' + type : ''}`} tabIndex={-1}>
-          <ExplorerHeader
-            depth={path.length}
-            page={page}
-            onPop={onPop}
-            onClose={onClose}
-            transName={animation}
-          />
-          <div className="c-explorer__drawer">
-            <CSSTransitionGroup
-              component="div"
-              transitionEnterTimeout={EXPLORER_ANIM_DURATION}
-              transitionLeaveTimeout={EXPLORER_ANIM_DURATION}
-              transitionName={`explorer-${animation}`}
-            >
-              <div key={path.length} className="c-explorer__transition-group">
-                <CSSTransitionGroup
-                  component="div"
-                  transitionEnterTimeout={EXPLORER_ANIM_DURATION}
-                  transitionLeaveTimeout={EXPLORER_ANIM_DURATION}
-                  transitionName="explorer-fade"
-                >
-                  {page.isFetching ? (
-                    <LoadingSpinner key={1} />
-                  ) : (
-                    <div key={0}>
-                      {this.getContents()}
-                      {(page.children.count > page.children.items.length) && (
-                        <PageCount id={page.id} count={page.meta.children.count} title={page.title} />
-                      )}
-                    </div>
-                  )}
-                </CSSTransitionGroup>
-
-              </div>
-            </CSSTransitionGroup>
-          </div>
-        </div>
-      </FocusTrap>
-    );
-  }
-}
-
-ExplorerPanel.propTypes = {
-  page: React.PropTypes.object,
-  onPop: React.PropTypes.func.isRequired,
-  onClose: React.PropTypes.func.isRequired,
-  type: React.PropTypes.string.isRequired,
-  path: React.PropTypes.array,
-  resolved: React.PropTypes.bool.isRequired,
-  init: React.PropTypes.func.isRequired,
-  getChildren: React.PropTypes.func.isRequired,
-  pushPage: React.PropTypes.func.isRequired,
-  loadItemWithChildren: React.PropTypes.func.isRequired,
-  nodes: React.PropTypes.object.isRequired,
-  pageTypes: React.PropTypes.object.isRequired,
-};

+ 0 - 41
client/src/components/explorer/ExplorerToggle.js

@@ -1,41 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-
-import * as actions from './actions';
-
-import Button from '../../components/Button/Button';
-
-/**
- * A Button which toggles the explorer, and doubles as a loading indicator.
- */
-// TODO isVisible should not be used here, but at the moment there is a click
-// binding problem between this and the ExplorerPanel clickOutside.
-const ExplorerToggle = ({ isVisible, isFetching, children, onToggle }) => (
-  <Button
-    icon="folder-open-inverse"
-    isLoading={isFetching}
-    onClick={isVisible ? null : onToggle}
-  >
-    {children}
-  </Button>
-);
-
-ExplorerToggle.propTypes = {
-  isVisible: React.PropTypes.bool,
-  isFetching: React.PropTypes.bool,
-  onToggle: React.PropTypes.func,
-  children: React.PropTypes.node,
-};
-
-const mapStateToProps = (store) => ({
-  isFetching: store.explorer.isFetching,
-  isVisible: store.explorer.isVisible,
-});
-
-const mapDispatchToProps = (dispatch) => ({
-  onToggle() {
-    dispatch(actions.toggleExplorer());
-  },
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(ExplorerToggle);

+ 0 - 11
client/src/components/explorer/LoadingSpinner.js

@@ -1,11 +0,0 @@
-import React from 'react';
-import { STRINGS } from '../../config/wagtail';
-import Icon from '../../components/Icon/Icon';
-
-const LoadingSpinner = () => (
-  <div className="c-explorer__loading">
-    <Icon name="spinner" className="c-explorer__spinner" /> {STRINGS.LOADING}
-  </div>
-);
-
-export default LoadingSpinner;

+ 0 - 99
client/src/components/explorer/actions/index.js

@@ -1,99 +0,0 @@
-import { createAction } from 'redux-actions';
-
-import { PAGES_ROOT_ID } from '../../../config/config';
-import * as admin from '../../../api/admin';
-
-export const fetchStart = createAction('FETCH_START');
-
-export const fetchSuccess = createAction('FETCH_SUCCESS', (id, body) => ({ id, body }));
-
-export const fetchFailure = createAction('FETCH_FAILURE');
-
-export const pushPage = createAction('PUSH_PAGE');
-
-export const popPage = createAction('POP_PAGE');
-
-export const fetchBranchSuccess = createAction('FETCH_BRANCH_SUCCESS', (id, json) => ({ id, json }));
-
-export const fetchBranchStart = createAction('FETCH_BRANCH_START');
-
-export const clearError = createAction('CLEAR_TRANSPORT_ERROR');
-
-export const resetTree = createAction('RESET_TREE');
-
-export const treeResolved = createAction('TREE_RESOLVED');
-
-export const fetchChildrenSuccess = createAction('FETCH_CHILDREN_SUCCESS', (id, json) => ({ id, json }));
-
-export const fetchChildrenStart = createAction('FETCH_CHILDREN_START');
-
-/**
- * Gets the children of a node from the API.
- */
-export function fetchChildren(id = 'root') {
-  return (dispatch, getState) => {
-    const { explorer } = getState();
-
-    dispatch(fetchChildrenStart(id));
-
-    return admin.getChildPages(id, {
-      fields: explorer.fields,
-    }).then(json => dispatch(fetchChildrenSuccess(id, json)));
-  };
-}
-
-// Make this a bit better... hmm....
-export function fetchTree(id = 1) {
-  return (dispatch) => {
-    dispatch(fetchBranchStart(id));
-
-    return admin.getPage(id).then((json) => {
-      dispatch(fetchBranchSuccess(id, json));
-
-      // Recursively walk up the tree to the root, to figure out how deep
-      // in the tree we are.
-      if (json.meta.parent) {
-        dispatch(fetchTree(json.meta.parent.id));
-      } else {
-        dispatch(treeResolved());
-      }
-    });
-  };
-}
-
-export function fetchRoot() {
-  return (dispatch) => {
-    // TODO Should not need an id.
-    dispatch(resetTree(PAGES_ROOT_ID));
-    dispatch(fetchBranchStart(PAGES_ROOT_ID));
-
-    dispatch(fetchBranchSuccess(PAGES_ROOT_ID, {
-      children: {},
-      meta: {
-        children: {},
-      },
-    }));
-
-    dispatch(fetchChildren(PAGES_ROOT_ID));
-
-    dispatch(treeResolved());
-  };
-}
-
-export const toggleExplorer = createAction('TOGGLE_EXPLORER');
-
-
-/**
- * TODO: determine if page is already loaded, don't load it again, just push.
- */
-export function fetchPage(id = 1) {
-  return dispatch => {
-    dispatch(fetchStart(id));
-    return admin.getPage(id)
-      .then(json => dispatch(fetchSuccess(id, json)))
-      .then(json => dispatch(fetchChildren(id, json)))
-      .catch(json => dispatch(fetchFailure(new Error(JSON.stringify(json)))));
-  };
-}
-
-export const setDefaultPage = createAction('SET_DEFAULT_PAGE');

+ 0 - 92
client/src/components/explorer/reducers/explorer.js

@@ -1,92 +0,0 @@
-import _ from 'lodash';
-
-const defaultState = {
-  isVisible: false,
-  isFetching: false,
-  isResolved: false,
-  path: [],
-  currentPage: 1,
-  defaultPage: 1,
-  // TODO Change to include less fields (just 'descendants'?) in the next version of the admin API.
-  // Specificies which fields are to be fetched in the API calls.
-  fields: ['title', 'latest_revision_created_at', 'status', 'descendants', 'children'],
-  // Coming from the API in order to get translated / pluralised labels.
-  pageTypes: {},
-};
-
-export default function explorer(state = defaultState, action = {}) {
-  let newNodes = state.path;
-
-  switch (action.type) {
-  case 'SET_DEFAULT_PAGE':
-    return _.assign({}, state, {
-      defaultPage: action.payload
-    });
-
-  case 'RESET_TREE':
-    return _.assign({}, state, {
-      isFetching: true,
-      isResolved: false,
-      currentPage: action.payload,
-      path: [],
-    });
-
-  case 'TREE_RESOLVED':
-    return _.assign({}, state, {
-      isFetching: false,
-      isResolved: true
-    });
-
-  case 'TOGGLE_EXPLORER':
-    return _.assign({}, state, {
-      isVisible: !state.isVisible,
-      currentPage: action.payload ? action.payload : state.defaultPage,
-    });
-
-  case 'FETCH_START':
-    return _.assign({}, state, {
-      isFetching: true
-    });
-
-  case 'FETCH_BRANCH_SUCCESS':
-    if (state.path.indexOf(action.payload.id) < 0) {
-      newNodes = [action.payload.id].concat(state.path);
-    }
-
-    return _.assign({}, state, {
-      path: newNodes,
-      currentPage: state.currentPage ? state.currentPage : action.payload.id
-    });
-
-    // called on fetch page...
-  case 'FETCH_SUCCESS':
-    if (state.path.indexOf(action.payload.id) < 0) {
-      newNodes = state.path.concat([action.payload.id]);
-    }
-
-    return _.assign({}, state, {
-      isFetching: false,
-      path: newNodes,
-    });
-
-  case 'PUSH_PAGE':
-    return _.assign({}, state, {
-      path: state.path.concat([action.payload])
-    });
-
-  case 'POP_PAGE':
-    return _.assign({}, state, {
-      path: state.path.length > 1 ? state.path.slice(0, -1) : state.path,
-    });
-
-  case 'FETCH_CHILDREN_SUCCESS':
-    return _.assign({}, state, {
-      isFetching: false,
-      // eslint-disable-next-line no-underscore-dangle
-      pageTypes: _.assign({}, state.pageTypes, action.payload.json.__types),
-    });
-
-  default:
-    return state;
-  }
-}

+ 0 - 55
client/src/components/explorer/reducers/explorer.test.js

@@ -1,55 +0,0 @@
-import * as actions from '../actions';
-import _ from 'lodash';
-import rootReducer from './index';
-import explorer from './explorer';
-
-describe('explorer', () => {
-  const initialState = {
-    isVisible: false,
-    isFetching: false,
-    isResolved: false,
-    path: [],
-    currentPage: 1,
-    defaultPage: 1,
-    fields: ['title', 'latest_revision_created_at', 'status', 'descendants', 'children'],
-    pageTypes: {},
-  };
-
-  it('exists', () => {
-    expect(explorer).toBeDefined();
-  });
-  it('returns the initial state if no input is provided', () =>  {
-    expect(explorer(undefined, undefined))
-      .toEqual(initialState);
-  });
-  it('sets the default page', () => {
-    expect(explorer(initialState, {type: 'SET_DEFAULT_PAGE', payload: 100}))
-      .toEqual(_.assign({}, initialState, {defaultPage: 100}))
-  });
-  it('resets the tree', () => {
-    expect(explorer(initialState, {type: 'RESET_TREE', payload: 100}))
-      .toEqual(_.assign({}, initialState, {isFetching: true, currentPage: 100}))
-  });
-  it('has resolved the tree', () => {
-    expect(explorer(initialState, {type: 'TREE_RESOLVED'}))
-      .toEqual(_.assign({}, initialState, {isResolved: true}))
-  });
-  it('toggles the explorer', () => {
-    expect(explorer(initialState, {type: 'TOGGLE_EXPLORER', payload: 100}))
-      .toEqual(
-        _.assign({}, initialState, {isVisible: !initialState.isVisible, currentPage: 100})
-      )
-  });
-  it('starts fetching', () => {
-    expect(explorer(initialState, {type: 'FETCH_START'}))
-      .toEqual(_.assign({}, initialState, {isFetching: true}))
-  });
-  it('pushes a page to the path', () => {
-    expect(explorer(initialState, {type: 'PUSH_PAGE', payload: 100}))
-      .toEqual(_.assign({}, initialState, {path: initialState.path.concat([100])}))
-  });
-  it('pops a page off the path', () => {
-    expect(explorer(_.assign({}, initialState, {path: initialState.path.concat(["root", 100])}), {type: 'POP_PAGE', payload: 100}))
-      .toEqual(_.assign({}, initialState, {path: initialState.path.concat(["root"])}))
-  });
-});

+ 0 - 13
client/src/components/explorer/reducers/index.js

@@ -1,13 +0,0 @@
-import { combineReducers } from 'redux';
-import explorer from './explorer';
-import nodes from './nodes';
-import transport from './transport';
-
-
-const rootReducer = combineReducers({
-  explorer,
-  transport,
-  nodes,
-});
-
-export default rootReducer;

+ 0 - 93
client/src/components/explorer/reducers/nodes.js

@@ -1,93 +0,0 @@
-import _ from 'lodash';
-
-const childrenDefaultState = {
-  items: [],
-  count: 0,
-  isFetching: false
-};
-
-const children = (state = childrenDefaultState, action) => {
-  switch (action.type) {
-  case 'FETCH_CHILDREN_START':
-    return _.assign({}, state, {
-      isFetching: true,
-    });
-
-  case 'FETCH_CHILDREN_SUCCESS':
-    return _.assign({}, state, {
-      items: action.payload.json.items.map(item => item.id),
-      count: action.payload.json.meta.total_count,
-      isFetching: false,
-      isLoaded: true,
-    });
-
-  default:
-    return state;
-  }
-};
-
-const defaultState = {
-  isError: false,
-  isFetching: false,
-  isLoaded: false,
-  children: children(undefined, {})
-};
-
-// TODO Why isn't the default state used on init?
-export default function nodes(state = {}, action = {}) {
-  switch (action.type) {
-  case 'FETCH_CHILDREN_START':
-    // TODO Very hard to understand this code. To refactor.
-    return _.assign({}, state, {
-      [action.payload]: _.assign({}, state[action.payload], {
-        isFetching: true,
-        children: children(state[action.payload] ? state[action.payload].children : undefined, action)
-      })
-    });
-
-  // eslint-disable-next-line no-case-declarations
-  case 'FETCH_CHILDREN_SUCCESS':
-    // TODO Very hard to understand this code. To refactor.
-    let map = {};
-    action.payload.json.items.forEach(item => {
-      map = _.assign({}, map, {
-        [item.id]: _.assign({}, defaultState, state[item.id], item, {
-          isLoaded: true
-        })
-      });
-    });
-
-    return _.assign({}, state, map, {
-      [action.payload.id]: _.assign({}, state[action.payload.id], {
-        isFetching: false,
-        children: children(state[action.payload.id].children, action)
-      })
-    });
-
-  case 'RESET_TREE':
-    return defaultState;
-
-  case 'FETCH_START':
-    return _.assign({}, state, {
-      [action.payload]: _.assign({}, defaultState, state[action.payload], {
-        isFetching: true,
-        isError: false,
-      })
-    });
-
-  case 'FETCH_BRANCH_SUCCESS':
-    return _.assign({}, state, {
-      [action.payload.id]: _.assign({}, defaultState, state[action.payload.id], action.payload.json, {
-        isFetching: false,
-        isError: false,
-        isLoaded: true
-      })
-    });
-
-  case 'FETCH_SUCCESS':
-    return state;
-
-  default:
-    return state;
-  }
-}

+ 0 - 72
client/src/components/explorer/reducers/nodes.test.js

@@ -1,72 +0,0 @@
-import * as actions from '../actions';
-import _ from 'lodash';
-import rootReducer from './index';
-import nodes from './nodes';
-
-describe('nodes', () => {
-  const initialState = {
-    isError: false,
-    isFetching: false,
-    isLoaded: false,
-    children: {
-      items: [],
-      count: 0,
-      isFetching: false
-    }
-  };
-
-  const fetchingState = {
-    "any": {
-      isFetching: true,
-      isError: false,
-      isLoaded: false,
-      children: {
-        items: [],
-        count: 0,
-        isFetching: false
-      }
-    }
-  };
-
-  const fetchingChildren = {
-    isError: false,
-    isFetching: false,
-    isLoaded: false,
-    children: {
-      items: [],
-      count: 0,
-      isFetching: false
-    },
-    "any": {
-      isFetching: true,
-      children: {
-        items: [],
-        count: 0,
-        isFetching: true
-      }
-    }
-  };
-
-  it('exists', () => {
-    expect(nodes).toBeDefined();
-  });
-
-  it('returns empty state on no action and no input state', () => {
-    expect(nodes(undefined, undefined)).toEqual({});
-  });
-  it('returns initial state on no action and initial state input', () => {
-    expect(nodes(initialState, undefined)).toEqual(initialState);
-  });
-  it('starts fetching children', () => {
-    expect(nodes(initialState, {type: 'FETCH_CHILDREN_START', payload: 'any'})).toEqual(fetchingChildren);
-  });
-  it('resets the tree', () => {
-    expect(nodes({}, {type: 'RESET_TREE'})).toEqual(initialState);
-  });
-  it('starts fetching', () => {
-    expect(nodes({}, {type: 'FETCH_START', payload: 'any'})).toEqual(fetchingState)
-  });
-  it('makes a fetch success', () => {
-    expect(nodes({'any': 'any'}, {type: 'FETCH_SUCCESS'})).toEqual({'any': 'any'})
-  })
-});

+ 0 - 23
client/src/components/explorer/reducers/transport.js

@@ -1,23 +0,0 @@
-import _ from 'lodash';
-
-const defaultState = {
-  error: null,
-  showMessage: false,
-};
-
-export default function transport(state = defaultState, action) {
-  switch (action.type) {
-  case 'FETCH_FAILURE':
-    return _.assign({}, state, {
-      error: action.payload.message,
-      showMessage: true
-    });
-  case 'CLEAR_TRANSPORT_ERROR':
-    return _.assign({}, state, {
-      error: null,
-      showMessage: false
-    });
-  default:
-    return state;
-  }
-}

+ 0 - 36
client/src/components/explorer/reducers/transport.test.js

@@ -1,36 +0,0 @@
-import * as actions from '../actions';
-import _ from 'lodash';
-import rootReducer from './index';
-import transport from './transport';
-
-describe('transport', () => {
-  const initialState = {
-    error: null,
-    showMessage: false,
-  };
-
-  it('exists', () => {
-    expect(transport).toBeDefined();
-  });
-
-  it('returns the initial state', () => {
-    expect(transport(undefined, {})).toEqual(initialState);
-  });
-
-  it('returns error message and flag', () => {
-    const action = actions.fetchFailure(new Error('Test error'));
-    expect(transport(initialState, action)).toEqual({
-      error: 'Test error',
-      showMessage: true,
-    });
-  });
-
-  it('clears previous error message and flag', () => {
-    const action = actions.clearError();
-    const errorState = {
-      error: 'Test error',
-      showMessage: true,
-    };
-    expect(transport(errorState, action)).toEqual(initialState);
-  });
-});

+ 0 - 323
client/src/components/explorer/style.scss

@@ -1,323 +0,0 @@
-$c-explorer-bg: #4C4E4D;
-$c-explorer-secondary: #cacaca;
-$c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
-
-.c-explorer, .c-explorer * {
-    box-sizing: border-box;
-}
-
-.c-explorer {
-    width: 100%;
-    height: 500px;
-    background: $c-explorer-bg;
-    position: absolute;
-    overflow: hidden;
-}
-
-    .c-explorer--sidebar {
-        height: 100vh;
-        box-shadow: 2px 2px 5px rgba(0,0,0,0.2);
-        z-index: 150;
-    }
-
-.c-explorer__header {
-    border-bottom: solid 1px #676767;
-    overflow: hidden;
-    color: $c-explorer-secondary;
-}
-
-.c-explorer__trigger {
-    display: block;
-    padding: .5rem 1rem;
-    white-space: nowrap;
-    overflow: hidden;
-    width: 100%;
-    background: none;
-    border: none;
-    text-align: left;
-    color: $c-explorer-secondary;
-    line-height: inherit;
-    font: inherit;
-    cursor: default;
-}
-
-.c-explorer__trigger--enabled {
-    cursor: pointer;
-    &:hover, &:focus {
-        color: $color-white;
-        background: rgba(0,0,0,0.2);
-        outline: none;
-    }
-}
-
-.c-explorer__back {
-    margin-right: .25rem;
-    float: left;
-    margin-top: -1px;
-
-    &:hover {
-        color: $color-white;
-    }
-
-    .icon {
-        line-height: 1;
-        display: inline-block;
-        font-size: 16px;
-    }
-}
-
-.c-explorer__title {
-    margin: 0;
-    color: $color-white;
-}
-
-.c-explorer__loading {
-    color: $color-white;
-    padding: 1rem;
-}
-
-.c-explorer__placeholder {
-    padding: 1rem;
-    color: $color-white;
-}
-
-.c-explorer__meta {
-    font-size: 12px;
-    color: $c-explorer-secondary;
-    margin-bottom: 0;
-}
-
-    // TODO Could be a utility class
-    .c-explorer__meta__type {
-        text-transform: capitalize;
-    }
-
-.c-explorer__item {
-    display: block;
-    position: relative;
-    border-bottom: solid 1px #676767;
-    &:last-child {
-        border-bottom: 0;
-    }
-}
-
-.c-explorer__item__link {
-    display: block;
-    padding: 1rem;
-    cursor: pointer;
-
-    &:hover, &:focus {
-        background: rgba(0, 0, 0, 0.25);
-        color: $color-white;
-        outline: none;
-    }
-}
-
-.c-explorer__item__children {
-    display: inline-block;
-    color: $color-white;
-    line-height: 1;
-    padding: .7em .3em .7em .7em;
-    cursor: pointer;
-    display: inline-block;
-    position: absolute;
-    right: 0;
-    top: 0;
-    bottom: 0;
-    font-size: 2em;
-    background: transparent;
-    border: 0;
-    &:hover, &:focus {
-        background: rgba(0,0,0,0.5);
-        color: $color-white;
-        outline: none;
-    }
-}
-
-.c-explorer__see-more {
-    padding: 1rem;
-    background: rgba(0,0,0,0.2);
-    color: $c-explorer-secondary;
-    display: block;
-
-    &:hover, &:focus {
-        color: $c-explorer-secondary;
-        background: rgba(0,0,0,0.4);
-        outline: none;
-    }
-}
-
-.c-explorer__see-more__title {
-    color: $color-white;
-}
-
-.c-status {
-    background: $color-grey-1;
-    color: $c-explorer-secondary;
-    text-transform: uppercase;
-    letter-spacing: .03rem;
-    font-size: 10px;
-}
-
-.c-explorer__drawer {
-    position: absolute;
-    bottom: 0;
-    top: 36px;
-    width: 100%;
-    overflow-y: auto;
-}
-
-.c-explorer__overflow {
-    max-width: 12rem;
-    display: block;
-    text-transform: uppercase;
-    float: left;
-    width: 100%;
-    overflow: hidden;
-    white-space: nowrap;
-    text-overflow: ellipsis;
-}
-
-// =============================================================================
-// TODO: move to their own component..
-// =============================================================================
-
-.o-pill {
-    display: inline-block;
-    padding: 0 .5em;
-    border-radius: .25em;
-    line-height: 1;
-    vertical-align: middle;
-    line-height: 1.5;
-}
-
-.c-explorer__rel {
-    position: relative;
-    display: block;
-    height: 19px;
-    width: 100%;
-}
-
-.c-explorer__parent-name {
-    position: absolute;
-    width: 100%;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-    overflow: hidden;
-}
-
-.c-explorer__spinner:after {
-    display: inline-block;
-    animation: spin 0.5s infinite linear;
-    line-height: 1;
-}
-
-// =============================================================================
-// Transitions
-// =============================================================================
-
-$out-circ: cubic-bezier(0.785, 0.135, 0.150, 0.860);
-$in-circ: cubic-bezier(0.785, 0.135, 0.150, 0.860);
-$c-explorer-duration: 200ms;
-
-.c-explorer__transition-group {
-    position: absolute;
-    width: 100%;
-    top: 0;
-}
-
-.explorer-push-enter {
-    transform: translateX(100%);
-    transition: transform $c-explorer-duration $out-circ, opacity $c-explorer-duration linear;
-    opacity: 0;
-}
-
-.explorer-push-enter-active {
-    transform: translateX(0);
-    opacity: 1;
-}
-
-.explorer-push-leave {
-    transform: translateX(0);
-    transition: transform $c-explorer-duration $in-circ, opacity $c-explorer-duration linear;
-    opacity: 1;
-}
-
-.explorer-push-leave-active {
-    transform: translateX(-100%);
-    opacity: 0;
-}
-
-// =============================================================================
-// Pop transition
-// =============================================================================
-
-.explorer-pop-enter {
-    transform: translateX(-100%);
-    transition: transform $c-explorer-duration $out-circ, opacity $c-explorer-duration linear;
-    opacity: 0;
-}
-
-.explorer-pop-enter-active {
-    transform: translateX(0);
-    opacity: 1;
-}
-
-.explorer-pop-leave {
-    transform: translateX(0);
-    transition: transform $c-explorer-duration $in-circ, opacity $c-explorer-duration linear;
-    opacity: 1;
-}
-
-.explorer-pop-leave-active {
-    transform: translateX(100%);
-    opacity: 0;
-}
-
-// =============================================================================
-// Toggle transition
-// =============================================================================
-
-.explorer-toggle-enter {
-    opacity: 0;
-    transition: all $c-explorer-duration;
-}
-
-.explorer-toggle-enter-active {
-    opacity: 1;
-}
-
-.explorer-toggle-leave {
-    opacity: 1;
-    transition: all $c-explorer-duration;
-}
-
-.explorer-toggle-leave-active {
-    opacity: 0;
-}
-
-// =============================================================================
-// Fade transition
-// =============================================================================
-
-.explorer-fade-enter {
-    position: absolute;
-    width: 100%;
-    opacity: 0;
-    transition: opacity .2s ease .1s;
-}
-
-.explorer-fade-enter-active {
-    opacity: 1;
-}
-
-.explorer-fade-leave {
-    position: absolute;
-    width: 100%;
-    opacity: 1;
-    transition: opacity .1s ease;
-}
-
-.explorer-fade-leave-active {
-    opacity: 0;
-}

+ 0 - 23
client/src/components/icon/README.md

@@ -1,23 +0,0 @@
-# Icon
-
-A simple component to render an icon. Abstracts away the actual icon implementation (font icons, SVG icons, CSS sprite).
-
-## Usage
-
-```javascript
-import { Icon } from 'wagtail';
-
-render(
-    <Icon
-        name="arrow-left"
-        className="icon--active icon--warning"
-        title="Move left"
-    />
-);
-```
-
-### Available props
-
-- `name`: icon name
-- `className`: additional CSS classes to add to the element
-- `title`: accessible label intended for screen readers

+ 0 - 3
client/src/config/config.js

@@ -1,3 +0,0 @@
-export const PAGES_ROOT_ID = 'root';
-
-export const EXPLORER_ANIM_DURATION = 220;

+ 0 - 19
client/src/config/config.test.js

@@ -1,19 +0,0 @@
-import {
-  PAGES_ROOT_ID,
-  EXPLORER_ANIM_DURATION,
-} from './config';
-
-describe('config', () => {
-  describe('PAGES_ROOT_ID', () => {
-    it('exists', () => {
-      expect(PAGES_ROOT_ID).toBeDefined();
-    });
-  });
-
-  describe('EXPLORER_ANIM_DURATION', () => {
-    it('exists', () => {
-      expect(EXPLORER_ANIM_DURATION).toBeDefined();
-    });
-  });
-
-});

+ 2 - 1
client/src/config/wagtail.js → client/src/config/wagtailConfig.js

@@ -2,4 +2,5 @@ export const ADMIN_API = global.wagtailConfig.ADMIN_API;
 export const STRINGS = global.wagtailConfig.STRINGS;
 export const ADMIN_URLS = global.wagtailConfig.ADMIN_URLS;
 
-export const DATE_FORMAT = global.wagtailConfig.DATE_FORMATTING.DATE_FORMAT;
+// Maximum number of pages to load inside the explorer menu.
+export const MAX_EXPLORER_PAGES = 200;

+ 5 - 5
client/src/config/wagtail.test.js → client/src/config/wagtailConfig.test.js

@@ -2,10 +2,10 @@ import {
   ADMIN_API,
   STRINGS,
   ADMIN_URLS,
-  DATE_FORMAT,
-} from './wagtail';
+  MAX_EXPLORER_PAGES,
+} from './wagtailConfig';
 
-describe('config', () => {
+describe('wagtailConfig', () => {
   describe('ADMIN_API', () => {
     it('exists', () => {
       expect(ADMIN_API).toBeDefined();
@@ -24,9 +24,9 @@ describe('config', () => {
     });
   });
 
-  describe('DATE_FORMAT', () => {
+  describe('MAX_EXPLORER_PAGES', () => {
     it('exists', () => {
-      expect(DATE_FORMAT).toBeDefined();
+      expect(MAX_EXPLORER_PAGES).toBeDefined();
     });
   });
 });

+ 11 - 6
client/src/index.js

@@ -4,17 +4,22 @@
  */
 
 import Button from './components/Button/Button';
-import Explorer from './components/Explorer/Explorer';
 import Icon from './components/Icon/Icon';
-import LoadingIndicator from './components/LoadingIndicator/LoadingIndicator';
-import AbsoluteDate from './components/AbsoluteDate/AbsoluteDate';
 import PublicationStatus from './components/PublicationStatus/PublicationStatus';
+import LoadingSpinner from './components/LoadingSpinner/LoadingSpinner';
+import Transition from './components/Transition/Transition';
+import Explorer, {
+  ExplorerToggle,
+  initExplorer,
+} from './components/Explorer';
 
 export {
   Button,
-  Explorer,
   Icon,
-  LoadingIndicator,
-  AbsoluteDate,
   PublicationStatus,
+  LoadingSpinner,
+  Transition,
+  Explorer,
+  ExplorerToggle,
+  initExplorer,
 };

+ 35 - 32
client/src/index.test.js

@@ -1,41 +1,44 @@
 import {
   Button,
-  Explorer,
   Icon,
-  LoadingIndicator,
-  AbsoluteDate,
   PublicationStatus,
+  LoadingSpinner,
+  Transition,
+  Explorer,
+  ExplorerToggle,
+  initExplorer,
 } from './index';
 
 describe('wagtail package API', () => {
-  describe('Button', () => {
-    it('exists', () => {
-      expect(Button).toBeDefined();
-    });
-  });
-  describe('Explorer', () => {
-    it('exists', () => {
-      expect(Explorer).toBeDefined();
-    });
-  });
-  describe('Icon', () => {
-    it('exists', () => {
-      expect(Icon).toBeDefined();
-    });
-  });
-  describe('LoadingIndicator', () => {
-    it('exists', () => {
-      expect(LoadingIndicator).toBeDefined();
-    });
-  });
-  describe('AbsoluteDate', () => {
-    it('exists', () => {
-      expect(AbsoluteDate).toBeDefined();
-    });
-  });
-  describe('PublicationStatus', () => {
-    it('exists', () => {
-      expect(PublicationStatus).toBeDefined();
-    });
+  it('has Button', () => {
+    expect(Button).toBeDefined();
+  });
+
+  it('has Icon', () => {
+    expect(Icon).toBeDefined();
+  });
+
+  it('has PublicationStatus', () => {
+    expect(PublicationStatus).toBeDefined();
+  });
+
+  it('has LoadingSpinner', () => {
+    expect(LoadingSpinner).toBeDefined();
+  });
+
+  it('has Transition', () => {
+    expect(Transition).toBeDefined();
+  });
+
+  it('has Explorer', () => {
+    expect(Explorer).toBeDefined();
+  });
+
+  it('has ExplorerToggle', () => {
+    expect(ExplorerToggle).toBeDefined();
+  });
+
+  it('has initExplorer', () => {
+    expect(initExplorer).toBeDefined();
   });
 });

+ 38 - 0
client/tests/mock-fetch.js

@@ -0,0 +1,38 @@
+// Mocking the global.fetch and global.Headers
+global.fetch = jest.fn();
+global.Headers = jest.fn();
+
+// Helper to mock a success response.
+fetch.mockResponseSuccess = (body) => {
+  fetch.mockImplementationOnce(() => Promise.resolve({
+    json: () => Promise.resolve(JSON.parse(body)),
+    status: 200,
+    statusText: 'OK',
+  }));
+};
+
+// Helper to mock a failure response.
+fetch.mockResponseFailure = () => {
+  fetch.mockImplementationOnce(() => Promise.resolve({
+    status: 500,
+    statusText: 'Internal Error',
+  }));
+};
+
+fetch.mockResponseCrash = () => {
+  fetch.mockImplementationOnce(() => Promise.reject({
+    status: 500,
+    statusText: 'Internal Error',
+  }));
+};
+
+// Helper to mock a timeout response.
+fetch.mockResponseTimeout = () => {
+  fetch.mockImplementationOnce(() => {
+    const timeout = 1000;
+
+    return new Promise((resolve) => {
+      setTimeout(() => setTimeout(resolve, timeout), timeout);
+    });
+  });
+};

+ 8 - 3
client/tests/stubs.js

@@ -9,6 +9,7 @@ global.wagtailConfig = {
     DOCUMENTS: '/admin/api/v2beta/documents/',
     IMAGES: '/admin/api/v2beta/images/',
     PAGES: '/admin/api/v2beta/pages/',
+    EXTRA_CHILDREN_PARAMETERS: '',
   },
   ADMIN_URLS: {
     PAGES: '/admin/pages/',
@@ -18,11 +19,15 @@ global.wagtailConfig = {
     SHORT_DATE_FORMAT: 'DD/MM/YYYY',
   },
   STRINGS: {
-    EXPLORER: 'Explorer',
+    EDIT: 'Edit',
+    PAGE: 'Page',
+    PAGES: 'Pages',
     LOADING: 'Loading...',
+    SERVER_ERROR: 'Server Error',
     NO_RESULTS: 'No results',
-    SEE_CHILDREN: 'See Children',
-    NO_DATE: 'No date',
+    SEE_CHILDREN: 'See children',
+    SEE_ALL: 'See all',
+    CLOSE_EXPLORER: 'Close explorer',
   },
 };
 

+ 39 - 21
client/webpack/base.config.js

@@ -1,37 +1,43 @@
 const path = require('path');
-const glob = require('glob');
 const webpack = require('webpack');
 
-const COMMON_PATH = './wagtail/wagtailadmin/static/wagtailadmin/js/common.js';
+// Generates a path to an entry file to be compiled by Webpack.
+const getEntryPath = (app, filename) => path.resolve('wagtail', app, 'static_src', app, 'app', filename);
+// Generates a path to the output bundle to be loaded in the browser.
+const getOutputPath = (app, filename) => path.join('wagtail', app, 'static', app, 'js', filename);
 
-function getEntryPoint(filename) {
-  const appName = filename.split(path.sep)[2];
-  const entryName = path.basename(filename, '.entry.js');
-  const outputPath = path.join('wagtail', appName, 'static', appName, 'js', entryName);
-  const entry = {};
-
-  entry[outputPath] = ['whatwg-fetch', 'babel-polyfill', filename];
-
-  return entry;
-}
+const isVendorModule = (module) => {
+  const res = module.resource;
+  return res && res.indexOf('node_modules') >= 0 && res.match(/\.js$/);
+};
 
-function entryPoints(globPath) {
-  const paths = glob.sync(globPath);
+module.exports = function exports() {
+  const entry = {
+    // Create a vendor chunk that will contain polyfills, and all third-party dependencies.
+    vendor: ['whatwg-fetch', 'babel-polyfill'],
+  };
 
-  return paths.reduce((entries, p) => Object.assign(entries, getEntryPoint(p)), {});
-}
+  entry[getOutputPath('wagtailadmin', 'wagtailadmin')] = getEntryPath('wagtailadmin', 'wagtailadmin.entry.js');
 
-module.exports = function exports() {
   return {
-    entry: entryPoints('./wagtail/**/static_src/**/app/*.entry.js'),
+    entry: entry,
     output: {
-      path: './',
+      path: '.',
       filename: '[name].js',
       publicPath: '/static/js/'
     },
     plugins: [
-      new webpack.optimize.CommonsChunkPlugin('common', COMMON_PATH, Infinity)
+      new webpack.optimize.CommonsChunkPlugin({
+        name: 'vendor',
+        filename: getOutputPath('wagtailadmin', '[name].js'),
+        minChunks: isVendorModule,
+      }),
     ],
+    resolve: {
+      alias: {
+        'wagtail-client': path.resolve('.', 'client'),
+      },
+    },
     module: {
       loaders: [
         {
@@ -39,6 +45,18 @@ module.exports = function exports() {
           loader: 'babel'
         },
       ]
-    }
+    },
+    stats: {
+      // Add chunk information (setting this to `false` allows for a less verbose output)
+      chunks: false,
+      // Add the hash of the compilation
+      hash: false,
+      // `webpack --colors` equivalent
+      colors: true,
+      // Add information about the reasons why modules are included
+      reasons: false,
+      // Add webpack version information
+      version: false,
+    },
   };
 };

File diff suppressed because it is too large
+ 226 - 216
npm-shrinkwrap.json


+ 7 - 8
package.json

@@ -14,9 +14,9 @@
   },
   "browserify-shim": {},
   "jest": {
-    "rootDir": "client",
     "setupFiles": [
-      "./tests/stubs.js"
+      "./client/tests/stubs.js",
+      "./client/tests/mock-fetch.js"
     ],
     "snapshotSerializers": [
       "enzyme-to-json/serializer"
@@ -25,7 +25,7 @@
   "devDependencies": {
     "babel-cli": "^6.22.2",
     "babel-core": "^6.22.1",
-    "babel-jest": "^18.0.0",
+    "babel-jest": "^19.0.0",
     "babel-loader": "^6.2.10",
     "babel-plugin-lodash": "^3.2.11",
     "babel-polyfill": "^6.22.0",
@@ -40,7 +40,6 @@
     "eslint-plugin-jsx-a11y": "^1.5.3",
     "eslint-plugin-react": "^5.2.2",
     "exports-loader": "^0.6.3",
-    "glob": "^7.0.0",
     "gulp": "~3.8.11",
     "gulp-autoprefixer": "~3.0.2",
     "gulp-rename": "^1.2.2",
@@ -48,16 +47,16 @@
     "gulp-sourcemaps": "~1.5.2",
     "gulp-util": "~2.2.14",
     "imports-loader": "^0.6.5",
-    "jest": "^18.1.0",
+    "jest": "^19.0.0",
     "mustache": "^2.2.1",
     "react-addons-test-utils": "^15.4.2",
+    "redux-mock-store": "^1.2.2",
     "require-dir": "^0.3.0",
     "webpack": "^1.12.14"
   },
   "dependencies": {
     "focus-trap-react": "^3.0.2",
     "lodash": "^4.17.4",
-    "moment": "^2.17.1",
     "react": "^15.4.2",
     "react-addons-css-transition-group": "^15.4.2",
     "react-dom": "^15.4.2",
@@ -69,8 +68,8 @@
   },
   "scripts": {
     "postinstall": "cd ./client; npm install; cd ..",
-    "build": "gulp build; webpack --progress --colors --config ./client/webpack/prod.config.js",
-    "watch": "webpack --progress --colors --config ./client/webpack/dev.config.js & gulp watch",
+    "build": "gulp build; webpack --config ./client/webpack/prod.config.js",
+    "watch": "webpack --config ./client/webpack/dev.config.js & gulp watch",
     "start": "npm run watch",
     "lint:js": "eslint --max-warnings 16 ./client",
     "lint": "npm run lint:js",

Some files were not shown because too many files changed in this diff