瀏覽代碼

First version of the explorer on top of admin API

Josh Barr 9 年之前
父節點
當前提交
d675807cf8
共有 43 個文件被更改,包括 1887 次插入173 次删除
  1. 1 23
      .eslintrc
  2. 11 4
      client/src/cli/component.js
  3. 126 0
      client/src/components/explorer/Explorer.js
  4. 7 0
      client/src/components/explorer/ExplorerEmpty.js
  5. 81 0
      client/src/components/explorer/ExplorerHeader.js
  6. 63 0
      client/src/components/explorer/ExplorerItem.js
  7. 226 0
      client/src/components/explorer/ExplorerPanel.js
  8. 9 0
      client/src/components/explorer/LoadingSpinner.js
  9. 31 0
      client/src/components/explorer/PageCount.js
  10. 142 0
      client/src/components/explorer/actions/index.js
  11. 0 28
      client/src/components/explorer/explorer-item.js
  12. 18 0
      client/src/components/explorer/filter.js
  13. 0 67
      client/src/components/explorer/index.js
  14. 95 0
      client/src/components/explorer/reducers/explorer.js
  15. 13 0
      client/src/components/explorer/reducers/index.js
  16. 99 0
      client/src/components/explorer/reducers/nodes.js
  17. 15 0
      client/src/components/explorer/reducers/transport.js
  18. 367 3
      client/src/components/explorer/style.scss
  19. 60 0
      client/src/components/explorer/toggle.js
  20. 17 0
      client/src/components/icon/Icon.js
  21. 13 0
      client/src/components/icon/README.md
  22. 5 0
      client/src/components/icon/style.scss
  23. 2 2
      client/src/components/index.js
  24. 3 3
      client/src/components/loading-indicator/LoadingIndicator.js
  25. 17 0
      client/src/components/publish-status/PublishStatus.js
  26. 9 0
      client/src/components/publish-status/README.md
  27. 5 0
      client/src/components/publish-status/style.scss
  28. 14 0
      client/src/components/published-time/PublishedTime.js
  29. 9 0
      client/src/components/published-time/README.md
  30. 5 0
      client/src/components/published-time/style.scss
  31. 0 0
      client/src/components/state-indicator/StateIndicator.js
  32. 14 1
      client/src/config/index.js
  33. 11 13
      client/template/component.mst
  34. 25 0
      client/template/component.test.mst
  35. 21 0
      client/tests/components/Icon.test.js
  36. 31 2
      client/tests/components/explorer.test.js
  37. 12 0
      client/tests/stubs.js
  38. 16 3
      package.json
  39. 35 11
      wagtail/wagtailadmin/static_src/wagtailadmin/app/wagtailadmin.entry.js
  40. 1 1
      wagtail/wagtailadmin/static_src/wagtailadmin/js/explorer-menu.js
  41. 1 0
      wagtail/wagtailadmin/static_src/wagtailadmin/scss/components/_main-nav.scss
  42. 239 12
      wagtail/wagtailadmin/static_src/wagtailadmin/scss/core.scss
  43. 18 0
      wagtail/wagtailadmin/templates/wagtailadmin/admin_base.html

+ 1 - 23
.eslintrc

@@ -1,25 +1,3 @@
 {
 {
-    "extends": "airbnb",
-
-    "rules": {
-        "indent": [2, 2],
-        "max-len": [1, 120, 4, {"ignoreUrls": true}],
-        "id-length": [1, {"min": 2, "exceptions": ["x", "y", "e", "i", "j", "k", "d", "n", "_", "$"]}],
-        "object-shorthand": [2, "methods"],
-        "no-new": [1],
-        "comma-dangle": [0],
-        "no-multi-spaces": [0],
-        "prefer-template": [0],
-        "no-var": [0],
-        "prefer-arrow-callback": [1],
-        "no-undef": [1],
-        "no-unused-vars": [1],
-        "no-warning-comments": [1, { "terms": ["todo", "fixme", "xxx"], "location": "start" }],
-        "react/sort-comp": [0],
-        "react/jsx-boolean-value": [0],
-        "react/jsx-no-bind": [0],
-        "react/prefer-es6-class": [0, 'never'],
-        "react/jsx-indent-props": [2, 4],
-        "jsx-quotes": [1, "prefer-double"]
-    }
+  "extends": "wagtail"
 }
 }

+ 11 - 4
client/src/cli/component.js

@@ -6,8 +6,9 @@ var TEMPLATES = path.join(__dirname, '..', '..', 'template');
 
 
 var files = [
 var files = [
 {
 {
-  name: 'index.js',
-  template: 'component.mst'
+  name: 'component.js',
+  template: 'component.mst',
+  suffix: '.js',
 },
 },
 {
 {
   name: 'style.scss',
   name: 'style.scss',
@@ -16,6 +17,11 @@ var files = [
 {
 {
   name: 'README.md',
   name: 'README.md',
   template: 'README.mst'
   template: 'README.mst'
+},
+{
+  name: 'component.test.js',
+  template: 'component.test.mst',
+  suffix: '.test.js',
 }
 }
 ];
 ];
 
 
@@ -47,7 +53,7 @@ function write(name, data) {
 // Write files!
 // Write files!
 // =============================================================================
 // =============================================================================
 function run(argv) {
 function run(argv) {
-  var name = argv.name;
+  var name = argv.name[0].toUpperCase() + argv.name.substring(1);
   var slug = slugify(name);
   var slug = slugify(name);
   var directory = path.join(argv.dir, slug);
   var directory = path.join(argv.dir, slug);
 
 
@@ -59,8 +65,9 @@ function run(argv) {
   }
   }
 
 
   files.forEach(function(file) {
   files.forEach(function(file) {
+    var fileName = file.suffix ? name + file.suffix : file.name;
     var template = fs.readFileSync(path.join(TEMPLATES, file.template), 'utf8');
     var template = fs.readFileSync(path.join(TEMPLATES, file.template), 'utf8');
-    var newPath = path.join(directory, file.name);
+    var newPath = path.join(directory, fileName);
     var context = {
     var context = {
       name: name,
       name: name,
       slug: slug
       slug: slug

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

@@ -0,0 +1,126 @@
+import React, { Component, PropTypes } from 'react';
+import CSSTransitionGroup from 'react-addons-css-transition-group';
+import { connect } from 'react-redux'
+
+import * as actions from './actions';
+import { EXPLORER_ANIM_DURATION } from 'config';
+import ExplorerPanel from './ExplorerPanel';
+
+
+class Explorer extends Component {
+  constructor(props) {
+    super(props);
+    this._init = this._init.bind(this);
+  }
+
+  componentDidMount() {
+    if (this.props.defaultPage) {
+      this.props.setDefaultPage(this.props.defaultPage);
+    }
+  }
+
+  _init(id) {
+    if (this.props.page && this.props.page.isLoaded) {
+      return;
+    }
+
+    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];
+    return nodes[id];
+  }
+
+  render() {
+    let { visible, depth, nodes, path, pageTypes, items, type, filter, fetching, resolved } = this.props;
+    let page = this._getPage();
+
+    const explorerProps = {
+      path,
+      pageTypes,
+      page,
+      type,
+      fetching,
+      filter,
+      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
+    }
+
+    const transProps = {
+      component: 'div',
+      transitionEnterTimeout: EXPLORER_ANIM_DURATION,
+      transitionLeaveTimeout: EXPLORER_ANIM_DURATION,
+      transitionName: 'explorer-toggle'
+    }
+
+    return (
+      <CSSTransitionGroup {...transProps}>
+        { visible ? <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,
+};
+
+
+// =============================================================================
+// Connector
+// =============================================================================
+
+const mapStateToProps = (state, ownProps) => ({
+  visible: 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,
+  pageTypes: state.explorer.pageTypes,
+  // page: state.explorer.page
+  // indexes: state.entities.indexes,
+  nodes: state.nodes,
+  animation: state.explorer.animation,
+  filter: state.explorer.filter,
+  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()) }
+  }
+}
+
+export default connect(
+  mapStateToProps,
+  mapDispatchToProps
+)(Explorer);

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

@@ -0,0 +1,7 @@
+import React from 'react';
+
+const ExplorerEmpty = () => (
+  <div className="c-explorer__placeholder">No results</div>
+);
+
+export default ExplorerEmpty;

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

@@ -0,0 +1,81 @@
+import React, { Component } from 'react';
+import CSSTransitionGroup from 'react-addons-css-transition-group';
+import { EXPLORER_ANIM_DURATION, EXPLORER_FILTERS } 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() {
+    let { onPop } = this.props;
+
+    return (
+      <span className='c-explorer__back' onClick={onPop}>
+        <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 '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}>
+          { depth > 1 ? this._getBackBtn() : null }
+          <span className='u-overflow c-explorer__overflow'>
+          <CSSTransitionGroup {...transitionProps}>
+            <span className='c-explorer__parent-name' key={depth}>
+              {this._getTitle()}
+            </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>
+    );
+  }
+}
+
+export default ExplorerHeader;

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

@@ -0,0 +1,63 @@
+import React, { Component, PropTypes } from 'react';
+
+import { ADMIN_PAGES } 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);
+  }
+
+  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'>
+            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>
+    );
+  }
+}
+
+ExplorerItem.propTypes = {
+  title: PropTypes.string,
+  data: PropTypes.object
+};

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

@@ -0,0 +1,226 @@
+import React, { Component, PropTypes } from 'react';
+import CSSTransitionGroup from 'react-addons-css-transition-group';
+import { EXPLORER_ANIM_DURATION } from 'config';
+
+import ExplorerEmpty from './ExplorerEmpty';
+import ExplorerHeader from './ExplorerHeader';
+import ExplorerItem from './ExplorerItem';
+import LoadingSpinner from './LoadingSpinner';
+
+export default class ExplorerPanel extends Component {
+  constructor(props) {
+    super(props);
+    this._clickOutside = this._clickOutside.bind(this);
+    this._onItemClick = this._onItemClick.bind(this);
+    this.closeModal = this.closeModal.bind(this);
+
+    this.state = {
+      modalIsOpen: false,
+      animation: 'push',
+    }
+  }
+
+  componentWillReceiveProps(newProps) {
+    let oldProps = this.props;
+
+    if (!oldProps.path) {
+      return;
+    }
+
+    if (newProps.path.length > oldProps.path.length) {
+      return this.setState({ animation: 'push' });
+    } else {
+      return this.setState({ animation: 'pop' });
+    }
+  }
+
+  _loadChildren() {
+    let { page } = 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);
+    }
+  }
+
+  componentDidUpdate() {
+    this._loadChildren();
+  }
+
+  componentDidMount() {
+    this.props.init();
+
+    document.body.style.overflow = 'hidden';
+    document.body.classList.add('u-explorer-open');
+    document.addEventListener('click', this._clickOutside);
+  }
+
+  componentWillUnmount() {
+    document.body.style.overflow = '';
+    document.body.classList.remove('u-explorer-open');
+    document.removeEventListener('click', this._clickOutside);
+  }
+
+  _clickOutside(e) {
+    let { explorer } = this.refs;
+
+    if (!explorer) {
+      return;
+    }
+
+    if (!explorer.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  _getStyle() {
+    const { top, left } = this.props;
+    return {
+      left: left + 'px',
+      top: top + 'px'
+    };
+  }
+
+  _getClass() {
+    let { type } = this.props;
+    let cls = ['c-explorer'];
+
+    if (type) {
+      cls.push(`c-explorer--${type}`);
+    }
+
+    return cls.join(' ');
+  }
+
+  closeModal() {
+    const { dispatch } = this.props;
+    dispatch(clearError());
+    this.setState({
+      modalIsOpen: false
+    });
+  }
+
+  _onItemClick(id) {
+    let node = this.props.nodes[id];
+
+    if (node.isLoaded) {
+      this.props.pushPage(id);
+    } else {
+      this.props.loadItemWithChildren(id);
+    }
+  }
+
+  renderChildren(page) {
+    let { 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} />
+    });
+  }
+
+  _getContents() {
+    let { page } = this.props;
+    let contents = null;
+
+    if (page) {
+      if (page.children.items.length) {
+        return this.renderChildren(page)
+      } else {
+        return <ExplorerEmpty />
+      }
+    }
+  }
+
+  render() {
+    let {
+      page,
+      onPop,
+      onClose,
+      loading,
+      type,
+      pageData,
+      transport,
+      onFilter,
+      filter,
+      path,
+      resolved
+    } = this.props;
+
+    // Don't show anything until the tree is resolved.
+    if (!this.props.resolved) {
+      return <div />
+    }
+
+    const headerProps = {
+      depth: path.length,
+      page,
+      onPop,
+      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`
+    }
+
+    return (
+      <div style={this._getStyle()} className={this._getClass()} ref='explorer'>
+        <ExplorerHeader {...headerProps} transName={this.state.animation} />
+        <div className='c-explorer__drawer'>
+          <CSSTransitionGroup {...transitionProps}>
+            <div {...transitionTargetProps}>
+              <CSSTransitionGroup {...innerTransitionProps}>
+                {page.isFetching ? <LoadingSpinner key={1} /> : (
+                  <div key={0}>
+                    {this._getContents()}
+                  </div>
+              )}
+              </CSSTransitionGroup>
+
+            </div>
+          </CSSTransitionGroup>
+        </div>
+      </div>
+    )
+  }
+}
+
+ExplorerPanel.propTypes = {
+
+}

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

@@ -0,0 +1,9 @@
+import React from 'react';
+
+const LoadingSpinner = () => (
+  <div className="c-explorer__loading">
+    <span className="c-explorer__spinner icon icon-spinner" /> Loading...
+  </div>
+);
+
+export default LoadingSpinner;

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

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

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

@@ -0,0 +1,142 @@
+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());
+}
+
+export const fetchStart = createAction('FETCH_START');
+
+export const fetchSuccess = createAction('FETCH_SUCCESS', (id, body) => {
+  return { id, body };
+});
+
+export const fetchFailure = createAction('FETCH_FAILURE');
+
+export const pushPage = createAction('PUSH_PAGE');
+
+export const popPage = createAction('POP_PAGE');
+
+export const fetchBranchSuccess = createAction('FETCH_BRANCH_SUCCESS', (id, json) => {
+  return { id, json };
+});
+
+export const fetchBranchStart = createAction('FETCH_BRANCH_START');
+
+export const clearError = createAction('CLEAR_TRANSPORT_ERROR');
+
+export const resetTree = createAction('RESET_TREE');
+
+export const treeResolved = createAction('TREE_RESOLVED');
+
+// 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());
+        }
+      });
+  };
+}
+
+export function fetchRoot() {
+  return (dispatch) => {
+    // TODO Should not need an id.
+    dispatch(resetTree(1));
+
+    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(fetchTree(rootId));
+      });
+  };
+}
+
+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) => {
+    const { explorer } = getState();
+    const id = explorer.path[explorer.path.length - 1];
+
+    dispatch({
+      payload: {
+        filter,
+        id
+      },
+      type: 'SET_FILTER'
+    });
+
+    dispatch(fetchChildren(id));
+  };
+}
+
+/**
+ * TODO: determine if page is already loaded, don't load it again, just push.
+ */
+export function fetchPage(id = 1) {
+  return dispatch => {
+    dispatch(fetchStart(id));
+    return _get(`${API_PAGES}${id}/`)
+      .then(json => dispatch(fetchSuccess(id, json)))
+      .then(json => dispatch(fetchChildren(id, json)))
+      .catch(json => dispatch(fetchFailure(new Error(JSON.stringify(json)))));
+  };
+}
+
+export const setDefaultPage = createAction('SET_DEFAULT_PAGE');

+ 0 - 28
client/src/components/explorer/explorer-item.js

@@ -1,28 +0,0 @@
-import React, { Component, PropTypes } from 'react';
-import StateIndicator from 'components/state-indicator';
-
-export default class ExplorerItem extends Component {
-  constructor(props) {
-    super(props);
-    this.state = {};
-  }
-
-  render() {
-    const { title, data } = this.props;
-
-    return (
-      <div className="c-explorer__item">
-        <h3 className="c-explorer__title">
-          <StateIndicator state={data.state} />
-          {title}
-        </h3>
-      </div>
-    );
-  }
-}
-
-
-ExplorerItem.propTypes = {
-  title: PropTypes.string,
-  data: PropTypes.object
-};

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

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

+ 0 - 67
client/src/components/explorer/index.js

@@ -1,67 +0,0 @@
-import React, { Component, PropTypes } from 'react';
-import LoadingIndicator from 'components/loading-indicator';
-import ExplorerItem from './explorer-item';
-
-import { API } from 'config';
-
-
-class Explorer extends Component {
-
-  constructor(props) {
-    super(props);
-    this.state = { cursor: null };
-  }
-
-  componentDidMount() {
-    fetch(`${API}/pages/?child_of=root`)
-    .then(res => res.json())
-    .then(body => {
-      this.setState({
-        cursor: body
-      });
-    });
-  }
-
-  componentWillUnmount(cursor) {
-
-  }
-
-  _getPages(cursor) {
-    if (!cursor) {
-      return [];
-    }
-
-    return cursor.pages.map(item =>
-      <ExplorerItem key={item.id} title={item.title} data={item} />
-    );
-  }
-
-  getPosition() {
-    const { position } = this.props;
-    return {
-      left: position.right + 'px',
-      top: position.top + 'px'
-    };
-  }
-
-  render() {
-    const { cursor } = this.state;
-    const pages = this._getPages(cursor);
-
-    return (
-      <div style={this.getPosition()} className="c-explorer">
-        {cursor ? pages : <LoadingIndicator />}
-      </div>
-    );
-  }
-}
-
-Explorer.propTypes = {
-  onPageSelect: PropTypes.func,
-  initialPath: PropTypes.string,
-  apiPath: PropTypes.string,
-  size: PropTypes.number,
-  position: PropTypes.object
-};
-
-export default Explorer;

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

@@ -0,0 +1,95 @@
+const stateDefaults = {
+  isVisible: false,
+  isFetching: false,
+  isResolved: false,
+  path: [],
+  currentPage: 1,
+  defaultPage: 1,
+  // 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) {
+
+  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
+      });
+
+    // 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
+      });
+  }
+  return state;
+}

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

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

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

@@ -0,0 +1,99 @@
+function children(state={
+  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
+      });
+  }
+  return state;
+}
+
+
+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)
+        })
+      });
+
+    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)
+        })
+      });
+
+    case 'RESET_TREE':
+      return Object.assign({}, {});
+
+    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 })
+        }
+      }
+
+      return Object.assign({}, updatedState);
+
+    case 'FETCH_START':
+      return Object.assign({}, state, {
+        [action.payload]: Object.assign({}, defaults, 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_SUCCESS':
+      return state;
+  }
+
+  return state;
+}

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

@@ -0,0 +1,15 @@
+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
+      });
+  }
+  return state;
+}

+ 367 - 3
client/src/components/explorer/style.scss

@@ -1,13 +1,377 @@
+$c-explorer-bg: #4C4E4D;
+$c-explorer-secondary: #aaa;
+$c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
+
+.c-explorer * {
+    box-sizing: border-box;
+}
+
 .c-explorer {
 .c-explorer {
     width: 320px;
     width: 320px;
     height: 500px;
     height: 500px;
+    background: $c-explorer-bg;
+    position: absolute;
+    overflow: hidden;
+}
+
+    .c-explorer--sidebar {
+        height: 100vh;
+        box-shadow: 2px 2px 5px rgba(0,0,0,0.2);
+        left: 180px;
+        top: 0;
+        z-index: 150;
+        position: fixed;
+    }
+
+.c-explorer__header {
+    border-bottom: solid 1px #676767;
+    overflow: hidden;
+    color: $c-explorer-secondary;
+}
+
+.c-explorer__trigger {
+    display: block;
+    padding: .5rem 1rem;
+    white-space: nowrap;
+    overflow: hidden;
+    width: 80%;
+    float: left;
+}
+
+.c-explorer__trigger--enabled {
+    cursor: pointer;
+
+    &:hover {
+        color: #fff;
+        background: rgba(0,0,0,0.2);
+    }
+}
+
+.c-explorer__filter {
+    float: right;
+    width: 50px;
+    margin-top: .5rem;
+}
+
+.c-filter {
+    display: inline-block;
+    vertical-align: middle;
+    padding: 0 .25em;
+    border: solid 1px rgba(255,255,255,0.1);
+    border-radius: 2px;
+    line-height: 1;
+    margin-left: .25rem;
+    cursor: pointer;
+    &:hover {
+        background: rgba(0,0,0,0.5);
+        border-color: rgba(0,0,0,0.5);
+        color: #fff;
+    }
+}
+
+.c-filter--active {
+    color: #fff;
+    border-color: rgba(255, 255, 255, .5);
+}
+
+
+.c-explorer__back {
+    cursor: pointer;
+    margin-right: .25rem;
+    float: left;
+    margin-top: -1px;
+
+    &:hover {
+        color: #fff;
+    }
+
+    .icon {
+        line-height: 1;
+        display: inline-block;
+        font-size: 16px;
+    }
+}
+
+.c-explorer__title {
+    margin: 0;
+    color: #fff;
+}
+
+.c-explorer__loading {
+    color: #fff;
+    padding: 1rem;
+}
+
+.c-explorer__item {
+    padding: 1rem;
+    cursor: pointer;
+    border-bottom: solid 1px #676767;
+
+    &:last-child {
+        border-bottom: 0;
+    }
+}
+
+.c-explorer__placeholder {
+    padding: 1rem;
+    color: #fff;
+}
+
+.c-explorer__meta {
+    font-size: 12px;
+    color: $c-explorer-secondary;
+    margin-bottom: 0;
+}
+
+    // TODO Could be a utility class
+    .c-explorer__meta__type {
+        text-transform: capitalize;
+    }
+
+.c-explorer__item:hover {
+    background: rgba(0, 0, 0, 0.25);
+    color: #fff;
+}
+
+.c-explorer__see-more {
+    cursor: pointer;
+    padding: .5rem 1rem;
+    background: rgba(0,0,0,0.2);
+    color: #fff;
+
+    &:hover {
+        background: rgba(0,0,0,0.4);
+    }
+}
+
+
+.c-explorer__children {
+    display: inline-block;
+    border-radius: 50rem;
+    border: solid 1px #aaa;
+    color: #fff;
+    line-height: 1;
+    padding: .5em .3em .5em .5em;
+    float: right;
+    cursor: pointer;
+
+    &:hover {
+        background: rgba(0,0,0,0.5);
+    }
+
+    > [aria-role='presentation'] {
+        display: none;
+    }
+}
+
+
+
+
+.c-status {
     background: #333;
     background: #333;
+    color: #ddd;
+    text-transform: uppercase;
+    letter-spacing: .03rem;
+    font-size: 10px;
+}
+
+.c-status--live {
+
+}
+
+
+.c-explorer__drawer {
     position: absolute;
     position: absolute;
-    z-index: 25;
+    bottom: 0;
+    top: 36px;
+    width: 100%;
+    overflow-y: auto;
+}
+
+
+.c-explorer__overflow {
+    max-width: 12rem;
+    display: block;
+    text-transform: uppercase;
+    float: left;
+    width: 100%;
+}
+
+
+// =============================================================================
+// TODO: move to their own component..
+// =============================================================================
+
+.o-pill {
+    display: inline-block;
+    padding: 0 .5em;
+    border-radius: .25em;
+    line-height: 1;
+    vertical-align: middle;
+    line-height: 1.5;
+}
+
+.u-overflow {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+}
+
+
+.c-explorer__rel {
+    position: relative;
+    display: block;
+    height: 19px;
+    width: 100%;
+}
+
+
+.c-explorer__parent-name {
+    position: absolute;
+    width: 100%;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    overflow: hidden;
+}
+
+.c-explorer__spinner:after {
+    display: inline-block;
+    animation: spin 0.5s infinite linear;
+    line-height: 1
+}
+
+
+
+// =============================================================================
+// Transitions
+// =============================================================================
+
+// $out-circ: cubic-bezier(0.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;
+
+.c-explorer__transition-group {
+    position: absolute;
+    width: 100%;
     top: 0;
     top: 0;
-    left: 180px;
 }
 }
 
 
-.c-explorer__item {
+.explorer-push-enter {
+    transform: translateX(100%);
+    transition: transform $c-explorer-duration $out-circ, opacity $c-explorer-duration linear;
+    opacity: 0;
+}
+
+.explorer-push-enter-active {
+    transform: translateX(0);
+    opacity: 1;
+}
+
+.explorer-push-leave {
+    transform: translateX(0);
+    transition: transform $c-explorer-duration $in-circ, opacity $c-explorer-duration linear;
+    opacity: 1;
+}
+
+.explorer-push-leave-active {
+    transform: translateX(-100%);
+    opacity: 0;
+}
+
+// =============================================================================
+// Pop transition
+// =============================================================================
+
+.explorer-pop-enter {
+    transform: translateX(-100%);
+    transition: transform $c-explorer-duration $out-circ, opacity $c-explorer-duration linear;
+    opacity: 0;
+}
+
+.explorer-pop-enter-active {
+    transform: translateX(0);
+    opacity: 1;
+}
+
+.explorer-pop-leave {
+    transform: translateX(0);
+    transition: transform $c-explorer-duration $in-circ, opacity $c-explorer-duration linear;
+    opacity: 1;
+}
+
+.explorer-pop-leave-active {
+    transform: translateX(100%);
+    opacity: 0;
+}
+
+
+.explorer-toggle-enter {
+    opacity: 0;
+    transition: all $c-explorer-duration;
+}
+
+.explorer-toggle-enter-active {
+    opacity: 1;
+}
+
+.explorer-toggle-leave {
+    opacity: 1;
+    transition: all $c-explorer-duration;
+}
+
+.explorer-toggle-leave-active {
+    opacity: 0;
+}
+
+
+// =============================================================================
+// Fade transition
+// =============================================================================
+
+.explorer-fade-enter {
+    position: absolute;
+    width: 100%;
+    opacity: 0;
+    transition: opacity .2s ease .1s;
+}
+
+.explorer-fade-enter-active {
+    opacity: 1;
+}
+
+.explorer-fade-leave {
+    position: absolute;
+    width: 100%;
+    opacity: 1;
+    transition: opacity .1s ease;
+}
+
+.explorer-fade-leave-active {
+    opacity: 0;
+}
+
+
+// =============================================================================
+// 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;
 }
 }

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

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

+ 17 - 0
client/src/components/icon/Icon.js

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

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

@@ -0,0 +1,13 @@
+# Icon
+
+A simple component to render an icon. Abstracts away the actual icon implementation (font icons, SVG icons, CSS sprite).
+
+## Usage
+
+```javascript
+import { Icon } from 'wagtail';
+
+render(
+    <Icon name="arrow-left" className="icon--active icon--warning" />
+);
+```

+ 5 - 0
client/src/components/icon/style.scss

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

+ 2 - 2
client/src/components/index.js

@@ -1,6 +1,6 @@
 import Explorer from './explorer';
 import Explorer from './explorer';
-import LoadingIndicator from './loading-indicator';
-import StateIndicator from './state-indicator';
+import LoadingIndicator from './LoadingIndicator';
+import StateIndicator from './StateIndicator';
 
 
 export { Explorer };
 export { Explorer };
 export { LoadingIndicator };
 export { LoadingIndicator };

+ 3 - 3
client/src/components/loading-indicator/index.js → client/src/components/loading-indicator/LoadingIndicator.js

@@ -1,9 +1,9 @@
 import React from 'react';
 import React from 'react';
 
 
-const LoadingIndicator = () =>
+const LoadingIndicator = () => (
     <div className="o-icon c-indicator is-spinning">
     <div className="o-icon c-indicator is-spinning">
         <span ariaRole="presentation">Loading...</span>
         <span ariaRole="presentation">Loading...</span>
-    </div>;
-
+    </div>
+);
 
 
 export default LoadingIndicator;
 export default LoadingIndicator;

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

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

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

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

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

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

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

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

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

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

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

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

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


+ 14 - 1
client/src/config/index.js

@@ -1 +1,14 @@
-export const API = '/admin/api/v2beta/';
+export const API = global.wagtailConfig.api;
+export const API_PAGES = global.wagtailConfig.api.pages;
+
+export const PAGES_ROOT_ID = 'root';
+
+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' }
+];

+ 11 - 13
client/template/component.mst

@@ -1,15 +1,13 @@
-import React, { Component, PropTypes } from 'react';
+import React, { PropTypes } from 'react';
 
 
-export default class {{ name }} extends Component {
-  constructor(props) {
-    super(props);
-    this.state = {};
-  }
+const {{ name }} = (props) => {
+  return (
+    <div className="c-{{ slug }}">
+    </div>
+  );
+};
 
 
-  render() {
-    return (
-      <div className="c-{{ slug }}">
-      </div>
-    );
-  }
-}
+{{ name }}.propTypes = {
+};
+
+export default {{ name }};

+ 25 - 0
client/template/component.test.mst

@@ -0,0 +1,25 @@
+// 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 {{ name }} from '../../src/components/{{ slug }}/{{ name }}';
+
+describe('{{ name }}', () => {
+  it('exists', () => {
+    expect({{ name }}).to.exist;
+  });
+
+  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);
+  });
+});

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

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

+ 31 - 2
client/tests/components/explorer.test.js

@@ -1,10 +1,39 @@
-/*eslint-disable */
+import React from 'react';
 import { expect } from 'chai';
 import { expect } from 'chai';
-import Explorer from '../../src/components/explorer';
+import { shallow } from 'enzyme';
 
 
+import '../stubs';
+import Explorer from '../../src/components/explorer/Explorer';
+import ExplorerItem from '../../src/components/explorer/ExplorerItem';
 
 
 describe('Explorer', () => {
 describe('Explorer', () => {
   it('exists', () => {
   it('exists', () => {
+    // eslint-disable-next-line no-unused-expressions
     expect(Explorer).to.exist;
     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');
+    });
+  });
 });
 });

+ 12 - 0
client/tests/stubs.js

@@ -0,0 +1,12 @@
+global.wagtailConfig = {
+  api: {
+    documents: '/admin/api/v1beta/documents/',
+    images: '/admin/api/v1beta/images/',
+    pages: '/admin/api/v1beta/pages/',
+  },
+  urls: {
+    pages: '/admin/pages/',
+  }
+};
+
+global.wagtailVersion = '1.6a1';

+ 16 - 3
package.json

@@ -20,9 +20,12 @@
     "babel-preset-es2015": "^6.5.0",
     "babel-preset-es2015": "^6.5.0",
     "babel-preset-react": "^6.5.0",
     "babel-preset-react": "^6.5.0",
     "chai": "^3.5.0",
     "chai": "^3.5.0",
-    "eslint": "^2.2.0",
-    "eslint-config-airbnb": "^6.0.2",
-    "eslint-plugin-react": "^4.1.0",
+    "enzyme": "^2.3.0",
+    "eslint": "^2.9.0",
+    "eslint-config-wagtail": "^0.1.0",
+    "eslint-plugin-import": "^1.8.1",
+    "eslint-plugin-jsx-a11y": "^1.5.3",
+    "eslint-plugin-react": "^4.3.0",
     "glob": "^7.0.0",
     "glob": "^7.0.0",
     "gulp": "~3.8.11",
     "gulp": "~3.8.11",
     "gulp-autoprefixer": "~3.0.2",
     "gulp-autoprefixer": "~3.0.2",
@@ -34,17 +37,26 @@
     "lodash": "^4.5.1",
     "lodash": "^4.5.1",
     "mocha": "^2.4.5",
     "mocha": "^2.4.5",
     "mustache": "^2.2.1",
     "mustache": "^2.2.1",
+    "react-addons-test-utils": "^0.14.8",
     "redux-devtools": "^3.1.1",
     "redux-devtools": "^3.1.1",
     "require-dir": "^0.3.0",
     "require-dir": "^0.3.0",
     "sinon": "^1.17.3"
     "sinon": "^1.17.3"
   },
   },
   "dependencies": {
   "dependencies": {
+    "babel-polyfill": "^6.5.0",
     "exports-loader": "^0.6.3",
     "exports-loader": "^0.6.3",
     "imports-loader": "^0.6.5",
     "imports-loader": "^0.6.5",
+    "moment": "^2.11.2",
     "react": "^0.14.7",
     "react": "^0.14.7",
+    "react-accessible-modal": "0.0.5",
+    "react-addons-css-transition-group": "^0.14.7",
     "react-dom": "^0.14.7",
     "react-dom": "^0.14.7",
+    "react-onclickoutside": "^4.5.0",
     "react-redux": "^4.4.0",
     "react-redux": "^4.4.0",
     "redux": "^3.3.1",
     "redux": "^3.3.1",
+    "redux-actions": "^0.10.0",
+    "redux-logger": "^2.6.0",
+    "redux-thunk": "^1.0.3",
     "webpack": "^1.12.14",
     "webpack": "^1.12.14",
     "whatwg-fetch": "^0.11.0"
     "whatwg-fetch": "^0.11.0"
   },
   },
@@ -57,6 +69,7 @@
     "lint": "npm run lint:js",
     "lint": "npm run lint:js",
     "test": "npm run test:unit",
     "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": "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:coverage": "env NODE_PATH=$NODE_PATH:$PWD/client/src babel-node $(npm bin)/isparta cover node_modules/mocha/bin/_mocha -- client/tests/**/*.test.js",
     "component": "node ./client/src/cli/index.js component --dir ./client/src/components/"
     "component": "node ./client/src/cli/index.js component --dir ./client/src/components/"
   }
   }

+ 35 - 11
wagtail/wagtailadmin/static_src/wagtailadmin/app/wagtailadmin.entry.js

@@ -1,6 +1,14 @@
+import 'babel-polyfill';
 import React from 'react';
 import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
-import Explorer from 'components/explorer';
+import { Provider } from 'react-redux';
+import { createStore, applyMiddleware } from 'redux';
+import createLogger from 'redux-logger'
+import thunkMiddleware from 'redux-thunk'
+
+import Explorer from 'components/explorer/Explorer';
+import ExplorerToggle from 'components/explorer/toggle';
+import rootReducer from 'components/explorer/reducers';
 
 
 
 
 document.addEventListener('DOMContentLoaded', e => {
 document.addEventListener('DOMContentLoaded', e => {
@@ -8,16 +16,32 @@ document.addEventListener('DOMContentLoaded', e => {
   const div = document.createElement('div');
   const div = document.createElement('div');
   const trigger = document.querySelector('[data-explorer-menu-url]');
   const trigger = document.querySelector('[data-explorer-menu-url]');
 
 
-  trigger.addEventListener('click', (e) => {
-    e.preventDefault();
-    e.stopPropagation();
-
-    if (!div.childNodes.length) {
-      ReactDOM.render(<Explorer position={trigger.getBoundingClientRect()} />, div);
-    } else {
-      ReactDOM.unmountComponentAtNode(div);
-    }
-  });
+  let rect = trigger.getBoundingClientRect();
+  let triggerParent = trigger.parentNode;
+  let label = trigger.innerText;
 
 
   top.parentNode.appendChild(div);
   top.parentNode.appendChild(div);
+
+  const loggerMiddleware = createLogger();
+
+  const store = createStore(
+    rootReducer,
+    applyMiddleware(loggerMiddleware, thunkMiddleware)
+  );
+
+  ReactDOM.render((
+      <Provider store={store}>
+        <ExplorerToggle label={label} />
+      </Provider>
+    ),
+    triggerParent
+  );
+
+  ReactDOM.render(
+    <Provider store={store}>
+      <Explorer type={'sidebar'} top={0} left={rect.right} defaultPage={1} />
+    </Provider>,
+    div
+  );
+
 });
 });

+ 1 - 1
wagtail/wagtailadmin/static_src/wagtailadmin/js/explorer-menu.js

@@ -3,7 +3,7 @@ $(function() {
     var $body = $('body');
     var $body = $('body');
 
 
     // Dynamically load menu on request.
     // Dynamically load menu on request.
-    $(document).on('click', '.dl-trigger', function() {
+    $(document).on('click', '.dl-trigger--unused', function() {
         var $this = $(this);
         var $this = $(this);
 
 
         // Close all submenus
         // Close all submenus

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

@@ -292,6 +292,7 @@ body.explorer-open {
             height: 100%;
             height: 100%;
             position: fixed;
             position: fixed;
             width: $menu-width;
             width: $menu-width;
+            z-index: 26;
         }
         }
     }
     }
 
 

+ 239 - 12
wagtail/wagtailadmin/static_src/wagtailadmin/scss/core.scss

@@ -27,6 +27,33 @@
 }
 }
 // scss-lint:enable all
 // 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;
+    }
+}
+
+
+
 html {
 html {
     background: $color-grey-4;
     background: $color-grey-4;
     height: 100%;
     height: 100%;
@@ -191,20 +218,22 @@ footer {
     }
     }
 }
 }
 
 
-::-webkit-scrollbar {
-    height: 10px;
-    width: 10px;
-    background: $color-grey-1;
-}
+// Let's not, for now...
 
 
-::-webkit-scrollbar-thumb {
-    background: $color-grey-2;
-    -webkit-border-radius: 1ex;
-}
+// ::-webkit-scrollbar {
+//     height: 10px;
+//     width: 10px;
+//     background: $color-grey-1;
+// }
 
 
-::-webkit-scrollbar-corner {
-    background: $color-grey-1;
-}
+// ::-webkit-scrollbar-thumb {
+//     background: $color-grey-2;
+//     -webkit-border-radius: 1ex;
+// }
+
+// ::-webkit-scrollbar-corner {
+//     background: $color-grey-1;
+// }
 
 
 .breadcrumb {
 .breadcrumb {
     @include unlist();
     @include unlist();
@@ -526,3 +555,201 @@ footer,
 // a {
 // a {
     // @include transition(color 0.2s ease, background-color 0.2s ease);
     // @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%);
+  }
+}

+ 18 - 0
wagtail/wagtailadmin/templates/wagtailadmin/admin_base.html

@@ -15,6 +15,19 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block js %}
 {% block js %}
+    <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.urls = {
+                pages: '{% url "wagtailadmin_explore_root" %}'
+            };
+        })(document, window);
+    </script>
     <script src="{% static 'wagtailadmin/js/vendor/jquery-2.2.1.min.js' %}"></script>
     <script src="{% static 'wagtailadmin/js/vendor/jquery-2.2.1.min.js' %}"></script>
     <script src="{% static 'wagtailadmin/js/vendor/jquery-ui-1.10.3.min.js' %}"></script>
     <script src="{% static 'wagtailadmin/js/vendor/jquery-ui-1.10.3.min.js' %}"></script>
     <script src="{% static 'wagtailadmin/js/vendor/jquery.datetimepicker.js' %}"></script>
     <script src="{% static 'wagtailadmin/js/vendor/jquery.datetimepicker.js' %}"></script>
@@ -27,6 +40,11 @@
     <script src="{% static 'wagtailadmin/js/core.js' %}"></script>
     <script src="{% static 'wagtailadmin/js/core.js' %}"></script>
     {% hook_output 'insert_global_admin_js' %}
     {% hook_output 'insert_global_admin_js' %}
 
 
+    <script src="{% static 'wagtailadmin/js/common.js' %}"></script>
+    <script src="{% static 'wagtailadmin/js/wagtailadmin.js' %}"></script>
+
+
+
     {% main_nav_js %}
     {% main_nav_js %}
 
 
     {% block extra_js %}{% endblock %}
     {% block extra_js %}{% endblock %}