Browse Source

Refactor explorer code with tests using Jest

Thibaud Colas 8 years ago
parent
commit
8bf2c9bf2e
85 changed files with 1938 additions and 1632 deletions
  1. 2 1
      .babelrc
  2. 5 1
      .eslintrc
  3. 0 0
      client/.npmignore
  4. 13 24
      client/README.md
  5. 11 8
      client/package.json
  6. 0 2
      client/scss/_components.scss
  7. 23 0
      client/src/api/admin.js
  8. 38 0
      client/src/api/client.js
  9. 23 0
      client/src/components/AbsoluteDate/AbsoluteDate.js
  10. 18 0
      client/src/components/AbsoluteDate/AbsoluteDate.test.js
  11. 11 0
      client/src/components/AbsoluteDate/__snapshots__/AbsoluteDate.test.js.snap
  12. 88 0
      client/src/components/Button/Button.js
  13. 39 0
      client/src/components/Button/Button.test.js
  14. 51 0
      client/src/components/Button/__snapshots__/Button.test.js.snap
  15. 14 0
      client/src/components/Explorer/Explorer.test.js
  16. 32 0
      client/src/components/Explorer/ExplorerItem.test.js
  17. 37 0
      client/src/components/Explorer/ExplorerToggle.test.js
  18. 14 0
      client/src/components/Explorer/LoadingSpinner.test.js
  19. 77 0
      client/src/components/Explorer/__snapshots__/ExplorerItem.test.js.snap
  20. 48 0
      client/src/components/Explorer/__snapshots__/ExplorerToggle.test.js.snap
  21. 11 0
      client/src/components/Explorer/__snapshots__/LoadingSpinner.test.js.snap
  22. 57 0
      client/src/components/Explorer/reducers/index.test.js
  23. 21 0
      client/src/components/Icon/Icon.test.js
  24. 22 0
      client/src/components/Icon/__snapshots__/Icon.test.js.snap
  25. 10 0
      client/src/components/LoadingIndicator/LoadingIndicator.js
  26. 14 0
      client/src/components/LoadingIndicator/LoadingIndicator.test.js
  27. 9 0
      client/src/components/LoadingIndicator/__snapshots__/LoadingIndicator.test.js.snap
  28. 13 0
      client/src/components/PublicationStatus/PublicationStatus.js
  29. 39 0
      client/src/components/PublicationStatus/PublicationStatus.test.js
  30. 19 0
      client/src/components/PublicationStatus/README.md
  31. 13 0
      client/src/components/PublicationStatus/__snapshots__/PublicationStatus.test.js.snap
  32. 48 51
      client/src/components/explorer/Explorer.js
  33. 0 8
      client/src/components/explorer/ExplorerEmpty.js
  34. 53 72
      client/src/components/explorer/ExplorerHeader.js
  35. 54 59
      client/src/components/explorer/ExplorerItem.js
  36. 92 94
      client/src/components/explorer/ExplorerPanel.js
  37. 41 0
      client/src/components/explorer/ExplorerToggle.js
  38. 20 0
      client/src/components/explorer/Filter.js
  39. 3 2
      client/src/components/explorer/LoadingSpinner.js
  40. 0 31
      client/src/components/explorer/PageCount.js
  41. 0 1
      client/src/components/explorer/README.md
  42. 49 74
      client/src/components/explorer/actions/index.js
  43. 0 18
      client/src/components/explorer/filter.js
  44. 79 76
      client/src/components/explorer/reducers/explorer.js
  45. 94 79
      client/src/components/explorer/reducers/nodes.js
  46. 21 13
      client/src/components/explorer/reducers/transport.js
  47. 27 71
      client/src/components/explorer/style.scss
  48. 0 60
      client/src/components/explorer/toggle.js
  49. 13 6
      client/src/components/icon/Icon.js
  50. 11 1
      client/src/components/icon/README.md
  51. 0 5
      client/src/components/icon/style.scss
  52. 0 7
      client/src/components/index.js
  53. 0 10
      client/src/components/loading-indicator/LoadingIndicator.js
  54. 0 1
      client/src/components/loading-indicator/README.md
  55. 0 3
      client/src/components/loading-indicator/style.scss
  56. 0 17
      client/src/components/publish-status/PublishStatus.js
  57. 0 9
      client/src/components/publish-status/README.md
  58. 0 5
      client/src/components/publish-status/style.scss
  59. 0 14
      client/src/components/published-time/PublishedTime.js
  60. 0 9
      client/src/components/published-time/README.md
  61. 0 5
      client/src/components/published-time/style.scss
  62. 0 9
      client/src/components/state-indicator/README.md
  63. 0 16
      client/src/components/state-indicator/StateIndicator.js
  64. 0 5
      client/src/components/state-indicator/style.scss
  65. 10 0
      client/src/config/config.js
  66. 25 0
      client/src/config/config.test.js
  67. 0 16
      client/src/config/index.js
  68. 5 0
      client/src/config/wagtail.js
  69. 32 0
      client/src/config/wagtail.test.js
  70. 20 1
      client/src/index.js
  71. 41 0
      client/src/index.test.js
  72. 2 2
      client/template/component.mst
  73. 5 15
      client/template/component.test.mst
  74. 0 21
      client/tests/components/Icon.test.js
  75. 0 39
      client/tests/components/explorer.test.js
  76. 20 7
      client/tests/stubs.js
  77. 1 1
      client/webpack/base.config.js
  78. 8 0
      client/webpack/dev.config.js
  79. 296 332
      npm-shrinkwrap.json
  80. 33 28
      package.json
  81. 34 33
      wagtail/wagtailadmin/static_src/wagtailadmin/app/wagtailadmin.entry.js
  82. 1 29
      wagtail/wagtailadmin/static_src/wagtailadmin/scss/components/_explorer.scss
  83. 15 1
      wagtail/wagtailadmin/static_src/wagtailadmin/scss/components/_main-nav.scss
  84. 1 230
      wagtail/wagtailadmin/static_src/wagtailadmin/scss/core.scss
  85. 12 10
      wagtail/wagtailadmin/templates/wagtailadmin/admin_base.html

+ 2 - 1
.babelrc

@@ -2,5 +2,6 @@
   "presets": [
     "es2015",
     "react"
-  ]
+  ],
+  "plugins": ["lodash"]
 }

+ 5 - 1
.eslintrc

@@ -1,3 +1,7 @@
 {
-  "extends": "wagtail"
+  "extends": "wagtail",
+
+  "env": {
+    "jest": true
+  }
 }

+ 0 - 0
client/.npmignore


+ 13 - 24
client/README.md

@@ -1,42 +1,31 @@
 # Wagtail client-side components
 
-This library aims to give developers the ability to subclass and configure Wagtail's UI components.
+> This library aims to give developers the ability to subclass and configure Wagtail's UI components.
 
 ## Usage
 
-```
+```sh
 npm install wagtail
 ```
 
 ```javascript
 import { Explorer } from 'wagtail';
-
-...
-
-<Explorer onChoosePage={(page)=> { console.log(`You picked ${page}`); }} />
-
+// [...]
+<Explorer />
 ```
 
-## Available components
-
-TODO
+## Development
 
-- [ ] Explorer
-- [ ] Modal
-- [ ] DatePicker
-- [ ] LinkChooser
-- [ ] DropDown
-
-## Building in development
-
-Run `webpack` from the Wagtail project root.
-
-```
-webpack
+```sh
+# From the project root, start the webpack + styles compilation.
+npm run start
 ```
 
-## How to release
+You will also need:
 
-The front-end is bundled at the same time as the Wagtail project, via `setuptools`.
+- [React DevTools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) – React developer tools integrated into Chrome.
+- [Redux DevTools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) – Redux developer tools integrated into Chrome.
 
+## Releases
 
+The front-end is bundled at the same time as the Wagtail project. This package also aims to be available separately on npm as [`wagtail`](https://www.npmjs.com/package/wagtail).

+ 11 - 8
client/package.json

@@ -1,18 +1,21 @@
 {
-  "name": "wagtail",
+  "name": "wagtail-client",
+  "version": "0.1.0",
+  "repository": "https://github.com/wagtail/wagtail",
+  "description": "Wagtail's client side code",
   "license": "BSD-3-Clause",
   "author": "Wagtail",
-  "version": "0.0.2",
+  "main": "src/index.js",
   "bin": {
     "wagtail": "./src/cli/index.js"
   },
-  "scripts": {
-    "test": "npm test"
-  },
-  "main": "src/index.js",
-  "description": "Wagtail's client side code",
+  "files": [
+    "src/index.js"
+  ],
+  "devDependencies": {},
   "dependencies": {
     "mustache": "^2.2.1",
     "yargs": "^4.2.0"
-  }
+  },
+  "scripts": {}
 }

+ 0 - 2
client/scss/_components.scss

@@ -1,3 +1 @@
 @import '../src/components/explorer/style';
-@import '../src/components/loading-indicator/style';
-@import '../src/components/state-indicator/style';

+ 23 - 0
client/src/api/admin.js

@@ -0,0 +1,23 @@
+import { get } from '../api/client';
+
+import { ADMIN_API } from '../config/wagtail';
+
+export const getChildPages = (id, options = {}) => {
+  let url = `${ADMIN_API.PAGES}?child_of=${id}`;
+
+  if (options.fields) {
+    url += `&fields=${global.encodeURIComponent(options.fields.join(','))}`;
+  }
+
+  if (options.filter) {
+    url += `&${options.filter}`;
+  }
+
+  return get(url).then(res => res.body);
+};
+
+export const getPage = (id) => {
+  const url = `${ADMIN_API.PAGES}${id}/`;
+
+  return get(url).then(res => res.body);
+};

+ 38 - 0
client/src/api/client.js

@@ -0,0 +1,38 @@
+import _ from 'lodash';
+
+const fetch = global.fetch;
+
+// fetch wrapper for JSON APIs.
+export const get = (url) => {
+  const headers = new Headers({
+    'Accept': 'application/json',
+    'Content-Type': 'application/json',
+  });
+
+  const options = {
+    credentials: 'same-origin',
+    headers: headers,
+    method: 'GET'
+  };
+
+  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;
+    });
+};

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

@@ -0,0 +1,23 @@
+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;

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

@@ -0,0 +1,18 @@
+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();
+  });
+});

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

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

+ 88 - 0
client/src/components/Button/Button.js

@@ -0,0 +1,88 @@
+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,
+    };
+  },
+
+  handleClick(e) {
+    const { href, onClick, preventDefault } = this.props;
+
+    if (preventDefault && href === '#') {
+      e.preventDefault();
+      e.stopPropagation();
+    }
+
+    if (onClick) {
+      onClick(e);
+    }
+  },
+
+  render() {
+    const {
+      className,
+      icon,
+      children,
+      accessibleLabel,
+      isLoading,
+      target,
+    } = this.props;
+
+    const props = _.omit(this.props, [
+      'className',
+      'icon',
+      'iconClassName',
+      'children',
+      'accessibleLabel',
+      'isLoading',
+      'onClick',
+      'preventDefault',
+    ]);
+
+    const hasIcon = icon !== '';
+    const hasText = children !== null;
+    const iconName = isLoading ? 'spinner' : icon;
+    const accessibleElt = accessibleLabel ? (
+      <span className="visuallyhidden">
+        {accessibleLabel}
+      </span>
+    ) : null;
+
+    return (
+      <a
+        className={`${className} ${hasIcon ? 'icon icon-' : ''}${iconName}`}
+        onClick={this.handleClick}
+        rel={target === '_blank' ? 'noopener' : null}
+        {...props}
+      >
+        {hasText ? children : accessibleElt}
+      </a>
+    );
+  },
+});

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

@@ -0,0 +1,39 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import Button from './Button';
+
+describe('Button', () => {
+  it('exists', () => {
+    expect(Button).toBeDefined();
+  });
+
+  it('basic', () => {
+    expect(shallow(<Button />)).toMatchSnapshot();
+  });
+
+  it('#children', () => {
+    expect(shallow(<Button>To infinity and beyond!</Button>)).toMatchSnapshot();
+  });
+
+  it('#accessibleLabel', () => {
+    expect(shallow(<Button accessibleLabel="I am here in the shadows" />)).toMatchSnapshot();
+  });
+
+  it('#icon', () => {
+    expect(shallow(<Button icon="test-icon" />)).toMatchSnapshot();
+  });
+
+  it('#icon changes with #isLoading', () => {
+    expect(shallow(<Button icon="test-icon" isLoading={true} />)).toMatchSnapshot();
+  });
+
+  it('is clickable', () => {
+    const onClick = jest.fn();
+    shallow(<Button onClick={onClick} />).simulate('click', {
+      preventDefault() {},
+      stopPropagation() {},
+    });
+    expect(onClick).toHaveBeenCalledTimes(1);
+  });
+});

+ 51 - 0
client/src/components/Button/__snapshots__/Button.test.js.snap

@@ -0,0 +1,51 @@
+exports[`Button #accessibleLabel 1`] = `
+<a
+  className=" "
+  href="#"
+  onClick={[Function]}
+  rel={null}
+  target={null}>
+  <span
+    className="visuallyhidden">
+    I am here in the shadows
+  </span>
+</a>
+`;
+
+exports[`Button #children 1`] = `
+<a
+  className=" "
+  href="#"
+  onClick={[Function]}
+  rel={null}
+  target={null}>
+  To infinity and beyond!
+</a>
+`;
+
+exports[`Button #icon 1`] = `
+<a
+  className=" icon icon-test-icon"
+  href="#"
+  onClick={[Function]}
+  rel={null}
+  target={null} />
+`;
+
+exports[`Button #icon changes with #isLoading 1`] = `
+<a
+  className=" icon icon-spinner"
+  href="#"
+  onClick={[Function]}
+  rel={null}
+  target={null} />
+`;
+
+exports[`Button basic 1`] = `
+<a
+  className=" "
+  href="#"
+  onClick={[Function]}
+  rel={null}
+  target={null} />
+`;

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

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

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

@@ -0,0 +1,32 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import ExplorerItem from './ExplorerItem';
+
+const mockProps = {
+  data: {
+    meta: {
+      children: {
+        count: 0,
+      }
+    }
+  },
+};
+
+describe('ExplorerItem', () => {
+  it('exists', () => {
+    expect(ExplorerItem).toBeDefined();
+  });
+
+  it('basic', () => {
+    expect(shallow(<ExplorerItem />)).toMatchSnapshot();
+  });
+
+  it('#data', () => {
+    expect(shallow(<ExplorerItem {...mockProps} />)).toMatchSnapshot();
+  });
+
+  it('#typeName', () => {
+    expect(shallow(<ExplorerItem {...mockProps} typeName="Foo" />)).toMatchSnapshot();
+  });
+});

+ 37 - 0
client/src/components/Explorer/ExplorerToggle.test.js

@@ -0,0 +1,37 @@
+import React from 'react';
+import { createStore } from 'redux';
+import { shallow } from 'enzyme';
+
+import ExplorerToggle from './ExplorerToggle';
+import rootReducer from './reducers';
+
+const store = createStore(rootReducer);
+
+describe('ExplorerToggle', () => {
+  it('exists', () => {
+    expect(ExplorerToggle).toBeDefined();
+  });
+
+  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>
+          To infinity and beyond!
+        </span>
+      </ExplorerToggle>
+    ))).toMatchSnapshot();
+  });
+});

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

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

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

@@ -0,0 +1,77 @@
+exports[`ExplorerItem #data 1`] = `
+<Button
+  accessibleLabel={null}
+  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>
+`;
+
+exports[`ExplorerItem #typeName 1`] = `
+<Button
+  accessibleLabel={null}
+  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">
+      Foo
+    </span>
+     |
+    <AbsoluteDate
+      time="" />
+     |
+    <PublicationStatus />
+  </p>
+</Button>
+`;
+
+exports[`ExplorerItem basic 1`] = `
+<Button
+  accessibleLabel={null}
+  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={null} />
+     |
+    <PublicationStatus
+      status={null} />
+  </p>
+</Button>
+`;

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

@@ -0,0 +1,48 @@
+exports[`ExplorerToggle #children 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],
+    }
+  } />
+`;

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

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

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

@@ -0,0 +1,57 @@
+import * as actions from '../actions';
+import rootReducer from './index';
+import explorer from './explorer';
+import nodes from './nodes';
+import transport from './transport';
+
+describe('explorer reducers', () => {
+  describe('root', () => {
+    it('exists', () => {
+      expect(rootReducer).toBeDefined();
+    });
+  });
+
+  describe('explorer', () => {
+    it('exists', () => {
+      expect(explorer).toBeDefined();
+    });
+  });
+
+  describe('nodes', () => {
+    it('exists', () => {
+      expect(nodes).toBeDefined();
+    });
+  });
+
+  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);
+    });
+  });
+});

+ 21 - 0
client/src/components/Icon/Icon.test.js

@@ -0,0 +1,21 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import Icon from './Icon';
+
+describe('Icon', () => {
+  it('exists', () => {
+    expect(Icon).toBeDefined();
+  });
+
+  it('#name', () => {
+    expect(shallow(<Icon name="test" />)).toMatchSnapshot();
+  });
+
+  it('#className', () => {
+    expect(shallow(<Icon name="test" className="u-test" />)).toMatchSnapshot();
+  });
+
+  it('#title', () => {
+    expect(shallow(<Icon name="test" title="Test title" />)).toMatchSnapshot();
+  });
+});

+ 22 - 0
client/src/components/Icon/__snapshots__/Icon.test.js.snap

@@ -0,0 +1,22 @@
+exports[`Icon #className 1`] = `
+<span
+  aria-hidden={true}
+  className="icon icon-test u-test" />
+`;
+
+exports[`Icon #name 1`] = `
+<span
+  aria-hidden={true}
+  className="icon icon-test " />
+`;
+
+exports[`Icon #title 1`] = `
+<span
+  aria-hidden={false}
+  className="icon icon-test ">
+  <span
+    className="visuallyhidden">
+    Test title
+  </span>
+</span>
+`;

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

@@ -0,0 +1,10 @@
+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;

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

@@ -0,0 +1,14 @@
+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();
+  });
+});

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

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

+ 13 - 0
client/src/components/PublicationStatus/PublicationStatus.js

@@ -0,0 +1,13 @@
+import React from 'react';
+
+const PublicationStatus = ({ status }) => (status ? (
+  <span className={`o-pill c-status${status.live ? ' c-status--live' : ''}`}>
+    {status.status}
+  </span>
+) : null);
+
+PublicationStatus.propTypes = {
+  status: React.PropTypes.object,
+};
+
+export default PublicationStatus;

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

@@ -0,0 +1,39 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import PublicationStatus from './PublicationStatus';
+
+describe('PublicationStatus', () => {
+  it('exists', () => {
+    expect(PublicationStatus).toBeDefined();
+  });
+
+  // TODO Skipped because causing a test error. Apparently this is fixed when using React 15.
+  it.skip('basic', () => {
+    expect(shallow(<PublicationStatus />)).toMatchSnapshot();
+  });
+
+  it('#status live', () => {
+    expect(shallow((
+      <PublicationStatus
+        status={{
+          status: 'live + draft',
+          live: true,
+          has_unpublished_changes: true,
+        }}
+      />
+    ))).toMatchSnapshot();
+  });
+
+  it('#status not live', () => {
+    expect(shallow((
+      <PublicationStatus
+        status={{
+          status: 'live + draft',
+          live: false,
+          has_unpublished_changes: true,
+        }}
+      />
+    ))).toMatchSnapshot();
+  });
+});

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

@@ -0,0 +1,19 @@
+# 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.

+ 13 - 0
client/src/components/PublicationStatus/__snapshots__/PublicationStatus.test.js.snap

@@ -0,0 +1,13 @@
+exports[`PublicationStatus #status live 1`] = `
+<span
+  className="o-pill c-status c-status--live">
+  live + draft
+</span>
+`;
+
+exports[`PublicationStatus #status not live 1`] = `
+<span
+  className="o-pill c-status">
+  live + draft
+</span>
+`;

+ 48 - 51
client/src/components/explorer/Explorer.js

@@ -1,16 +1,16 @@
-import React, { Component, PropTypes } from 'react';
+import React from 'react';
 import CSSTransitionGroup from 'react-addons-css-transition-group';
-import { connect } from 'react-redux'
+import { connect } from 'react-redux';
 
 import * as actions from './actions';
-import { EXPLORER_ANIM_DURATION } from 'config';
+import { EXPLORER_ANIM_DURATION } from '../../config/config';
 import ExplorerPanel from './ExplorerPanel';
 
-
-class Explorer extends Component {
+// TODO To refactor.
+class Explorer extends React.Component {
   constructor(props) {
     super(props);
-    this._init = this._init.bind(this);
+    this.init = this.init.bind(this);
   }
 
   componentDidMount() {
@@ -19,7 +19,7 @@ class Explorer extends Component {
     }
   }
 
-  _init(id) {
+  init() {
     if (this.props.page && this.props.page.isLoaded) {
       return;
     }
@@ -27,15 +27,15 @@ class Explorer extends Component {
     this.props.onShow(this.props.page ? this.props.page : this.props.defaultPage);
   }
 
-  _getPage() {
-    let { nodes, depth, path } = this.props;
-    let id = path[path.length - 1];
+  getPage() {
+    const { nodes, path } = this.props;
+    const id = path[path.length - 1];
     return nodes[id];
   }
 
   render() {
-    let { visible, depth, nodes, path, pageTypes, items, type, filter, fetching, resolved } = this.props;
-    let page = this._getPage();
+    const { isVisible, nodes, path, pageTypes, type, filter, fetching, resolved } = this.props;
+    const page = this.getPage();
 
     const explorerProps = {
       path,
@@ -47,54 +47,56 @@ class Explorer extends Component {
       nodes,
       resolved,
       ref: 'explorer',
-      left: this.props.left,
-      top: this.props.top,
       onPop: this.props.onPop,
-      onItemClick: this.props.onItemClick,
       onClose: this.props.onClose,
       transport: this.props.transport,
       onFilter: this.props.onFilter,
       getChildren: this.props.getChildren,
       loadItemWithChildren: this.props.loadItemWithChildren,
       pushPage: this.props.pushPage,
-      init: this._init
-    }
+      init: this.init,
+    };
 
     const transProps = {
       component: 'div',
       transitionEnterTimeout: EXPLORER_ANIM_DURATION,
       transitionLeaveTimeout: EXPLORER_ANIM_DURATION,
       transitionName: 'explorer-toggle'
-    }
+    };
 
     return (
       <CSSTransitionGroup {...transProps}>
-        { visible ? <ExplorerPanel {...explorerProps} /> : null }
+        {isVisible ? <ExplorerPanel {...explorerProps} /> : null}
       </CSSTransitionGroup>
     );
   }
 }
 
 Explorer.propTypes = {
-  onPageSelect: PropTypes.func,
-  initialPath: PropTypes.string,
-  apiPath: PropTypes.string,
-  size: PropTypes.number,
-  position: PropTypes.object,
-  page: PropTypes.number,
-  defaultPage: PropTypes.number,
+  isVisible: React.PropTypes.bool.isRequired,
+  fetching: React.PropTypes.bool.isRequired,
+  resolved: React.PropTypes.bool.isRequired,
+  path: React.PropTypes.array,
+  type: React.PropTypes.string.isRequired,
+  filter: 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,
+  onFilter: React.PropTypes.func.isRequired,
+  getChildren: React.PropTypes.func.isRequired,
+  loadItemWithChildren: React.PropTypes.func.isRequired,
+  pushPage: React.PropTypes.func.isRequired,
+  pageTypes: React.PropTypes.object.isRequired,
 };
 
-
-// =============================================================================
-// Connector
-// =============================================================================
-
-const mapStateToProps = (state, ownProps) => ({
-  visible: state.explorer.isVisible,
+const mapStateToProps = (state) => ({
+  isVisible: state.explorer.isVisible,
   page: state.explorer.currentPage,
-  depth: state.explorer.depth,
-  loading: state.explorer.isLoading,
   fetching: state.explorer.isFetching,
   resolved: state.explorer.isResolved,
   path: state.explorer.path,
@@ -107,20 +109,15 @@ const mapStateToProps = (state, ownProps) => ({
   transport: state.transport
 });
 
-const mapDispatchToProps = (dispatch) => {
-  return {
-    setDefaultPage: (id) => { dispatch(actions.setDefaultPage(id)) },
-    getChildren: (id) => { dispatch(actions.fetchChildren(id)) },
-    onShow: (id) => { dispatch(actions.fetchRoot()) },
-    onFilter: (filter) => { dispatch(actions.setFilter(filter)) },
-    loadItemWithChildren: (id) => { dispatch(actions.fetchPage(id)) },
-    pushPage: (id) => { dispatch(actions.pushPage(id)) },
-    onPop: () => { dispatch(actions.popPage()) },
-    onClose: () => { dispatch(actions.toggleExplorer()) }
-  }
-}
+const mapDispatchToProps = (dispatch) => ({
+  setDefaultPage: (id) => dispatch(actions.setDefaultPage(id)),
+  getChildren: (id) => dispatch(actions.fetchChildren(id)),
+  onShow: () => dispatch(actions.fetchRoot()),
+  onFilter: (filter) => dispatch(actions.setFilter(filter)),
+  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);
+export default connect(mapStateToProps, mapDispatchToProps)(Explorer);

+ 0 - 8
client/src/components/explorer/ExplorerEmpty.js

@@ -1,8 +0,0 @@
-import React from 'react';
-import { STRINGS } from 'config';
-
-const ExplorerEmpty = () => (
-  <div className="c-explorer__placeholder">{STRINGS['NO_RESULTS']}</div>
-);
-
-export default ExplorerEmpty;

+ 53 - 72
client/src/components/explorer/ExplorerHeader.js

@@ -1,79 +1,60 @@
-import React, { Component } from 'react';
+import React from 'react';
 import CSSTransitionGroup from 'react-addons-css-transition-group';
-import { EXPLORER_ANIM_DURATION, EXPLORER_FILTERS, STRINGS } from 'config';
-
-import Icon from 'components/icon/Icon';
-import Filter from './Filter';
-
-class ExplorerHeader extends Component {
-
-  constructor(p) {
-    super(p)
-    this.onFilter = this.onFilter.bind(this);
-  }
-
-  _getBackBtn() {
-    return (
-      <span className='c-explorer__back'>
-        <Icon name="arrow-left" />
-      </span>
-    );
-  }
-
-  onFilter(e) {
-    this.props.onFilter(e.target.value);
-  }
-
-  _getClass() {
-    let cls = ['c-explorer__trigger'];
-
-    if (this.props.depth > 1) {
-      cls.push('c-explorer__trigger--enabled');
-    }
-    return cls.join(' ');
-  }
-
-  _getTitle() {
-    let { page, depth } = this.props;
-
-    if (depth < 2 || !page) {
-      return STRINGS['EXPLORER'];
-    }
-
-    return page.title;
-  }
-
-  render() {
-    let { page, depth, filter, onPop, onFilter, transName } = this.props;
-
-    const transitionProps = {
-      component: 'span',
-      transitionEnterTimeout: EXPLORER_ANIM_DURATION,
-      transitionLeaveTimeout: EXPLORER_ANIM_DURATION,
-      transitionName: `explorer-${transName}`,
-      className: 'c-explorer__rel',
-    }
-
-    return (
-      <div className="c-explorer__header">
-        <span className={this._getClass()} onClick={onPop}>
-          <span className='u-overflow c-explorer__overflow'>
+import { EXPLORER_ANIM_DURATION, EXPLORER_FILTERS } from '../../config/config';
+import { STRINGS } from '../../config/wagtail';
+
+import Icon from '../../components/Icon/Icon';
+import Filter from '../../components/Explorer/Filter';
+
+const ExplorerHeader = ({ page, depth, filter, onPop, onFilter, transName }) => {
+  const title = depth < 2 || !page ? STRINGS.EXPLORER : page.title;
+
+  const transitionProps = {
+    component: 'span',
+    transitionEnterTimeout: EXPLORER_ANIM_DURATION,
+    transitionLeaveTimeout: EXPLORER_ANIM_DURATION,
+    transitionName: `explorer-${transName}`,
+    className: 'c-explorer__rel',
+  };
+
+  // TODO Do not use a span for a clickable element.
+  return (
+    <div className="c-explorer__header">
+      <span className={`c-explorer__trigger${depth > 1 ? ' c-explorer__trigger--enabled' : ''}`} onClick={onPop}>
+        <span className="u-overflow c-explorer__overflow">
           <CSSTransitionGroup {...transitionProps}>
-            <span className='c-explorer__parent-name' key={depth}>
-               { depth > 1 ? this._getBackBtn() : null }
-              {this._getTitle()}
+            <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>
-        </span>
-        <span className="c-explorer__filter">
-          {EXPLORER_FILTERS.map(props => {
-            return <Filter key={props.id} {...props} activeFilter={filter} onFilter={onFilter} />
-          })}
         </span>
-      </div>
-    );
-  }
-}
+      </span>
+      <span className="c-explorer__filter">
+        {EXPLORER_FILTERS.map((item) => (
+          <Filter
+            key={item.id}
+            {...item}
+            activeFilter={filter}
+            onFilter={onFilter}
+          />
+        ))}
+      </span>
+    </div>
+  );
+};
+
+ExplorerHeader.propTypes = {
+  page: React.PropTypes.object,
+  depth: React.PropTypes.number,
+  filter: React.PropTypes.string,
+  onPop: React.PropTypes.func,
+  onFilter: React.PropTypes.func,
+  transName: React.PropTypes.string,
+};
 
 export default ExplorerHeader;

+ 54 - 59
client/src/components/explorer/ExplorerItem.js

@@ -1,63 +1,58 @@
-import React, { Component, PropTypes } from 'react';
-
-import { ADMIN_PAGES, STRINGS } from 'config';
-import Icon from 'components/icon/Icon';
-import PublishStatus from 'components/publish-status/PublishStatus';
-import PublishedTime from 'components/published-time/PublishedTime';
-import StateIndicator from 'components/state-indicator/StateIndicator';
-
-export default class ExplorerItem extends Component {
-
-  constructor(props) {
-    super(props);
-    this._loadChildren = this._loadChildren.bind(this);
-  }
-
-  _onNavigate(id) {
-    window.location.href = `${ADMIN_PAGES}${id}`;
-  }
-
-  _loadChildren(e) {
-    e.stopPropagation();
-    let { onItemClick, data } = this.props;
-    onItemClick(data.id, data.title);
+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, filter, onItemClick }) => {
+  const { id, meta } = data;
+  const status = meta ? meta.status : null;
+  const time = meta ? meta.latest_revision_created_at : null;
+
+  // If we only want pages with children, get this info by
+  // looking at the descendants count vs children count.
+  // // TODO refactor.
+  let count = 0;
+  if (meta) {
+    count = filter.match(/has_children/) ? meta.descendants.count - meta.children.count : meta.children.count;
   }
-
-  render() {
-    const { title, typeName, data, index } = this.props;
-    const { meta } = data;
-
-    let count = meta.children.count;
-
-    // TODO refactor.
-    // If we only want pages with children, get this info by
-    // looking at the descendants count vs children count.
-    if (this.props.filter && this.props.filter.match(/has_children/)) {
-      count = meta.descendants.count - meta.children.count;
-    }
-
-    return (
-      <div onClick={this._onNavigate.bind(this, data.id)} className="c-explorer__item">
-        {count > 0 ?
-        <span className="c-explorer__children" onClick={this._loadChildren}>
-          <Icon name="folder-inverse" />
-          <span aria-role='presentation'>
-            {STRINGS['SEE_CHILDREN']}
-          </span>
-        </span> : null }
-        <h3 className="c-explorer__title">
-          <StateIndicator state={data.state} />
-          {title}
-        </h3>
-        <p className='c-explorer__meta'>
-          <span className="c-explorer__meta__type">{typeName}</span> | <PublishedTime publishedAt={meta.latest_revision_created_at} /> | <PublishStatus status={meta.status} />
-        </p>
-      </div>
-    );
-  }
-}
+  const hasChildren = count > 0;
+
+  return (
+    <Button href={`${ADMIN_URLS.PAGES}${id}`} className="c-explorer__item">
+      {hasChildren ? (
+        <span
+          role="button"
+          className="c-explorer__children"
+          onClick={onItemClick.bind(null, id)}
+        >
+          <Icon name="folder-inverse" title={STRINGS.SEE_CHILDREN} />
+        </span>
+      ) : null}
+
+      <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>
+  );
+};
 
 ExplorerItem.propTypes = {
-  title: PropTypes.string,
-  data: PropTypes.object
+  title: React.PropTypes.string,
+  data: React.PropTypes.object,
+  filter: React.PropTypes.string,
+  typeName: React.PropTypes.string,
+  onItemClick: React.PropTypes.func,
 };
+
+ExplorerItem.defaultProps = {
+  filter: '',
+  data: {},
+  onItemClick: () => {},
+};
+
+export default ExplorerItem;

+ 92 - 94
client/src/components/explorer/ExplorerPanel.js

@@ -1,71 +1,67 @@
-import React, { Component, PropTypes } from 'react';
+import React from 'react';
 import CSSTransitionGroup from 'react-addons-css-transition-group';
-import { EXPLORER_ANIM_DURATION, STRINGS } from 'config';
 
-import ExplorerEmpty from './ExplorerEmpty';
+import { EXPLORER_ANIM_DURATION } from '../../config/config';
+import { STRINGS } from '../../config/wagtail';
+
+
 import ExplorerHeader from './ExplorerHeader';
 import ExplorerItem from './ExplorerItem';
 import LoadingSpinner from './LoadingSpinner';
 
-export default class ExplorerPanel extends Component {
+export default class ExplorerPanel extends React.Component {
   constructor(props) {
     super(props);
-    this._clickOutside = this._clickOutside.bind(this);
-    this._onItemClick = this._onItemClick.bind(this);
-    this.closeModal = this.closeModal.bind(this);
+    this.clickOutside = this.clickOutside.bind(this);
+    this.onItemClick = this.onItemClick.bind(this);
 
     this.state = {
-      modalIsOpen: false,
+      // TODO Refactor value to constant.
       animation: 'push',
-    }
+    };
   }
 
   componentWillReceiveProps(newProps) {
-    let oldProps = this.props;
+    const { path } = this.props;
 
-    if (!oldProps.path) {
-      return;
-    }
+    if (path) {
+      const isPush = newProps.path.length > path.length;
+      const animation = isPush ? 'push' : 'pop';
 
-    if (newProps.path.length > oldProps.path.length) {
-      return this.setState({ animation: 'push' });
-    } else {
-      return this.setState({ animation: 'pop' });
+      this.setState({
+        animation: animation,
+      });
     }
   }
 
-  _loadChildren() {
-    let { page } = this.props;
+  loadChildren() {
+    const { page, getChildren } = this.props;
 
-    if (!page || page.children.isFetching) {
-      return false;
-    }
-
-    if (page.meta.children.count && !page.children.length && !page.children.isFetching && !page.children.isLoaded) {
-      this.props.getChildren(page.id);
+    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();
+    this.loadChildren();
   }
 
   componentDidMount() {
     this.props.init();
 
-    document.body.style.overflow = 'hidden';
-    document.body.classList.add('u-explorer-open');
-    document.addEventListener('click', this._clickOutside);
+    document.body.classList.add('explorer-open');
+    document.addEventListener('click', this.clickOutside);
   }
 
   componentWillUnmount() {
-    document.body.style.overflow = '';
-    document.body.classList.remove('u-explorer-open');
-    document.removeEventListener('click', this._clickOutside);
+    document.body.classList.remove('explorer-open');
+    document.removeEventListener('click', this.clickOutside);
   }
 
-  _clickOutside(e) {
-    let { explorer } = this.refs;
+  clickOutside(e) {
+    const { explorer } = this.refs;
 
     if (!explorer) {
       return;
@@ -76,17 +72,9 @@ export default class ExplorerPanel extends Component {
     }
   }
 
-  _getStyle() {
-    const { top, left } = this.props;
-    return {
-      left: left + 'px',
-      top: top + 'px'
-    };
-  }
-
-  _getClass() {
-    let { type } = this.props;
-    let cls = ['c-explorer'];
+  getClass() {
+    const { type } = this.props;
+    const cls = ['c-explorer'];
 
     if (type) {
       cls.push(`c-explorer--${type}`);
@@ -95,16 +83,11 @@ export default class ExplorerPanel extends Component {
     return cls.join(' ');
   }
 
-  closeModal() {
-    const { dispatch } = this.props;
-    dispatch(clearError());
-    this.setState({
-      modalIsOpen: false
-    });
-  }
+  onItemClick(id, e) {
+    const node = this.props.nodes[id];
 
-  _onItemClick(id) {
-    let node = this.props.nodes[id];
+    e.preventDefault();
+    e.stopPropagation();
 
     if (node.isLoaded) {
       this.props.pushPage(id);
@@ -114,52 +97,54 @@ export default class ExplorerPanel extends Component {
   }
 
   renderChildren(page) {
-    let { nodes, pageTypes, filter } = this.props;
+    const { nodes, pageTypes, filter } = this.props;
 
     if (!page || !page.children.items) {
       return [];
     }
 
-    return page.children.items.map(index => {
-      return nodes[index];
-    }).map(item => {
-      const typeName = pageTypes[item.meta.type] ? pageTypes[item.meta.type].verbose_name : item.meta.type;
-      const props = {
-        onItemClick: this._onItemClick,
-        parent: page,
-        key: item.id,
-        title: item.title,
-        typeName,
-        data: item,
-        filter,
-      };
-
-      return <ExplorerItem {...props} />
-    });
+    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;
+        const props = {
+          onItemClick: this.onItemClick,
+          parent: page,
+          key: item.id,
+          title: item.title,
+          typeName,
+          data: item,
+          filter,
+        };
+
+        return (
+          <ExplorerItem {...props} />
+        );
+      });
   }
 
-  _getContents() {
-    let { page } = this.props;
-    let contents = null;
+  getContents() {
+    const { page } = this.props;
+    let ret;
 
     if (page) {
       if (page.children.items.length) {
-        return this.renderChildren(page)
+        ret = this.renderChildren(page);
       } else {
-        return <ExplorerEmpty />
+        ret = (
+          <div className="c-explorer__placeholder">{STRINGS.NO_RESULTS}</div>
+        );
       }
     }
+
+    return ret;
   }
 
   render() {
-    let {
+    const {
       page,
       onPop,
       onClose,
-      loading,
-      type,
-      pageData,
-      transport,
       onFilter,
       filter,
       path,
@@ -167,8 +152,8 @@ export default class ExplorerPanel extends Component {
     } = this.props;
 
     // Don't show anything until the tree is resolved.
-    if (!this.props.resolved) {
-      return <div />
+    if (!resolved) {
+      return <div />;
     }
 
     const headerProps = {
@@ -178,37 +163,37 @@ export default class ExplorerPanel extends Component {
       onClose,
       onFilter,
       filter
-    }
+    };
 
     const transitionTargetProps = {
       key: path.length,
       className: 'c-explorer__transition-group'
-    }
+    };
 
     const transitionProps = {
       component: 'div',
       transitionEnterTimeout: EXPLORER_ANIM_DURATION,
       transitionLeaveTimeout: EXPLORER_ANIM_DURATION,
       transitionName: `explorer-${this.state.animation}`
-    }
+    };
 
     const innerTransitionProps = {
       component: 'div',
       transitionEnterTimeout: EXPLORER_ANIM_DURATION,
       transitionLeaveTimeout: EXPLORER_ANIM_DURATION,
-      transitionName: `explorer-fade`
-    }
+      transitionName: 'explorer-fade'
+    };
 
     return (
-      <div style={this._getStyle()} className={this._getClass()} ref='explorer'>
+      <div className={this.getClass()} ref="explorer">
         <ExplorerHeader {...headerProps} transName={this.state.animation} />
-        <div className='c-explorer__drawer'>
+        <div className="c-explorer__drawer">
           <CSSTransitionGroup {...transitionProps}>
             <div {...transitionTargetProps}>
               <CSSTransitionGroup {...innerTransitionProps}>
                 {page.isFetching ? <LoadingSpinner key={1} /> : (
                   <div key={0}>
-                    {this._getContents()}
+                    {this.getContents()}
                   </div>
               )}
               </CSSTransitionGroup>
@@ -217,10 +202,23 @@ export default class ExplorerPanel extends Component {
           </CSSTransitionGroup>
         </div>
       </div>
-    )
+    );
   }
 }
 
 ExplorerPanel.propTypes = {
-
-}
+  page: React.PropTypes.object,
+  onPop: React.PropTypes.func.isRequired,
+  onClose: React.PropTypes.func.isRequired,
+  type: React.PropTypes.string.isRequired,
+  onFilter: React.PropTypes.func.isRequired,
+  filter: 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,
+};

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

@@ -0,0 +1,41 @@
+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);

+ 20 - 0
client/src/components/explorer/Filter.js

@@ -0,0 +1,20 @@
+import React from 'react';
+
+// TODO Do not use a span for a clickable element.
+const Filter = ({ label, filter = null, activeFilter, onFilter }) => (
+  <span
+    className={`c-filter${activeFilter === filter ? ' c-filter--active' : ''}`}
+    onClick={onFilter.bind(this, filter)}
+  >
+    {label}
+  </span>
+);
+
+Filter.propTypes = {
+  label: React.PropTypes.string.isRequired,
+  filter: React.PropTypes.string,
+  activeFilter: React.PropTypes.string,
+  onFilter: React.PropTypes.func.isRequired,
+};
+
+export default Filter;

+ 3 - 2
client/src/components/explorer/LoadingSpinner.js

@@ -1,9 +1,10 @@
 import React from 'react';
-import { STRINGS } from 'config';
+import { STRINGS } from '../../config/wagtail';
+import Icon from '../../components/Icon/Icon';
 
 const LoadingSpinner = () => (
   <div className="c-explorer__loading">
-    <span className="c-explorer__spinner icon icon-spinner" /> {STRINGS['LOADING']}...
+    <Icon name="spinner" className="c-explorer__spinner" /> {STRINGS.LOADING}
   </div>
 );
 

+ 0 - 31
client/src/components/explorer/PageCount.js

@@ -1,31 +0,0 @@
-import React from 'react';
-
-import { ADMIN_PAGES } from 'config';
-
-const PageCount = ({ id, count }) => {
-  let prefix = '';
-  let suffix = 'pages';
-
-  if (count === 0) {
-    return <div />;
-  }
-
-  if (count > 1) {
-    prefix = 'all ';
-  }
-
-  if (count === 1) {
-    suffix = 'page';
-  }
-
-  return (
-    <div onClick={() => {
-        window.location.href = `${ADMIN_PAGES}${id}/`
-      }}
-      className="c-explorer__see-more">
-      See {prefix}{ count } {suffix}
-    </div>
-  );
-}
-
-export default PageCount;

+ 0 - 1
client/src/components/explorer/README.md

@@ -1 +0,0 @@
-# Explorer

+ 49 - 74
client/src/components/explorer/actions/index.js

@@ -1,27 +1,11 @@
 import { createAction } from 'redux-actions';
 
-import { API_PAGES, PAGES_ROOT_ID } from 'config';
-
-function _getHeaders() {
-  const headers = new Headers();
-  headers.append('Content-Type', 'application/json');
-
-  return {
-    credentials: 'same-origin',
-    headers: headers,
-    method: 'GET'
-  };
-}
-
-function _get(url) {
-  return fetch(url, _getHeaders()).then(response => response.json());
-}
+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) => {
-  return { id, body };
-});
+export const fetchSuccess = createAction('FETCH_SUCCESS', (id, body) => ({ id, body }));
 
 export const fetchFailure = createAction('FETCH_FAILURE');
 
@@ -29,9 +13,7 @@ export const pushPage = createAction('PUSH_PAGE');
 
 export const popPage = createAction('POP_PAGE');
 
-export const fetchBranchSuccess = createAction('FETCH_BRANCH_SUCCESS', (id, json) => {
-  return { id, json };
-});
+export const fetchBranchSuccess = createAction('FETCH_BRANCH_SUCCESS', (id, json) => ({ id, json }));
 
 export const fetchBranchStart = createAction('FETCH_BRANCH_START');
 
@@ -41,73 +23,66 @@ 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,
+      filter: explorer.filter,
+    }).then(json => dispatch(fetchChildrenSuccess(id, json)));
+  };
+}
+
 // Make this a bit better... hmm....
 export function fetchTree(id = 1) {
   return (dispatch) => {
     dispatch(fetchBranchStart(id));
 
-    return _get(`${API_PAGES}${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());
-        }
-      });
+    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(1));
+    dispatch(resetTree(PAGES_ROOT_ID));
+    dispatch(fetchBranchStart(PAGES_ROOT_ID));
 
-    return _get(`${API_PAGES}?child_of=${PAGES_ROOT_ID}`)
-      .then(json => {
-        // TODO right now, only works for a single homepage.
-        // TODO What do we do if there is no homepage?
-        const rootId = json.items[0].id;
+    dispatch(fetchBranchSuccess(PAGES_ROOT_ID, {
+      children: {},
+      meta: {
+        children: {},
+      },
+    }));
 
-        dispatch(fetchTree(rootId));
-      });
+    dispatch(fetchChildren(PAGES_ROOT_ID));
+
+    dispatch(treeResolved());
   };
 }
 
 export const toggleExplorer = createAction('TOGGLE_EXPLORER');
 
-export const fetchChildrenSuccess = createAction('FETCH_CHILDREN_SUCCESS', (id, json) => {
-  return { 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();
-
-    let api = `${API_PAGES}?child_of=${id}`;
-
-    if (explorer.fields) {
-      api += `&fields=${explorer.fields.map(global.encodeURIComponent).join(',')}`;
-    }
-
-    if (explorer.filter) {
-      api = `${api}&${explorer.filter}`;
-    }
-
-    dispatch(fetchChildrenStart(id));
-
-    return _get(api)
-      .then(json => dispatch(fetchChildrenSuccess(id, json)));
-  };
-}
 
 export function setFilter(filter) {
   return (dispatch, getState) => {
@@ -117,9 +92,9 @@ export function setFilter(filter) {
     dispatch({
       payload: {
         filter,
-        id
+        id,
       },
-      type: 'SET_FILTER'
+      type: 'SET_FILTER',
     });
 
     dispatch(fetchChildren(id));
@@ -132,7 +107,7 @@ export function setFilter(filter) {
 export function fetchPage(id = 1) {
   return dispatch => {
     dispatch(fetchStart(id));
-    return _get(`${API_PAGES}${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)))));

+ 0 - 18
client/src/components/explorer/filter.js

@@ -1,18 +0,0 @@
-import React, { Component } from 'react';
-
-const Filter = ({label, filter=null, activeFilter, onFilter}) => {
-  let click = onFilter.bind(this, filter);
-  let isActive =  activeFilter === filter;
-  let cls = ['c-filter'];
-
-  if (isActive) {
-    cls.push('c-filter--active');
-  }
-
-  return (
-    <span className={cls.join(' ')} onClick={click}>{label}</span>
-  );
-}
-
-
-export default Filter;

+ 79 - 76
client/src/components/explorer/reducers/explorer.js

@@ -1,95 +1,98 @@
-const stateDefaults = {
+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'],
   filter: 'has_children=1',
   // Coming from the API in order to get translated / pluralised labels.
   pageTypes: {},
-}
-
-export default function explorer(state = stateDefaults, action) {
+};
 
+export default function explorer(state = defaultState, action) {
   let newNodes = state.path;
 
   switch (action.type) {
-    case 'SET_DEFAULT_PAGE':
-      return Object.assign({}, state, {
-        defaultPage: action.payload
-      });
-
-    case 'RESET_TREE':
-      return Object.assign({}, state, {
-        isFetching: true,
-        isResolved: false,
-        currentPage: action.payload,
-        path: [],
-      });
-
-    case 'TREE_RESOLVED':
-      return Object.assign({}, state, {
-        isFetching: false,
-        isResolved: true
-      });
-
-    case 'TOGGLE_EXPLORER':
-      return Object.assign({}, state, {
-        isVisible: !state.isVisible,
-        currentPage: action.payload ? action.payload : state.defaultPage,
-      });
-
-    case 'FETCH_START':
-      return Object.assign({}, state, {
-        isFetching: true
-      });
-
-    case 'FETCH_BRANCH_SUCCESS':
-      if (state.path.indexOf(action.payload.id) < 0) {
-        newNodes = [action.payload.id].concat(state.path);
-      }
-
-      return Object.assign({}, state, {
-        path: newNodes,
-        currentPage: state.currentPage ? state.currentPage : action.payload.id
-      });
+  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 Object.assign({}, state, {
-        isFetching: false,
-        path: newNodes,
-      });
-
-    case 'PUSH_PAGE':
-      return Object.assign({}, state, {
-        path: state.path.concat([action.payload])
-      });
-      return state;
-
-    case 'POP_PAGE':
-      let poppedNodes = state.path.length > 1 ? state.path.slice(0, -1) : state.path;
-      return Object.assign({}, state, {
-        path: poppedNodes,
-      });
-
-    case 'FETCH_CHILDREN_SUCCESS':
-      return Object.assign({}, state, {
-        isFetching: false,
-        pageTypes: action.payload.json.__types,
-      });
-
-    case 'SET_FILTER':
-      return Object.assign({}, state, {
-        filter: action.filter
-      });
+  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),
+    });
+
+  case 'SET_FILTER':
+    return _.assign({}, state, {
+      filter: action.filter
+    });
+
+  default:
+    return state;
   }
-  return state;
 }

+ 94 - 79
client/src/components/explorer/reducers/nodes.js

@@ -1,99 +1,114 @@
-function children(state={
+import _ from 'lodash';
+
+const childrenDefaultState = {
   items: [],
   count: 0,
   isFetching: false
-}, action) {
-
-  switch(action.type) {
-    case 'FETCH_CHILDREN_START':
-      return Object.assign({}, state, {
-        isFetching: true
-      });
-
-    case 'FETCH_CHILDREN_SUCCESS':
-      return Object.assign({}, state, {
-        items: action.payload.json.items.map(item => { return item.id }),
-        count: action.payload.json.meta.total_count,
-        isFetching: false,
-        isLoaded: true
-      });
+};
+
+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;
   }
-  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) {
-  let defaults = {
-    isError: false,
-    isFetching: false,
-    isLoaded: false,
-    children: children(undefined, {})
-  };
-
-  switch(action.type) {
-    case 'FETCH_CHILDREN_START':
-      return Object.assign({}, state, {
-        [action.payload]: Object.assign({}, state[action.payload], {
-          isFetching: true,
-          children: children(state[action.payload] ? state[action.payload].children : undefined, 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
         })
       });
+    });
 
-    case 'FETCH_CHILDREN_SUCCESS':
-      let map = {};
-
-      action.payload.json.items.forEach(item => {
-        map = Object.assign({}, map, {
-          [item.id]: Object.assign({}, defaults, state[item.id], item, {
-            isLoaded: true
-          })
-        });
-      });
-
-      return Object.assign({}, state, map, {
-        [action.payload.id]: Object.assign({}, state[action.payload.id], {
-          isFetching: false,
-          children: children(state[action.payload.id].children, action)
-        })
-      });
+    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 Object.assign({}, {});
+  case 'RESET_TREE':
+    return defaultState;
 
-    case 'SET_FILTER':
+  // eslint-disable-next-line no-case-declarations
+  case 'SET_FILTER':
       // Unset all isLoaded states when the filter changes
-      let updatedState = {};
-
-      for (let _key in state) {
-        if (state.hasOwnProperty( _key )) {
-          let _obj = state[_key];
-          _obj.children.isLoaded = false;
-          updatedState[_obj.id] = Object.assign({}, _obj, { isLoaded: false })
-        }
+    const updatedState = {};
+
+    // TODO Do not use for in.
+    // TODO Very hard to understand this code. To refactor.
+    // eslint-disable-next-line
+    for (let key in state) {
+      if (state.hasOwnProperty(key)) {
+        // eslint-disable-next-line prefer-const
+        let obj = state[key];
+        obj.children.isLoaded = false;
+        updatedState[obj.id] = _.assign({}, obj, {
+          isLoaded: false,
+        });
       }
+    }
 
-      return Object.assign({}, updatedState);
+    return _.assign({}, updatedState);
 
-    case 'FETCH_START':
-      return Object.assign({}, state, {
-        [action.payload]: Object.assign({}, defaults, state[action.payload], {
-          isFetching: true,
-          isError: false,
-        })
-      });
+  case 'FETCH_START':
+    return _.assign({}, state, {
+      [action.payload]: _.assign({}, defaultState, state[action.payload], {
+        isFetching: true,
+        isError: false,
+      })
+    });
 
-    case 'FETCH_BRANCH_SUCCESS':
-      return Object.assign({}, state, {
-        [action.payload.id]: Object.assign({}, defaults, state[action.payload.id], action.payload.json, {
-          isFetching: false,
-          isError: false,
-          isLoaded: true
-        })
-      });
+  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;
-  }
+  case 'FETCH_SUCCESS':
+    return state;
 
-  return state;
+  default:
+    return state;
+  }
 }

+ 21 - 13
client/src/components/explorer/reducers/transport.js

@@ -1,15 +1,23 @@
-export default function transport(state={error: null, showMessage: false}, action) {
-  switch(action.type) {
-    case 'FETCH_FAILURE':
-      return Object.assign({}, state, {
-        error: action.payload.message,
-        showMessage: true
-      });
-    case 'CLEAR_TRANSPORT_ERROR':
-      return Object.assign({}, state, {
-        error: null,
-        showMessage: false
-      });
+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;
   }
-  return state;
 }

+ 27 - 71
client/src/components/explorer/style.scss

@@ -2,12 +2,12 @@ $c-explorer-bg: #4C4E4D;
 $c-explorer-secondary: #aaa;
 $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
 
-.c-explorer * {
+.c-explorer, .c-explorer * {
     box-sizing: border-box;
 }
 
 .c-explorer {
-    width: 320px;
+    width: 100%;
     height: 500px;
     background: $c-explorer-bg;
     position: absolute;
@@ -17,10 +17,7 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
     .c-explorer--sidebar {
         height: 100vh;
         box-shadow: 2px 2px 5px rgba(0,0,0,0.2);
-        left: 180px;
-        top: 0;
         z-index: 150;
-        position: fixed;
     }
 
 .c-explorer__header {
@@ -42,7 +39,7 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
     cursor: pointer;
 
     &:hover {
-        color: #fff;
+        color: $color-white;
         background: rgba(0,0,0,0.2);
     }
 }
@@ -65,12 +62,12 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
     &:hover {
         background: rgba(0,0,0,0.5);
         border-color: rgba(0,0,0,0.5);
-        color: #fff;
+        color: $color-white;
     }
 }
 
 .c-filter--active {
-    color: #fff;
+    color: $color-white;
     border-color: rgba(255, 255, 255, .5);
 }
 
@@ -81,7 +78,7 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
     margin-top: -1px;
 
     &:hover {
-        color: #fff;
+        color: $color-white;
     }
 
     .icon {
@@ -93,11 +90,11 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
 
 .c-explorer__title {
     margin: 0;
-    color: #fff;
+    color: $color-white;
 }
 
 .c-explorer__loading {
-    color: #fff;
+    color: $color-white;
     padding: 1rem;
 }
 
@@ -113,7 +110,7 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
 
 .c-explorer__placeholder {
     padding: 1rem;
-    color: #fff;
+    color: $color-white;
 }
 
 .c-explorer__meta {
@@ -127,28 +124,31 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
         text-transform: capitalize;
     }
 
-.c-explorer__item:hover {
-    background: rgba(0, 0, 0, 0.25);
-    color: #fff;
+.c-explorer__item {
+    display: block;
+
+    &:hover {
+        background: rgba(0, 0, 0, 0.25);
+        color: $color-white;
+    }
 }
 
 .c-explorer__see-more {
     cursor: pointer;
     padding: .5rem 1rem;
     background: rgba(0,0,0,0.2);
-    color: #fff;
+    color: $color-white;
 
     &:hover {
         background: rgba(0,0,0,0.4);
     }
 }
 
-
 .c-explorer__children {
     display: inline-block;
     border-radius: 50rem;
     border: solid 1px #aaa;
-    color: #fff;
+    color: $color-white;
     line-height: 1;
     padding: .5em .3em .5em .5em;
     float: right;
@@ -156,29 +156,18 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
 
     &:hover {
         background: rgba(0,0,0,0.5);
-    }
-
-    > [aria-role='presentation'] {
-        display: none;
+        color: $color-white;
     }
 }
 
-
-
-
 .c-status {
-    background: #333;
+    background: $color-grey-1;
     color: #ddd;
     text-transform: uppercase;
     letter-spacing: .03rem;
     font-size: 10px;
 }
 
-.c-status--live {
-
-}
-
-
 .c-explorer__drawer {
     position: absolute;
     bottom: 0;
@@ -187,16 +176,17 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
     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..
 // =============================================================================
@@ -210,13 +200,6 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
     line-height: 1.5;
 }
 
-.u-overflow {
-    overflow: hidden;
-    white-space: nowrap;
-    text-overflow: ellipsis;
-}
-
-
 .c-explorer__rel {
     position: relative;
     display: block;
@@ -224,7 +207,6 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
     width: 100%;
 }
 
-
 .c-explorer__parent-name {
     position: absolute;
     width: 100%;
@@ -236,18 +218,13 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
 .c-explorer__spinner:after {
     display: inline-block;
     animation: spin 0.5s infinite linear;
-    line-height: 1
+    line-height: 1;
 }
 
-
-
 // =============================================================================
 // Transitions
 // =============================================================================
 
-// $out-circ: cubic-bezier(0.075, 0.820, 0.165, 1.000);
-// $in-circ: cubic-bezier(0.600, 0.040, 0.980, 0.335);
-
 $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;
@@ -306,6 +283,9 @@ $c-explorer-duration: 200ms;
     opacity: 0;
 }
 
+// =============================================================================
+// Toggle transition
+// =============================================================================
 
 .explorer-toggle-enter {
     opacity: 0;
@@ -325,7 +305,6 @@ $c-explorer-duration: 200ms;
     opacity: 0;
 }
 
-
 // =============================================================================
 // Fade transition
 // =============================================================================
@@ -351,26 +330,3 @@ $c-explorer-duration: 200ms;
 .explorer-fade-leave-active {
     opacity: 0;
 }
-
-
-// =============================================================================
-// Header transitions
-// =============================================================================
-
-.header-push-enter {
-    opacity: 0;
-    transition: opacity .1s linear .1s;
-}
-
-.header-push-enter-active {
-    opacity: 1;
-}
-
-.header-push-leave {
-    opacity: 1;
-    transition: opacity .1s;
-}
-
-.header-push-leave-active {
-    opacity: 0;
-}

+ 0 - 60
client/src/components/explorer/toggle.js

@@ -1,60 +0,0 @@
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-
-import * as actions from './actions';
-
-class Toggle extends Component {
-  constructor(props) {
-    super(props)
-    this._sandbox = this._sandbox.bind(this);
-  }
-
-  componentDidUpdate() {
-    if (this.props.visible) {
-      this.refs.btn.addEventListener('click', this._sandbox);
-    } else {
-      this.refs.btn.removeEventListener('click', this._sandbox);
-    }
-  }
-
-  _sandbox(e) {
-    e.stopPropagation();
-    e.preventDefault();
-    this.props.onToggle(this.props.page);
-  }
-
-  render() {
-    const cls = ['icon icon-folder-open-inverse dl-trigger'];
-
-    if (this.props.loading) {
-      cls.push('icon-spinner');
-    }
-
-    return (
-      <a href="#" ref="btn" onClick={this._sandbox} className={cls.join('  ')}>
-        {this.props.label}
-      </a>
-    );
-  }
-}
-
-Toggle.propTypes = {
-
-};
-
-const mapStateToProps = (store) => {
-  return {
-    loading: store.explorer.isFetching,
-    visible: store.explorer.isVisible,
-  }
-}
-
-const mapDispatchToProps = (dispatch) => {
-  return {
-    onToggle: (id) => {
-      dispatch(actions.toggleExplorer());
-    }
-  }
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(Toggle);

+ 13 - 6
client/src/components/icon/Icon.js

@@ -1,17 +1,24 @@
-import React, { PropTypes } from 'react';
+import React from 'react';
 
-// TODO Add support for accessible label.
-const Icon = ({ name, className }) => (
-  <span className={`icon icon-${name} ${className}`} />
+const Icon = ({ name, className, title }) => (
+  <span className={`icon icon-${name} ${className}`} aria-hidden={!title}>
+    {title ? (
+      <span className="visuallyhidden">
+        {title}
+      </span>
+    ) : null}
+  </span>
 );
 
 Icon.propTypes = {
-  name: PropTypes.string.isRequired,
-  className: PropTypes.string,
+  name: React.PropTypes.string.isRequired,
+  className: React.PropTypes.string,
+  title: React.PropTypes.string,
 };
 
 Icon.defaultProps = {
   className: '',
+  title: null,
 };
 
 export default Icon;

+ 11 - 1
client/src/components/icon/README.md

@@ -8,6 +8,16 @@ A simple component to render an icon. Abstracts away the actual icon implementat
 import { Icon } from 'wagtail';
 
 render(
-    <Icon name="arrow-left" className="icon--active icon--warning" />
+    <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 - 5
client/src/components/icon/style.scss

@@ -1,5 +0,0 @@
-// Icon
-
-.c-icon {
-    display: block;
-}

+ 0 - 7
client/src/components/index.js

@@ -1,7 +0,0 @@
-import Explorer from './explorer';
-import LoadingIndicator from './LoadingIndicator';
-import StateIndicator from './StateIndicator';
-
-export { Explorer };
-export { LoadingIndicator };
-export { StateIndicator };

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

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

+ 0 - 1
client/src/components/loading-indicator/README.md

@@ -1 +0,0 @@
-# Loading indicator

+ 0 - 3
client/src/components/loading-indicator/style.scss

@@ -1,3 +0,0 @@
-.c-indicator {
-
-}

+ 0 - 17
client/src/components/publish-status/PublishStatus.js

@@ -1,17 +0,0 @@
-import React, { Component, PropTypes } from 'react';
-
-const PublishStatus = ({ status }) => {
-  if (!status) {
-    return null;
-  }
-
-  let classes = ['o-pill', 'c-status', 'c-status--' + status.status];
-
-  return (
-    <span className={classes.join('  ')}>
-      {status.status}
-    </span>
-  );
-}
-
-export default PublishStatus;

+ 0 - 9
client/src/components/publish-status/README.md

@@ -1,9 +0,0 @@
-# PublishStatus
-
-About this component
-
-## Usage
-
-```javascript
-import { PublishStatus } from 'wagtail';
-```

+ 0 - 5
client/src/components/publish-status/style.scss

@@ -1,5 +0,0 @@
-// PublishStatus
-
-.c-publish-status {
-    display: block;
-}

+ 0 - 14
client/src/components/published-time/PublishedTime.js

@@ -1,14 +0,0 @@
-import React, { Component, PropTypes } from 'react';
-import moment from 'moment';
-
-
-const PublishedTime = ({publishedAt}) => {
-  let date = moment(publishedAt);
-  let str = publishedAt ?  date.format('DD.MM.YYYY') : 'No date';
-
-  return (
-    <span>{str}</span>
-  );
-}
-
-export default PublishedTime;

+ 0 - 9
client/src/components/published-time/README.md

@@ -1,9 +0,0 @@
-# PublishedTime
-
-About this component
-
-## Usage
-
-```javascript
-import { PublishedTime } from 'wagtail';
-```

+ 0 - 5
client/src/components/published-time/style.scss

@@ -1,5 +0,0 @@
-// PublishedTime
-
-.c-published-time {
-    display: block;
-}

+ 0 - 9
client/src/components/state-indicator/README.md

@@ -1,9 +0,0 @@
-# StateIndicator
-
-About this component
-
-## Usage
-
-```javascript
-import { StateIndicator } from 'wagtail';
-```

+ 0 - 16
client/src/components/state-indicator/StateIndicator.js

@@ -1,16 +0,0 @@
-import React, { Component, PropTypes } from 'react';
-
-export default class StateIndicator extends Component {
-  constructor(props) {
-    super(props);
-    this.state = {};
-  }
-
-  render() {
-    return (
-      <div className="c-state-indicator">
-
-      </div>
-    );
-  }
-}

+ 0 - 5
client/src/components/state-indicator/style.scss

@@ -1,5 +0,0 @@
-// StateIndicator
-
-.c-state-indicator {
-    display: block;
-}

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

@@ -0,0 +1,10 @@
+export const PAGES_ROOT_ID = 'root';
+
+export const EXPLORER_ANIM_DURATION = 220;
+
+// TODO Add back in when we want to support explorer that displays pages
+// without children (API call without has_children=1).
+export const EXPLORER_FILTERS = [
+  { id: 1, label: 'A', filter: null },
+  { id: 2, label: 'B', filter: 'has_children=1' }
+];

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

@@ -0,0 +1,25 @@
+import {
+  PAGES_ROOT_ID,
+  EXPLORER_ANIM_DURATION,
+  EXPLORER_FILTERS,
+} 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();
+    });
+  });
+
+  describe('EXPLORER_FILTERS', () => {
+    it('exists', () => {
+      expect(EXPLORER_FILTERS).toBeDefined();
+    });
+  });
+});

+ 0 - 16
client/src/config/index.js

@@ -1,16 +0,0 @@
-export const API = global.wagtailConfig.api;
-export const API_PAGES = global.wagtailConfig.api.pages;
-
-export const PAGES_ROOT_ID = 'root';
-
-export const STRINGS = global.wagtailConfig.strings;
-
-export const EXPLORER_ANIM_DURATION = 220;
-
-export const ADMIN_PAGES = global.wagtailConfig.urls.pages;
-
-export const EXPLORER_FILTERS = [
-  // TODO Add back in when we want to support explorer without has_children=1
-  // { id: 1, label: 'A', filter: null },
-  // { id: 2, label: 'B', filter: 'has_children=1' }
-];

+ 5 - 0
client/src/config/wagtail.js

@@ -0,0 +1,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 = 'DD.MM.YYYY';

+ 32 - 0
client/src/config/wagtail.test.js

@@ -0,0 +1,32 @@
+import {
+  ADMIN_API,
+  STRINGS,
+  ADMIN_URLS,
+  DATE_FORMAT,
+} from './wagtail';
+
+describe('config', () => {
+  describe('ADMIN_API', () => {
+    it('exists', () => {
+      expect(ADMIN_API).toBeDefined();
+    });
+  });
+
+  describe('STRINGS', () => {
+    it('exists', () => {
+      expect(STRINGS).toBeDefined();
+    });
+  });
+
+  describe('ADMIN_URLS', () => {
+    it('exists', () => {
+      expect(ADMIN_URLS).toBeDefined();
+    });
+  });
+
+  describe('DATE_FORMAT', () => {
+    it('exists', () => {
+      expect(DATE_FORMAT).toBeDefined();
+    });
+  });
+});

+ 20 - 1
client/src/index.js

@@ -1 +1,20 @@
-export * from './components';
+/**
+ * Entry point for the wagtail package.
+ * Re-exports components and other modules via a cleaner API.
+ */
+
+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';
+
+export {
+  Button,
+  Explorer,
+  Icon,
+  LoadingIndicator,
+  AbsoluteDate,
+  PublicationStatus,
+};

+ 41 - 0
client/src/index.test.js

@@ -0,0 +1,41 @@
+import {
+  Button,
+  Explorer,
+  Icon,
+  LoadingIndicator,
+  AbsoluteDate,
+  PublicationStatus,
+} 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();
+    });
+  });
+});

+ 2 - 2
client/template/component.mst

@@ -1,6 +1,6 @@
-import React, { PropTypes } from 'react';
+import React from 'react';
 
-const {{ name }} = (props) => {
+const {{ name }} = () => {
   return (
     <div className="c-{{ slug }}">
     </div>

+ 5 - 15
client/template/component.test.mst

@@ -1,25 +1,15 @@
-// TODO Move this file to the client/tests/components directory.
 import React from 'react';
 import { expect } from 'chai';
-import { shallow, mount, render } from 'enzyme';
-import '../stubs';
+import { shallow } from 'enzyme';
 
-import {{ name }} from '../../src/components/{{ slug }}/{{ name }}';
+import {{ name }} from '../../src/components/{{ name }}/{{ name }}';
 
 describe('{{ name }}', () => {
   it('exists', () => {
-    expect({{ name }}).to.exist;
+    expect({{ name }}).toBeDefined();
   });
 
-  it('contains spec with an expectation', () => {
-    expect(shallow(<{{ name }} />).contains(<div className="c-{{ slug }}" />)).to.equal(true);
-  });
-
-  it('contains spec with an expectation', () => {
-    expect(shallow(<{{ name }} />).is('.c-{{ slug }}')).to.equal(true);
-  });
-
-  it('contains spec with an expectation', () => {
-    expect(mount(<{{ name }} />).find('.c-{{ slug }}').length).to.equal(1);
+  it('basic', () => {
+    expect(shallow(<{{ name }} />)).toMatchSnapshot();
   });
 });

+ 0 - 21
client/tests/components/Icon.test.js

@@ -1,21 +0,0 @@
-import React from 'react';
-import { expect } from 'chai';
-import { shallow } from 'enzyme';
-import '../stubs';
-
-import Icon from '../../src/components/icon/Icon';
-
-describe('Icon', () => {
-  it('exists', () => {
-    // eslint-disable-next-line no-unused-expressions
-    expect(Icon).to.exist;
-  });
-
-  it('has just icon classes by default', () => {
-    expect(shallow(<Icon name="test" />).is('.icon.icon-test')).to.equal(true);
-  });
-
-  it('has additional classes if specified', () => {
-    expect(shallow(<Icon name="test" className="icon-red icon-big" />).prop('className')).to.contain('icon-red icon-big');
-  });
-});

+ 0 - 39
client/tests/components/explorer.test.js

@@ -1,39 +0,0 @@
-import React from 'react';
-import { expect } from 'chai';
-import { shallow } from 'enzyme';
-
-import '../stubs';
-import Explorer from '../../src/components/explorer/Explorer';
-import ExplorerItem from '../../src/components/explorer/ExplorerItem';
-
-describe('Explorer', () => {
-  it('exists', () => {
-    // eslint-disable-next-line no-unused-expressions
-    expect(Explorer).to.exist;
-  });
-
-  describe('ExplorerItem', () => {
-    const props = {
-      data: {
-        meta: {
-            children: {
-                count: 0,
-            }
-        }
-      },
-    };
-
-    it('exists', () => {
-      // eslint-disable-next-line no-unused-expressions
-      expect(ExplorerItem).to.exist;
-    });
-
-    it('has item metadata', () => {
-        expect(shallow(<ExplorerItem {...props} />).find('.c-explorer__meta')).to.have.lengthOf(1);
-    });
-
-    it('metadata contains item type', () => {
-        expect(shallow(<ExplorerItem {...props} typeName="Foo" />).find('.c-explorer__meta').text()).to.contain('Foo');
-    });
-  });
-});

+ 20 - 7
client/tests/stubs.js

@@ -1,12 +1,25 @@
+/**
+ * Test stubs to mirror available global variables.
+ * Those variables usually come from the back-end via templates.
+ * See /wagtailadmin/templates/wagtailadmin/admin_base.html.
+ */
+
 global.wagtailConfig = {
-  api: {
-    documents: '/admin/api/v1beta/documents/',
-    images: '/admin/api/v1beta/images/',
-    pages: '/admin/api/v1beta/pages/',
+  ADMIN_API: {
+    DOCUMENTS: '/admin/api/v2beta/documents/',
+    IMAGES: '/admin/api/v2beta/images/',
+    PAGES: '/admin/api/v2beta/pages/',
+  },
+  ADMIN_URLS: {
+    PAGES: '/admin/pages/',
+  },
+  STRINGS: {
+    EXPLORER: 'Explorer',
+    LOADING: 'Loading...',
+    NO_RESULTS: 'No results',
+    SEE_CHILDREN: 'See Children',
+    NO_DATE: 'No date',
   },
-  urls: {
-    pages: '/admin/pages/',
-  }
 };
 
 global.wagtailVersion = '1.6a1';

+ 1 - 1
client/webpack/base.config.js

@@ -17,7 +17,7 @@ function entryPoint(filename) {
   var name = appName(filename);
   var entryName = path.basename(filename, '.entry.js');
   var outputPath = path.join('wagtail', name, 'static', name, 'js', entryName);
-  return [outputPath, filename];
+  return [outputPath, ['babel-polyfill', filename]];
 }
 
 

+ 8 - 0
client/webpack/dev.config.js

@@ -5,6 +5,14 @@ var config = base('development');
 
 // development overrides go here
 config.watch = true;
+
+// add poll-options for in vagrant development
+// See http://andrewhfarmer.com/webpack-watch-in-vagrant-docker/
+config.watchOptions = {
+  poll: 1000,
+  aggregateTimeout: 300,
+};
+
 // See http://webpack.github.io/docs/configuration.html#devtool
 config.devtool = 'inline-source-map';
 

File diff suppressed because it is too large
+ 296 - 332
npm-shrinkwrap.json


+ 33 - 28
package.json

@@ -13,19 +13,32 @@
     ]
   },
   "browserify-shim": {},
+  "jest": {
+    "rootDir": "client",
+    "setupFiles": [
+      "./tests/stubs.js"
+    ],
+    "snapshotSerializers": [
+      "enzyme-to-json/serializer"
+    ]
+  },
   "devDependencies": {
     "babel-cli": "^6.5.1",
     "babel-core": "^6.5.2",
+    "babel-jest": "^18.0.0",
     "babel-loader": "^6.2.3",
+    "babel-plugin-lodash": "^3.2.9",
+    "babel-polyfill": "^6.5.0",
     "babel-preset-es2015": "^6.5.0",
     "babel-preset-react": "^6.5.0",
-    "chai": "^3.5.0",
     "enzyme": "^2.3.0",
+    "enzyme-to-json": "^1.4.5",
     "eslint": "^2.9.0",
-    "eslint-config-wagtail": "^0.1.0",
+    "eslint-config-wagtail": "^0.1.1",
     "eslint-plugin-import": "^1.8.1",
     "eslint-plugin-jsx-a11y": "^1.5.3",
-    "eslint-plugin-react": "^4.3.0",
+    "eslint-plugin-react": "^5.2.2",
+    "exports-loader": "^0.6.3",
     "glob": "^7.0.0",
     "gulp": "~3.8.11",
     "gulp-autoprefixer": "~3.0.2",
@@ -33,32 +46,24 @@
     "gulp-sass": "~2.3.1",
     "gulp-sourcemaps": "~1.5.2",
     "gulp-util": "~2.2.14",
-    "isparta": "^4.0.0",
-    "lodash": "^4.5.1",
-    "mocha": "^2.4.5",
+    "imports-loader": "^0.6.5",
+    "jest": "^18.1.0",
     "mustache": "^2.2.1",
-    "react-addons-test-utils": "^0.14.8",
-    "redux-devtools": "^3.1.1",
+    "react-addons-test-utils": "^15.4.2",
     "require-dir": "^0.3.0",
-    "sinon": "^1.17.3"
+    "webpack": "^1.12.14"
   },
   "dependencies": {
-    "babel-polyfill": "^6.5.0",
-    "exports-loader": "^0.6.3",
-    "imports-loader": "^0.6.5",
-    "moment": "^2.11.2",
-    "react": "^0.14.7",
-    "react-accessible-modal": "0.0.5",
-    "react-addons-css-transition-group": "^0.14.7",
-    "react-dom": "^0.14.7",
-    "react-onclickoutside": "^4.5.0",
-    "react-redux": "^4.4.0",
-    "redux": "^3.3.1",
-    "redux-actions": "^0.10.0",
-    "redux-logger": "^2.6.0",
-    "redux-thunk": "^1.0.3",
-    "webpack": "^1.12.14",
-    "whatwg-fetch": "^0.11.0"
+    "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",
+    "react-redux": "^5.0.2",
+    "redux": "^3.6.0",
+    "redux-actions": "^1.2.1",
+    "redux-thunk": "^2.2.0",
+    "whatwg-fetch": "^2.0.2"
   },
   "scripts": {
     "postinstall": "cd ./client; npm install; cd ..",
@@ -68,9 +73,9 @@
     "lint:js": "eslint --max-warnings 16 ./client",
     "lint": "npm run lint:js",
     "test": "npm run test:unit",
-    "test:unit": "env NODE_PATH=$NODE_PATH:$PWD/client/src mocha --compilers js:babel-core/register client/tests/**/*.test.js",
-    "test:unit:watch": "env NODE_PATH=$NODE_PATH:$PWD/client/src mocha --watch --compilers js:babel-core/register client/tests/**/*.test.js",
-    "test:unit:coverage": "env NODE_PATH=$NODE_PATH:$PWD/client/src babel-node $(npm bin)/isparta cover node_modules/mocha/bin/_mocha -- client/tests/**/*.test.js",
+    "test:unit": "jest",
+    "test:unit:watch": "jest --watch",
+    "test:unit:coverage": "jest --coverage",
     "component": "node ./client/src/cli/index.js component --dir ./client/src/components/"
   }
 }

+ 34 - 33
wagtail/wagtailadmin/static_src/wagtailadmin/app/wagtailadmin.entry.js

@@ -1,47 +1,48 @@
-import 'babel-polyfill';
 import React from 'react';
 import ReactDOM from 'react-dom';
 import { Provider } from 'react-redux';
-import { createStore, applyMiddleware } from 'redux';
-import createLogger from 'redux-logger'
-import thunkMiddleware from 'redux-thunk'
+import { createStore, applyMiddleware, compose } from 'redux';
+import thunkMiddleware from 'redux-thunk';
 
 import Explorer from 'components/explorer/Explorer';
-import ExplorerToggle from 'components/explorer/toggle';
+import ExplorerToggle from 'components/explorer/ExplorerToggle';
 import rootReducer from 'components/explorer/reducers';
 
+const initExplorer = () => {
+  const explorerNode = document.querySelector('#explorer');
+  const toggleNode = document.querySelector('[data-explorer-menu-url]');
 
-document.addEventListener('DOMContentLoaded', e => {
-  const top = document.querySelector('.wrapper');
-  const div = document.createElement('div');
-  const trigger = document.querySelector('[data-explorer-menu-url]');
+  if (explorerNode && toggleNode) {
+    const middleware = [
+      thunkMiddleware,
+    ];
 
-  let rect = trigger.getBoundingClientRect();
-  let triggerParent = trigger.parentNode;
-  let label = trigger.innerText;
+    const store = createStore(rootReducer, {}, compose(
+      applyMiddleware(...middleware),
+      // Expose store to Redux DevTools extension.
+      window.devToolsExtension ? window.devToolsExtension() : f => f
+    ));
 
-  top.parentNode.appendChild(div);
-
-  const loggerMiddleware = createLogger();
-
-  const store = createStore(
-    rootReducer,
-    applyMiddleware(loggerMiddleware, thunkMiddleware)
-  );
-
-  ReactDOM.render((
+    const toggle = (
       <Provider store={store}>
-        <ExplorerToggle label={label} />
+        <ExplorerToggle>{toggleNode.innerText}</ExplorerToggle>
       </Provider>
-    ),
-    triggerParent
-  );
-
-  ReactDOM.render(
-    <Provider store={store}>
-      <Explorer type={'sidebar'} top={0} left={rect.right} defaultPage={1} />
-    </Provider>,
-    div
-  );
+    );
 
+    const explorer = (
+      <Provider store={store}>
+        <Explorer type="sidebar" defaultPage={1} />
+      </Provider>
+    );
+
+    ReactDOM.render(toggle, toggleNode.parentNode);
+    ReactDOM.render(explorer, explorerNode);
+  }
+};
+
+/**
+ * Admin JS entry point. Add in here code to run once the page is loaded.
+ */
+document.addEventListener('DOMContentLoaded', () => {
+  initExplorer();
 });

+ 1 - 29
wagtail/wagtailadmin/static_src/wagtailadmin/scss/components/_explorer.scss

@@ -1,15 +1,14 @@
 // min z-index: 500;
 // max z-index: unknown;
 
+// TODO Clean-up unused code in the new version of the explorer
 $explorer-z-index: 500;
 
 .explorer {
-    pointer-events: none;
     width: 100%;
     position: relative;
     top: 0;
     left: 0;
-    display: none;
 
     ul {
         background: $color-grey-1;
@@ -30,33 +29,6 @@ $explorer-z-index: 500;
         }
     }
 
-    a {
-        text-decoration: none;
-        padding: 0.9em;
-        color: $color-white;
-        display: block;
-        position: relative;
-        outline: none;
-        white-space: nowrap;
-        text-overflow: ellipsis;
-        overflow: hidden;
-
-        &:before {
-            opacity: 0.5;
-            margin-right: 0.5em;
-            font-size: 1.5em;
-        }
-
-        &:hover {
-            background: $color-teal-dark;
-            color: $color-white;
-        }
-    }
-
-    .has-children a {
-        padding-right: 5em;
-    }
-
     .children {
         position: absolute;
         z-index: $explorer-z-index + 1;

+ 15 - 1
wagtail/wagtailadmin/static_src/wagtailadmin/scss/components/_main-nav.scss

@@ -244,6 +244,8 @@ body.nav-open {
 
 // Explorer open condition, widens navigation area
 body.explorer-open {
+    overflow: hidden;
+
     .wrapper {
         transform: translate3d($menu-width*2, 0, 0);
         -webkit-transform: translate3d($menu-width*2, 0, 0);
@@ -442,7 +444,6 @@ body.explorer-open {
         position: absolute;
         top: 0;
         left: 99%;
-        margin-top: 175px; // same as .nav-main minus 1 pixel for border
     }
 
     .dl-menu {
@@ -456,6 +457,19 @@ body.explorer-open {
     }
 
     body.explorer-open {
+        // TODO Do we want this layer appearing when the explorer is open?
+        &:after {
+            content: '';
+            position: fixed;
+            background: rgba(255, 255, 255, 0.5);
+            width: 100%;
+            height: 100%;
+            top: 0;
+            left: 0;
+            opacity: 1;
+            animation: opacity .2s ease-out;
+        }
+
         .wrapper {
             -webkit-transform: none;
             transform: none;

+ 1 - 230
wagtail/wagtailadmin/static_src/wagtailadmin/scss/core.scss

@@ -21,38 +21,7 @@
 
 @import 'wagtailadmin/scss/fonts';
 
-// scss-lint:disable all
-#wagtail {
-    @import '../../../../../client/scss/style';
-}
-// scss-lint:enable all
-
-@keyframes matteIn {
-    0% {
-        opacity: 0;
-    }
-    100% {
-        opacity: 1;
-    }
-}
-
-.u-explorer-open {
-    overflow: hidden;
-
-    &:after {
-        // content: '';
-        // position: fixed;
-        // background: rgba(255, 255, 255, 0.5);
-        // width: 100%;
-        // height: 100%;
-        // top: 0;
-        // left: 0;
-        // opacity: 1;
-        // animation: matteIn .2s ease-out;
-    }
-}
-
-
+@import '../../../../../client/scss/style';
 
 html {
     background: $color-grey-4;
@@ -555,201 +524,3 @@ footer,
 // a {
     // @include transition(color 0.2s ease, background-color 0.2s ease);
 // }
-
-
-/**
-// -----------------------------------------------------------------------------
-// Modal lightboxes
-// -----------------------------------------------------------------------------
-//
-// As of 2015, the vertical-align: middle table is still the best cross-browser
-// way to vertically centre stuff. This modal component uses this pattern with
-// the following structure:
-//
-// <div class="modal modal--active">
-//     <div class="modal__table">
-//         <div class="modal__center">
-//             <div class="modal__content">
-//                 Hello!
-//             </div>
-//         </div>
-//     </div>
-// </div>
-//
-// Requires '_animations.scss';
-$z-index-modal: 1;
-$z-index-modal-matte: 2;
-$z-index-modal-content: 3;
-$color-modal-close-bg: #333;
-$color-modal-close-text: #fff;
-$color-modal-content-bg: #fff;
-$color-black-opacity-093: rgba(255, 255, 255, .93);
-$color-dark-grey: #222;
-*/
-
-.u-body-modal-active {
-  overflow: hidden;
-}
-
-.modal {
-  position: fixed;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  left: 0;
-  z-index: 1;
-  animation: modal-in .15s ease-out 0s backwards;
-}
-
-.modal--active {
-    display: block;
-}
-
-
-.modal--exit {
-  animation: modal-out .4s ease-out .4s forwards;
-}
-
-.modal--exit .modal__content {
-  animation: affordance-out .4s ease-in 0s forwards;
-}
-
-.modal--exit .modal__close {
-  animation: affordance-out-right .4s ease-in 0s forwards;
-}
-
-
-.modal__overlay {
-  position: absolute;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  left: 0;
-  z-index: 2;
-  background: rgba(0, 0, 0, .93);
-}
-
-
-.modal__table {
-  display: table;
-  position: relative;
-  width: 100%;
-  height: 100%;
-  vertical-align: middle;
-}
-
-.modal__center {
-  display: table-cell;
-  text-align: center;
-  vertical-align: middle;
-  animation: modal-in .15s ease-out .25s backwards;
-}
-
-.modal__content {
-  display: inline-block;
-  position: relative;
-  z-index: 3;
-  max-width: 32em;
-  min-width: 10.5em;
-  min-height: 6em;
-  padding: 1em 2em;
-  background: #fff;
-  animation: affordance-in .5s cubic-bezier(.075, .82, .165, 0) .3s backwards;
-}
-
-
-.modal__close {
-  position: absolute;
-  top: 0;
-  right: 0;
-  z-index: 3;
-  padding: .9rem 1.35rem 1.1rem;
-  font-size: 2em;
-  line-height: 1;
-  color: #fff;
-  cursor: pointer;
-  background: #333;
-  animation: affordance-in-right .5s cubic-bezier(.075, .82, .165, 0) .25s backwards;
-}
-
-.modal__close:hover,
-.modal__close:active {
-  color: #fff;
-  background: #222;
-}
-
-
-
-/**
- * Animation keyframes
- */
-
-@keyframes modal-in {
-  0% {
-    opacity: 0;
-  }
-
-  100% {
-    opacity: 1;
-  }
-}
-
-@keyframes modal-out {
-  0% {
-    opacity: 1;
-  }
-
-  100% {
-    opacity: 0;
-  }
-}
-
-@keyframes affordance-in {
-  0% {
-    opacity: 0;
-    transform: translateY(5%);
-  }
-
-  100% {
-    opacity: 1;
-    transform: translateY(0);
-  }
-}
-
-@keyframes affordance-out {
-  0% {
-    opacity: 1;
-    transform: translateY(0%);
-  }
-
-  100% {
-    opacity: 0;
-    transform: translateY(5%);
-  }
-}
-
-
-
-@keyframes affordance-in-right {
-  0% {
-    opacity: 0;
-    transform: translateX(100%);
-  }
-
-  100% {
-    opacity: 1;
-    transform: translateX(0);
-  }
-}
-
-@keyframes affordance-out-right {
-  0% {
-    opacity: 1;
-    transform: translateX(0%);
-  }
-
-  100% {
-    opacity: 0;
-    transform: translateX(100%);
-  }
-}

+ 12 - 10
wagtail/wagtailadmin/templates/wagtailadmin/admin_base.html

@@ -18,20 +18,22 @@
     <script>
         (function(document, window) {
             window.wagtailConfig = window.wagtailConfig || {};
-            wagtailConfig.api = {
-                pages: '{% url "wagtailadmin_api_v1:pages:listing" %}',
-                documents: '{% url "wagtailadmin_api_v1:documents:listing" %}',
-                images: '{% url "wagtailadmin_api_v1:images:listing" %}'
+            wagtailConfig.ADMIN_API = {
+                PAGES: '{% url "wagtailadmin_api_v1:pages:listing" %}',
+                DOCUMENTS: '{% url "wagtailadmin_api_v1:documents:listing" %}',
+                IMAGES: '{% url "wagtailadmin_api_v1:images:listing" %}'
             };
-            wagtailConfig.strings = {
+
+            wagtailConfig.STRINGS = {
                 EXPLORER: "{% trans 'Explorer' %}",
-                LOADING: "{% trans 'Loading' %}",
+                LOADING: "{% trans 'Loading...' %}",
                 NO_RESULTS: "{% trans 'No results' %}",
-                SEE_CHILDREN: "{% trans 'See Children' %}"
-
+                SEE_CHILDREN: "{% trans 'See Children' %}",
+                NO_DATE: "{% trans 'No date' %}",
             };
-            wagtailConfig.urls = {
-                pages: '{% url "wagtailadmin_explore_root" %}'
+
+            wagtailConfig.ADMIN_URLS = {
+                PAGES: '{% url "wagtailadmin_explore_root" %}'
             };
         })(document, window);
     </script>

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