diff --git a/.eslintrc b/.eslintrc
index 9fcfb7739..32325aa0e 100644
--- a/.eslintrc
+++ b/.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"
}
diff --git a/client/src/cli/component.js b/client/src/cli/component.js
index d34b3a6f9..39997bbab 100644
--- a/client/src/cli/component.js
+++ b/client/src/cli/component.js
@@ -6,8 +6,9 @@ var TEMPLATES = path.join(__dirname, '..', '..', 'template');
var files = [
{
- name: 'index.js',
- template: 'component.mst'
+ name: 'component.js',
+ template: 'component.mst',
+ suffix: '.js',
},
{
name: 'style.scss',
@@ -16,6 +17,11 @@ var files = [
{
name: 'README.md',
template: 'README.mst'
+},
+{
+ name: 'component.test.js',
+ template: 'component.test.mst',
+ suffix: '.test.js',
}
];
@@ -47,7 +53,7 @@ function write(name, data) {
// Write files!
// =============================================================================
function run(argv) {
- var name = argv.name;
+ var name = argv.name[0].toUpperCase() + argv.name.substring(1);
var slug = slugify(name);
var directory = path.join(argv.dir, slug);
@@ -59,8 +65,9 @@ function run(argv) {
}
files.forEach(function(file) {
+ var fileName = file.suffix ? name + file.suffix : file.name;
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 = {
name: name,
slug: slug
diff --git a/client/src/components/explorer/Explorer.js b/client/src/components/explorer/Explorer.js
new file mode 100644
index 000000000..3920093fb
--- /dev/null
+++ b/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 (
+
+ { visible ? : null }
+
+ );
+ }
+}
+
+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);
diff --git a/client/src/components/explorer/ExplorerEmpty.js b/client/src/components/explorer/ExplorerEmpty.js
new file mode 100644
index 000000000..88e7a8c87
--- /dev/null
+++ b/client/src/components/explorer/ExplorerEmpty.js
@@ -0,0 +1,7 @@
+import React from 'react';
+
+const ExplorerEmpty = () => (
+
No results
+);
+
+export default ExplorerEmpty;
diff --git a/client/src/components/explorer/ExplorerHeader.js b/client/src/components/explorer/ExplorerHeader.js
new file mode 100644
index 000000000..de3f25fb1
--- /dev/null
+++ b/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 (
+
+
+
+ );
+ }
+
+ 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 (
+
+
+ { depth > 1 ? this._getBackBtn() : null }
+
+
+
+ {this._getTitle()}
+
+
+
+
+
+ {EXPLORER_FILTERS.map(props => {
+ return
+ })}
+
+
+ );
+ }
+}
+
+export default ExplorerHeader;
diff --git a/client/src/components/explorer/ExplorerItem.js b/client/src/components/explorer/ExplorerItem.js
new file mode 100644
index 000000000..7e97de929
--- /dev/null
+++ b/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 (
+
+ {count > 0 ?
+
+
+
+ See Children
+
+ : null }
+
+
+ {title}
+
+
+ {typeName} | |
+
+
+ );
+ }
+}
+
+ExplorerItem.propTypes = {
+ title: PropTypes.string,
+ data: PropTypes.object
+};
diff --git a/client/src/components/explorer/ExplorerPanel.js b/client/src/components/explorer/ExplorerPanel.js
new file mode 100644
index 000000000..ed1c757fb
--- /dev/null
+++ b/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
+ });
+ }
+
+ _getContents() {
+ let { page } = this.props;
+ let contents = null;
+
+ if (page) {
+ if (page.children.items.length) {
+ return this.renderChildren(page)
+ } else {
+ return
+ }
+ }
+ }
+
+ 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
+ }
+
+ 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 (
+
+
+
+
+
+
+ {page.isFetching ? : (
+
+ {this._getContents()}
+
+ )}
+
+
+
+
+
+
+ )
+ }
+}
+
+ExplorerPanel.propTypes = {
+
+}
diff --git a/client/src/components/explorer/LoadingSpinner.js b/client/src/components/explorer/LoadingSpinner.js
new file mode 100644
index 000000000..80fb30fdc
--- /dev/null
+++ b/client/src/components/explorer/LoadingSpinner.js
@@ -0,0 +1,9 @@
+import React from 'react';
+
+const LoadingSpinner = () => (
+
+ Loading...
+
+);
+
+export default LoadingSpinner;
diff --git a/client/src/components/explorer/PageCount.js b/client/src/components/explorer/PageCount.js
new file mode 100644
index 000000000..17a8439ee
--- /dev/null
+++ b/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 ;
+ }
+
+ if (count > 1) {
+ prefix = 'all ';
+ }
+
+ if (count === 1) {
+ suffix = 'page';
+ }
+
+ return (
+ {
+ window.location.href = `${ADMIN_PAGES}${id}/`
+ }}
+ className="c-explorer__see-more">
+ See {prefix}{ count } {suffix}
+
+ );
+}
+
+export default PageCount;
diff --git a/client/src/components/explorer/actions/index.js b/client/src/components/explorer/actions/index.js
new file mode 100644
index 000000000..8e9641218
--- /dev/null
+++ b/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');
diff --git a/client/src/components/explorer/explorer-item.js b/client/src/components/explorer/explorer-item.js
deleted file mode 100644
index a0f381e69..000000000
--- a/client/src/components/explorer/explorer-item.js
+++ /dev/null
@@ -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 (
-
-
-
- {title}
-
-
- );
- }
-}
-
-
-ExplorerItem.propTypes = {
- title: PropTypes.string,
- data: PropTypes.object
-};
diff --git a/client/src/components/explorer/filter.js b/client/src/components/explorer/filter.js
new file mode 100644
index 000000000..9e69aae1d
--- /dev/null
+++ b/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 (
+ {label}
+ );
+}
+
+
+export default Filter;
diff --git a/client/src/components/explorer/index.js b/client/src/components/explorer/index.js
deleted file mode 100644
index 17e9bc7da..000000000
--- a/client/src/components/explorer/index.js
+++ /dev/null
@@ -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 =>
-
- );
- }
-
- 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 (
-
- {cursor ? pages : }
-
- );
- }
-}
-
-Explorer.propTypes = {
- onPageSelect: PropTypes.func,
- initialPath: PropTypes.string,
- apiPath: PropTypes.string,
- size: PropTypes.number,
- position: PropTypes.object
-};
-
-export default Explorer;
diff --git a/client/src/components/explorer/reducers/explorer.js b/client/src/components/explorer/reducers/explorer.js
new file mode 100644
index 000000000..84029977d
--- /dev/null
+++ b/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;
+}
diff --git a/client/src/components/explorer/reducers/index.js b/client/src/components/explorer/reducers/index.js
new file mode 100644
index 000000000..ce7b01768
--- /dev/null
+++ b/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;
diff --git a/client/src/components/explorer/reducers/nodes.js b/client/src/components/explorer/reducers/nodes.js
new file mode 100644
index 000000000..965ae871f
--- /dev/null
+++ b/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;
+}
diff --git a/client/src/components/explorer/reducers/transport.js b/client/src/components/explorer/reducers/transport.js
new file mode 100644
index 000000000..c4642ac0d
--- /dev/null
+++ b/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;
+}
diff --git a/client/src/components/explorer/style.scss b/client/src/components/explorer/style.scss
index a0f3426b3..1f3e4c7e4 100644
--- a/client/src/components/explorer/style.scss
+++ b/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 {
width: 320px;
height: 500px;
- background: #333;
+ background: $c-explorer-bg;
position: absolute;
- z-index: 25;
- top: 0;
- left: 180px;
+ 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;
+ color: #ddd;
+ text-transform: uppercase;
+ letter-spacing: .03rem;
+ font-size: 10px;
+}
+
+.c-status--live {
}
+
+
+.c-explorer__drawer {
+ position: absolute;
+ bottom: 0;
+ top: 36px;
+ width: 100%;
+ overflow-y: auto;
+}
+
+
+.c-explorer__overflow {
+ max-width: 12rem;
+ display: block;
+ text-transform: uppercase;
+ float: left;
+ width: 100%;
+}
+
+
+// =============================================================================
+// 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;
+}
+
+.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;
+}
diff --git a/client/src/components/explorer/toggle.js b/client/src/components/explorer/toggle.js
new file mode 100644
index 000000000..a5ee3fb64
--- /dev/null
+++ b/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 (
+
+ {this.props.label}
+
+ );
+ }
+}
+
+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);
diff --git a/client/src/components/icon/Icon.js b/client/src/components/icon/Icon.js
new file mode 100644
index 000000000..d87a5685f
--- /dev/null
+++ b/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 }) => (
+
+);
+
+Icon.propTypes = {
+ name: PropTypes.string.isRequired,
+ className: PropTypes.string,
+};
+
+Icon.defaultProps = {
+ className: '',
+};
+
+export default Icon;
diff --git a/client/src/components/icon/README.md b/client/src/components/icon/README.md
new file mode 100644
index 000000000..ba5654f06
--- /dev/null
+++ b/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(
+
+);
+```
diff --git a/client/src/components/icon/style.scss b/client/src/components/icon/style.scss
new file mode 100644
index 000000000..4c538c4e8
--- /dev/null
+++ b/client/src/components/icon/style.scss
@@ -0,0 +1,5 @@
+// Icon
+
+.c-icon {
+ display: block;
+}
diff --git a/client/src/components/index.js b/client/src/components/index.js
index c7c4fc6c0..0bdc6c5cf 100644
--- a/client/src/components/index.js
+++ b/client/src/components/index.js
@@ -1,6 +1,6 @@
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 { LoadingIndicator };
diff --git a/client/src/components/loading-indicator/index.js b/client/src/components/loading-indicator/LoadingIndicator.js
similarity index 78%
rename from client/src/components/loading-indicator/index.js
rename to client/src/components/loading-indicator/LoadingIndicator.js
index 6f0722440..e081165cc 100644
--- a/client/src/components/loading-indicator/index.js
+++ b/client/src/components/loading-indicator/LoadingIndicator.js
@@ -1,9 +1,9 @@
import React from 'react';
-const LoadingIndicator = () =>
+const LoadingIndicator = () => (
Loading...
-
;
-
+
+);
export default LoadingIndicator;
diff --git a/client/src/components/publish-status/PublishStatus.js b/client/src/components/publish-status/PublishStatus.js
new file mode 100644
index 000000000..17d1c06b2
--- /dev/null
+++ b/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 (
+
+ {status.status}
+
+ );
+}
+
+export default PublishStatus;
diff --git a/client/src/components/publish-status/README.md b/client/src/components/publish-status/README.md
new file mode 100644
index 000000000..0b3ecc71d
--- /dev/null
+++ b/client/src/components/publish-status/README.md
@@ -0,0 +1,9 @@
+# PublishStatus
+
+About this component
+
+## Usage
+
+```javascript
+import { PublishStatus } from 'wagtail';
+```
diff --git a/client/src/components/publish-status/style.scss b/client/src/components/publish-status/style.scss
new file mode 100644
index 000000000..21f0b2b13
--- /dev/null
+++ b/client/src/components/publish-status/style.scss
@@ -0,0 +1,5 @@
+// PublishStatus
+
+.c-publish-status {
+ display: block;
+}
diff --git a/client/src/components/published-time/PublishedTime.js b/client/src/components/published-time/PublishedTime.js
new file mode 100644
index 000000000..1274d6356
--- /dev/null
+++ b/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 (
+ {str}
+ );
+}
+
+export default PublishedTime;
diff --git a/client/src/components/published-time/README.md b/client/src/components/published-time/README.md
new file mode 100644
index 000000000..eacf96e6f
--- /dev/null
+++ b/client/src/components/published-time/README.md
@@ -0,0 +1,9 @@
+# PublishedTime
+
+About this component
+
+## Usage
+
+```javascript
+import { PublishedTime } from 'wagtail';
+```
diff --git a/client/src/components/published-time/style.scss b/client/src/components/published-time/style.scss
new file mode 100644
index 000000000..69a1f68b5
--- /dev/null
+++ b/client/src/components/published-time/style.scss
@@ -0,0 +1,5 @@
+// PublishedTime
+
+.c-published-time {
+ display: block;
+}
diff --git a/client/src/components/state-indicator/index.js b/client/src/components/state-indicator/StateIndicator.js
similarity index 100%
rename from client/src/components/state-indicator/index.js
rename to client/src/components/state-indicator/StateIndicator.js
diff --git a/client/src/config/index.js b/client/src/config/index.js
index e43c98fad..3c013de4e 100644
--- a/client/src/config/index.js
+++ b/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' }
+];
diff --git a/client/template/component.mst b/client/template/component.mst
index 3fedd86bb..2ad2f46ac 100644
--- a/client/template/component.mst
+++ b/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 (
+
+
+ );
+};
- render() {
- return (
-
-
- );
- }
-}
+{{ name }}.propTypes = {
+};
+
+export default {{ name }};
diff --git a/client/template/component.test.mst b/client/template/component.test.mst
new file mode 100644
index 000000000..7cbd1ef4c
--- /dev/null
+++ b/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()).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);
+ });
+});
diff --git a/client/tests/components/Icon.test.js b/client/tests/components/Icon.test.js
new file mode 100644
index 000000000..ae0913825
--- /dev/null
+++ b/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().is('.icon.icon-test')).to.equal(true);
+ });
+
+ it('has additional classes if specified', () => {
+ expect(shallow().prop('className')).to.contain('icon-red icon-big');
+ });
+});
diff --git a/client/tests/components/explorer.test.js b/client/tests/components/explorer.test.js
index 88d17dcda..d8727a534 100644
--- a/client/tests/components/explorer.test.js
+++ b/client/tests/components/explorer.test.js
@@ -1,10 +1,39 @@
-/*eslint-disable */
+import React from 'react';
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', () => {
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().find('.c-explorer__meta')).to.have.lengthOf(1);
+ });
+
+ it('metadata contains item type', () => {
+ expect(shallow().find('.c-explorer__meta').text()).to.contain('Foo');
+ });
+ });
});
diff --git a/client/tests/stubs.js b/client/tests/stubs.js
new file mode 100644
index 000000000..709deee1f
--- /dev/null
+++ b/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';
diff --git a/package.json b/package.json
index 0bb3da464..f195d6135 100644
--- a/package.json
+++ b/package.json
@@ -20,9 +20,12 @@
"babel-preset-es2015": "^6.5.0",
"babel-preset-react": "^6.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",
"gulp": "~3.8.11",
"gulp-autoprefixer": "~3.0.2",
@@ -34,17 +37,26 @@
"lodash": "^4.5.1",
"mocha": "^2.4.5",
"mustache": "^2.2.1",
+ "react-addons-test-utils": "^0.14.8",
"redux-devtools": "^3.1.1",
"require-dir": "^0.3.0",
"sinon": "^1.17.3"
},
"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"
},
@@ -57,6 +69,7 @@
"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",
"component": "node ./client/src/cli/index.js component --dir ./client/src/components/"
}
diff --git a/wagtail/wagtailadmin/static_src/wagtailadmin/app/wagtailadmin.entry.js b/wagtail/wagtailadmin/static_src/wagtailadmin/app/wagtailadmin.entry.js
index 3f8831f96..f1f79fd42 100644
--- a/wagtail/wagtailadmin/static_src/wagtailadmin/app/wagtailadmin.entry.js
+++ b/wagtail/wagtailadmin/static_src/wagtailadmin/app/wagtailadmin.entry.js
@@ -1,6 +1,14 @@
+import 'babel-polyfill';
import React from 'react';
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 => {
@@ -8,16 +16,32 @@ document.addEventListener('DOMContentLoaded', e => {
const div = document.createElement('div');
const trigger = document.querySelector('[data-explorer-menu-url]');
- trigger.addEventListener('click', (e) => {
- e.preventDefault();
- e.stopPropagation();
-
- if (!div.childNodes.length) {
- ReactDOM.render(, div);
- } else {
- ReactDOM.unmountComponentAtNode(div);
- }
- });
+ let rect = trigger.getBoundingClientRect();
+ let triggerParent = trigger.parentNode;
+ let label = trigger.innerText;
top.parentNode.appendChild(div);
+
+ const loggerMiddleware = createLogger();
+
+ const store = createStore(
+ rootReducer,
+ applyMiddleware(loggerMiddleware, thunkMiddleware)
+ );
+
+ ReactDOM.render((
+
+
+
+ ),
+ triggerParent
+ );
+
+ ReactDOM.render(
+
+
+ ,
+ div
+ );
+
});
diff --git a/wagtail/wagtailadmin/static_src/wagtailadmin/js/explorer-menu.js b/wagtail/wagtailadmin/static_src/wagtailadmin/js/explorer-menu.js
index 7b837b083..b473ba3f8 100644
--- a/wagtail/wagtailadmin/static_src/wagtailadmin/js/explorer-menu.js
+++ b/wagtail/wagtailadmin/static_src/wagtailadmin/js/explorer-menu.js
@@ -3,7 +3,7 @@ $(function() {
var $body = $('body');
// Dynamically load menu on request.
- $(document).on('click', '.dl-trigger', function() {
+ $(document).on('click', '.dl-trigger--unused', function() {
var $this = $(this);
// Close all submenus
diff --git a/wagtail/wagtailadmin/static_src/wagtailadmin/scss/components/_main-nav.scss b/wagtail/wagtailadmin/static_src/wagtailadmin/scss/components/_main-nav.scss
index da2ec2c7d..3c155f4be 100644
--- a/wagtail/wagtailadmin/static_src/wagtailadmin/scss/components/_main-nav.scss
+++ b/wagtail/wagtailadmin/static_src/wagtailadmin/scss/components/_main-nav.scss
@@ -292,6 +292,7 @@ body.explorer-open {
height: 100%;
position: fixed;
width: $menu-width;
+ z-index: 26;
}
}
diff --git a/wagtail/wagtailadmin/static_src/wagtailadmin/scss/core.scss b/wagtail/wagtailadmin/static_src/wagtailadmin/scss/core.scss
index 6ab611a22..79c1ec22b 100644
--- a/wagtail/wagtailadmin/static_src/wagtailadmin/scss/core.scss
+++ b/wagtail/wagtailadmin/static_src/wagtailadmin/scss/core.scss
@@ -27,6 +27,33 @@
}
// 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 {
background: $color-grey-4;
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 {
@include unlist();
@@ -526,3 +555,201 @@ 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:
+//
+//
+//
+// 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%);
+ }
+}
diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/admin_base.html b/wagtail/wagtailadmin/templates/wagtailadmin/admin_base.html
index 6283ed3c1..afd1b2272 100644
--- a/wagtail/wagtailadmin/templates/wagtailadmin/admin_base.html
+++ b/wagtail/wagtailadmin/templates/wagtailadmin/admin_base.html
@@ -15,6 +15,19 @@
{% endblock %}
{% block js %}
+
@@ -27,6 +40,11 @@
{% hook_output 'insert_global_admin_js' %}
+
+
+
+
+
{% main_nav_js %}
{% block extra_js %}{% endblock %}