First version of the explorer on top of admin API

This commit is contained in:
Josh Barr 2016-02-26 23:10:54 +02:00 committed by Thibaud Colas
parent ded36c5634
commit d675807cf8
43 changed files with 1888 additions and 174 deletions

View file

@ -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"
}

View file

@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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');

View file

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

View file

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

View file

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

View file

@ -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;
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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 };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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' }
];

View file

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

View file

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

View file

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

View file

@ -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(<ExplorerItem {...props} />).find('.c-explorer__meta')).to.have.lengthOf(1);
});
it('metadata contains item type', () => {
expect(shallow(<ExplorerItem {...props} typeName="Foo" />).find('.c-explorer__meta').text()).to.contain('Foo');
});
});
});

12
client/tests/stubs.js Normal file
View file

@ -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';

View file

@ -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/"
}

View file

@ -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(<Explorer position={trigger.getBoundingClientRect()} />, div);
} else {
ReactDOM.unmountComponentAtNode(div);
}
});
let rect = trigger.getBoundingClientRect();
let triggerParent = trigger.parentNode;
let label = trigger.innerText;
top.parentNode.appendChild(div);
const loggerMiddleware = createLogger();
const store = createStore(
rootReducer,
applyMiddleware(loggerMiddleware, thunkMiddleware)
);
ReactDOM.render((
<Provider store={store}>
<ExplorerToggle label={label} />
</Provider>
),
triggerParent
);
ReactDOM.render(
<Provider store={store}>
<Explorer type={'sidebar'} top={0} left={rect.right} defaultPage={1} />
</Provider>,
div
);
});

View file

@ -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

View file

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

View file

@ -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:
//
// <div class="modal modal--active">
// <div class="modal__table">
// <div class="modal__center">
// <div class="modal__content">
// Hello!
// </div>
// </div>
// </div>
// </div>
//
// Requires '_animations.scss';
$z-index-modal: 1;
$z-index-modal-matte: 2;
$z-index-modal-content: 3;
$color-modal-close-bg: #333;
$color-modal-close-text: #fff;
$color-modal-content-bg: #fff;
$color-black-opacity-093: rgba(255, 255, 255, .93);
$color-dark-grey: #222;
*/
.u-body-modal-active {
overflow: hidden;
}
.modal {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
animation: modal-in .15s ease-out 0s backwards;
}
.modal--active {
display: block;
}
.modal--exit {
animation: modal-out .4s ease-out .4s forwards;
}
.modal--exit .modal__content {
animation: affordance-out .4s ease-in 0s forwards;
}
.modal--exit .modal__close {
animation: affordance-out-right .4s ease-in 0s forwards;
}
.modal__overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
background: rgba(0, 0, 0, .93);
}
.modal__table {
display: table;
position: relative;
width: 100%;
height: 100%;
vertical-align: middle;
}
.modal__center {
display: table-cell;
text-align: center;
vertical-align: middle;
animation: modal-in .15s ease-out .25s backwards;
}
.modal__content {
display: inline-block;
position: relative;
z-index: 3;
max-width: 32em;
min-width: 10.5em;
min-height: 6em;
padding: 1em 2em;
background: #fff;
animation: affordance-in .5s cubic-bezier(.075, .82, .165, 0) .3s backwards;
}
.modal__close {
position: absolute;
top: 0;
right: 0;
z-index: 3;
padding: .9rem 1.35rem 1.1rem;
font-size: 2em;
line-height: 1;
color: #fff;
cursor: pointer;
background: #333;
animation: affordance-in-right .5s cubic-bezier(.075, .82, .165, 0) .25s backwards;
}
.modal__close:hover,
.modal__close:active {
color: #fff;
background: #222;
}
/**
* Animation keyframes
*/
@keyframes modal-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes modal-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes affordance-in {
0% {
opacity: 0;
transform: translateY(5%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes affordance-out {
0% {
opacity: 1;
transform: translateY(0%);
}
100% {
opacity: 0;
transform: translateY(5%);
}
}
@keyframes affordance-in-right {
0% {
opacity: 0;
transform: translateX(100%);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
@keyframes affordance-out-right {
0% {
opacity: 1;
transform: translateX(0%);
}
100% {
opacity: 0;
transform: translateX(100%);
}
}

View file

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