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 %}