Update explorer for latest scope, UI, with tests

This commit is contained in:
Janneke Janssen 2017-02-12 16:29:56 +01:00 committed by Thibaud Colas
parent a0e4b0bafa
commit 2ff4a5aad1
116 changed files with 10216 additions and 9203 deletions

View file

@ -3,5 +3,13 @@
"env": {
"jest": true
},
"settings": {
"import/resolver": {
"webpack": {
"config": "client/webpack/prod.config.js"
}
}
}
}

View file

@ -1 +1,4 @@
@import '../src/components/explorer/style';
@import '../src/components/Transition/Transition';
@import '../src/components/LoadingSpinner/LoadingSpinner';
@import '../src/components/PublicationStatus/PublicationStatus';
@import '../src/components/Explorer/Explorer';

View file

@ -1 +1,7 @@
@import 'objects/o.icon';
.o-pill {
display: inline-block;
padding: .2em .5em;
border-radius: .25em;
vertical-align: middle;
line-height: 1.5;
}

View file

@ -0,0 +1,14 @@
$breakpoint-small: $breakpoint-mobile - 0.0625em;
$breakpoint-medium: $breakpoint-mobile;
@mixin small {
@media only screen and (max-width: $breakpoint-small) {
@content;
}
}
@mixin medium {
@media only screen and (min-width: $breakpoint-medium) {
@content;
}
}

View file

@ -0,0 +1,3 @@
.u-hidden {
display: none;
}

View file

@ -1,3 +0,0 @@
.o-icon {
display: inline-block;
}

View file

@ -1,4 +0,0 @@
.is-spinning {
}

View file

@ -2,9 +2,8 @@
// Wagtail CMS main stylesheet
// =============================================================================
@import 'tools.breakpoints';
@import 'objects';
@import 'components';
@import 'states/states';
@import 'states/animations';
@import 'utilities/utilities';
@import 'themes/themes';
@import 'utilities';

View file

@ -1,9 +0,0 @@
.u-text-center {
text-align: center;
}
@media screen and (min-width: 15em) {
.u-text-center\@sm {
text-align: center;
}
}

View file

@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`client API should crash fetching 1`] = `
Object {
"status": 500,
"statusText": "Internal Error",
}
`;
exports[`client API should fail fetching 1`] = `[Error: Internal Error]`;
exports[`client API should succeed fetching 1`] = `
Object {
"items": Array [],
"meta": Object {
"total_count": 1,
},
}
`;
exports[`client API should timeout fetching 1`] = `[Error: Response timeout]`;

View file

@ -1,22 +1,31 @@
import { get } from '../api/client';
import { ADMIN_API } from '../config/wagtail';
import { ADMIN_API } from '../config/wagtailConfig';
export const getChildPages = (id, options = {}) => {
export const getPage = (id) => {
const url = `${ADMIN_API.PAGES}${id}/`;
return get(url);
};
export const getPageChildren = (id, options = {}) => {
let url = `${ADMIN_API.PAGES}?child_of=${id}`;
if (options.fields) {
url += `&fields=${global.encodeURIComponent(options.fields.join(','))}`;
}
// Only show pages that have children for now
url += `&has_children=1`;
if (options.onlyWithChildren) {
url += '&has_children=1';
}
return get(url).then(res => res.body);
};
export const getPage = (id) => {
const url = `${ADMIN_API.PAGES}${id}/`;
return get(url).then(res => res.body);
if (options.offset) {
url += `&offset=${options.offset}`;
}
// TODO To remove once we are done testing this.
url += ADMIN_API.EXTRA_CHILDREN_PARAMETERS;
return get(url);
};

View file

@ -0,0 +1,53 @@
import { ADMIN_API } from '../config/wagtailConfig';
import { getPageChildren, getPage } from './admin';
import * as client from './client';
const stubResult = {
__types: {
test: {
verbose_name: 'Test',
},
},
items: [
{ meta: { type: 'test' } },
{ meta: { type: 'foo' } },
],
};
client.get = jest.fn(() => Promise.resolve(stubResult));
describe('admin API', () => {
describe('getPageChildren', () => {
it('works', () => {
getPageChildren(3);
expect(client.get).toBeCalledWith(`${ADMIN_API.PAGES}?child_of=3`);
});
it('#fields', () => {
getPageChildren(3, { fields: ['title', 'latest_revision_created_at'] });
// eslint-disable-next-line max-len
expect(client.get).toBeCalledWith(`${ADMIN_API.PAGES}?child_of=3&fields=title%2Clatest_revision_created_at`);
});
it('#onlyWithChildren', () => {
getPageChildren(3, { onlyWithChildren: true });
expect(client.get).toBeCalledWith(`${ADMIN_API.PAGES}?child_of=3&has_children=1`);
});
it('#offset', () => {
getPageChildren(3, { offset: 5 });
expect(client.get).toBeCalledWith(`${ADMIN_API.PAGES}?child_of=3&offset=5`);
});
});
describe('getPage', () => {
it('should return a result by with a default id argument', () => {
getPage(3);
expect(client.get).toBeCalledWith(`${ADMIN_API.PAGES}3/`);
});
});
afterEach(() => {
client.get.mockClear();
});
});

View file

@ -1,39 +1,57 @@
import _ from 'lodash';
const fetch = global.fetch;
const Headers = global.Headers;
// fetch wrapper for JSON APIs.
export const get = (url) => {
const headers = new Headers({
'Accept': 'application/json',
'Content-Type': 'application/json',
const REQUEST_TIMEOUT = 15000;
const checkStatus = (response) => {
if (response.status >= 200 && response.status < 300) {
return response;
}
const error = new Error(response.statusText);
throw error;
};
const parseJSON = response => response.json();
// Response timeout cancelling the promise (not the request).
// See https://github.com/github/fetch/issues/175#issuecomment-216791333.
const timeout = (ms, promise) => {
const race = new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('Response timeout'));
}, ms);
promise.then((res) => {
clearTimeout(timeoutId);
resolve(res);
}, (err) => {
clearTimeout(timeoutId);
reject(err);
});
});
return race;
};
/**
* Wrapper around fetch with sane defaults for behavior in the face of
* errors.
*/
const request = (method, url) => {
const options = {
credentials: 'same-origin',
headers: headers,
method: 'GET'
headers: new Headers({
'Accept': 'application/json',
'Content-Type': 'application/json',
}),
method: method
};
return fetch(url, options)
.then((res) => {
const response = {
status: res.status,
statusText: res.statusText,
headers: res.headers
};
let ret;
if (response.status >= 200 && response.status < 300) {
ret = res.json().then(json => _.assign(response, { body: json }));
} else {
ret = res.text().then((text) => {
const err = _.assign(new Error(response.statusText), response, { body: text });
throw err;
});
}
return ret;
});
return timeout(REQUEST_TIMEOUT, fetch(url, options))
.then(checkStatus)
.then(parseJSON);
};
export const get = url => request('GET', url);

View file

@ -0,0 +1,43 @@
import * as client from './client';
describe('client API', () => {
it('should succeed fetching', (done) => {
const response = '{"meta":{"total_count":1},"items":[]}';
fetch.mockResponseSuccess(response);
client.get('/example/url').then((result) => {
expect(result).toMatchSnapshot();
done();
});
});
it('should fail fetching', (done) => {
fetch.mockResponseFailure();
client.get('/example/url').catch((result) => {
expect(result).toMatchSnapshot();
done();
});
});
it('should crash fetching', (done) => {
fetch.mockResponseCrash();
client.get('/example/url').catch((result) => {
expect(result).toMatchSnapshot();
done();
});
});
it('should timeout fetching', (done) => {
jest.useFakeTimers();
fetch.mockResponseTimeout();
client.get('/example/url').catch((result) => {
expect(result).toMatchSnapshot();
done();
});
jest.runOnlyPendingTimers();
});
});

View file

@ -1,23 +0,0 @@
import React from 'react';
import moment from 'moment';
import { DATE_FORMAT, STRINGS } from '../../config/wagtail';
const AbsoluteDate = ({ time }) => {
const date = moment(time);
const text = time ? date.format(DATE_FORMAT) : STRINGS.NO_DATE;
return (
<span>{text}</span>
);
};
AbsoluteDate.propTypes = {
time: React.PropTypes.string,
};
AbsoluteDate.defaultProps = {
time: '',
};
export default AbsoluteDate;

View file

@ -1,18 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import AbsoluteDate from './AbsoluteDate';
describe('AbsoluteDate', () => {
it('exists', () => {
expect(AbsoluteDate).toBeDefined();
});
it('basic', () => {
expect(shallow(<AbsoluteDate />)).toMatchSnapshot();
});
it('#time', () => {
expect(shallow(<AbsoluteDate time="2016-09-19T20:22:33.356623Z" />)).toMatchSnapshot();
});
});

View file

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

View file

@ -1,88 +1,89 @@
import React from 'react';
import _ from 'lodash';
const getClassName = (className, icon) => {
const hasIcon = icon !== '';
let iconName = '';
if (hasIcon) {
if (typeof icon === 'string') {
iconName = ` icon-${icon}`;
} else {
iconName = icon.map(val => ` icon-${val}`).join('');
}
}
return `${className} ${hasIcon ? 'icon' : ''}${iconName}`;
};
const handleClick = (href, onClick, preventDefault, e) => {
if (preventDefault && href === '#') {
e.preventDefault();
e.stopPropagation();
}
if (onClick) {
onClick(e);
}
};
/**
* A reusable button. Uses a <a> tag underneath.
*/
export default React.createClass({
propTypes: {
href: React.PropTypes.string,
className: React.PropTypes.string,
icon: React.PropTypes.string,
target: React.PropTypes.string,
children: React.PropTypes.node,
accessibleLabel: React.PropTypes.string,
onClick: React.PropTypes.func,
isLoading: React.PropTypes.bool,
preventDefault: React.PropTypes.bool,
},
const Button = ({
className,
icon,
children,
accessibleLabel,
isLoading,
href,
target,
preventDefault,
onClick,
}) => {
const hasText = children !== null;
const iconName = isLoading ? 'spinner' : icon;
const accessibleElt = accessibleLabel ? (
<span className="visuallyhidden">
{accessibleLabel}
</span>
) : null;
getDefaultProps() {
return {
href: '#',
className: '',
icon: '',
target: null,
children: null,
accessibleLabel: null,
onClick: null,
isLoading: false,
preventDefault: true,
};
},
return (
<a
className={getClassName(className, iconName)}
onClick={handleClick.bind(null, href, onClick, preventDefault)}
rel={target === '_blank' ? 'noopener noreferrer' : null}
href={href}
target={target}
>
{hasText ? children : accessibleElt}
</a>
);
};
handleClick(e) {
const { href, onClick, preventDefault } = this.props;
Button.propTypes = {
href: React.PropTypes.string,
className: React.PropTypes.string,
icon: React.PropTypes.oneOfType([
React.PropTypes.string,
React.PropTypes.arrayOf(React.PropTypes.string),
]),
target: React.PropTypes.string,
children: React.PropTypes.node,
accessibleLabel: React.PropTypes.string,
onClick: React.PropTypes.func,
isLoading: React.PropTypes.bool,
preventDefault: React.PropTypes.bool,
};
if (preventDefault && href === '#') {
e.preventDefault();
e.stopPropagation();
}
Button.defaultProps = {
href: '#',
className: '',
icon: '',
target: null,
children: null,
accessibleLabel: null,
onClick: null,
isLoading: false,
preventDefault: true,
};
if (onClick) {
onClick(e);
}
},
render() {
const {
className,
icon,
children,
accessibleLabel,
isLoading,
target,
} = this.props;
const props = _.omit(this.props, [
'className',
'icon',
'iconClassName',
'children',
'accessibleLabel',
'isLoading',
'onClick',
'preventDefault',
]);
const hasIcon = icon !== '';
const hasText = children !== null;
const iconName = isLoading ? 'spinner' : icon;
const accessibleElt = accessibleLabel ? (
<span className="visuallyhidden">
{accessibleLabel}
</span>
) : null;
return (
<a
className={`${className} ${hasIcon ? 'icon icon-' : ''}${iconName}`}
onClick={this.handleClick}
rel={target === '_blank' ? 'noopener' : null}
{...props}
>
{hasText ? children : accessibleElt}
</a>
);
},
});
export default Button;

View file

@ -24,6 +24,14 @@ describe('Button', () => {
expect(shallow(<Button icon="test-icon" />)).toMatchSnapshot();
});
it('#target', () => {
expect(shallow(<Button target="_blank" />)).toMatchSnapshot();
});
it('#multiple icons', () => {
expect(shallow(<Button icon={['test-icon', 'secondary-icon']} />)).toMatchSnapshot();
});
it('#icon changes with #isLoading', () => {
expect(shallow(<Button icon="test-icon" isLoading={true} />)).toMatchSnapshot();
});
@ -36,4 +44,13 @@ describe('Button', () => {
});
expect(onClick).toHaveBeenCalledTimes(1);
});
it('dismisses clicks', () => {
const preventDefault = jest.fn();
shallow(<Button />).simulate('click', {
preventDefault,
stopPropagation() {},
});
expect(preventDefault).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,12 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Button #accessibleLabel 1`] = `
<a
className=" "
href="#"
onClick={[Function]}
rel={null}
target={null}>
target={null}
>
<span
className="visuallyhidden">
className="visuallyhidden"
>
I am here in the shadows
</span>
</a>
@ -18,7 +22,8 @@ exports[`Button #children 1`] = `
href="#"
onClick={[Function]}
rel={null}
target={null}>
target={null}
>
To infinity and beyond!
</a>
`;
@ -29,7 +34,8 @@ exports[`Button #icon 1`] = `
href="#"
onClick={[Function]}
rel={null}
target={null} />
target={null}
/>
`;
exports[`Button #icon changes with #isLoading 1`] = `
@ -38,7 +44,28 @@ exports[`Button #icon changes with #isLoading 1`] = `
href="#"
onClick={[Function]}
rel={null}
target={null} />
target={null}
/>
`;
exports[`Button #multiple icons 1`] = `
<a
className=" icon icon-test-icon icon-secondary-icon"
href="#"
onClick={[Function]}
rel={null}
target={null}
/>
`;
exports[`Button #target 1`] = `
<a
className=" "
href="#"
onClick={[Function]}
rel="noopener noreferrer"
target="_blank"
/>
`;
exports[`Button basic 1`] = `
@ -47,5 +74,6 @@ exports[`Button basic 1`] = `
href="#"
onClick={[Function]}
rel={null}
target={null} />
target={null}
/>
`;

View file

@ -0,0 +1,52 @@
import React from 'react';
import { connect } from 'react-redux';
import * as actions from './actions';
import ExplorerPanel from './ExplorerPanel';
const Explorer = ({
isVisible,
nodes,
path,
pushPage,
popPage,
onClose,
}) => {
const page = nodes[path[path.length - 1]];
return isVisible ? (
<ExplorerPanel
path={path}
page={page}
nodes={nodes}
onClose={onClose}
popPage={popPage}
pushPage={pushPage}
/>
) : null;
};
Explorer.propTypes = {
isVisible: React.PropTypes.bool.isRequired,
path: React.PropTypes.array.isRequired,
nodes: React.PropTypes.object.isRequired,
pushPage: React.PropTypes.func.isRequired,
popPage: React.PropTypes.func.isRequired,
onClose: React.PropTypes.func.isRequired,
};
const mapStateToProps = (state) => ({
isVisible: state.explorer.isVisible,
path: state.explorer.path,
nodes: state.nodes,
});
const mapDispatchToProps = (dispatch) => ({
pushPage: (id) => dispatch(actions.pushPage(id)),
popPage: () => dispatch(actions.popPage()),
onClose: () => dispatch(actions.closeExplorer()),
});
export default connect(mapStateToProps, mapDispatchToProps)(Explorer);

View file

@ -0,0 +1,124 @@
$c-explorer-bg: #4C4E4D;
$c-explorer-bg-dark: $color-grey-1;
$c-explorer-bg-active: rgba(0,0,0,0.425);
$c-explorer-secondary: #a5a5a5;
$c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
@import 'ExplorerItem';
.c-explorer,
.c-explorer * {
box-sizing: border-box;
}
.c-explorer {
position: relative;
overflow: hidden;
height: 100vh;
background: $c-explorer-bg;
@include medium {
box-shadow: 2px 2px 5px $c-explorer-bg-active;
}
}
.c-explorer > .c-transition-group {
display: flex;
flex-direction: column;
height: 100%;
z-index: 150;
}
.c-explorer__close {
padding: 1em;
color: $c-explorer-secondary;
border-bottom: 1px solid rgba(200, 200, 200, 0.1);
cursor: pointer;
&:focus {
background-color: $c-explorer-bg-active;
color: $color-white;
outline: none;
}
// Overrides for default link hover.
&:hover {
color: $c-explorer-secondary;
}
@include small {
.explorer-open & {
display: block;
}
}
}
.c-explorer__drawer {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.c-explorer__header {
display: block;
height: 50px;
background-color: $c-explorer-bg-dark;
border-bottom: 1px solid $c-explorer-bg-dark;
color: $color-white;
&:focus {
background-color: $c-explorer-bg-active;
color: $color-white;
outline: none;
}
// Overrides for default link hover.
&:hover {
color: $color-white;
}
@include hover {
background-color: $c-explorer-bg-active;
}
}
.c-explorer__header__inner {
padding: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.icon {
color: $c-explorer-secondary;
margin-right: .25rem;
font-size: 1rem;
}
}
.c-explorer__placeholder {
padding: 1rem;
color: $color-white;
}
.c-explorer__see-more {
display: block;
padding: 1rem;
height: 50px;
background: rgba(0,0,0,0.3);
color: $color-white;
&:focus {
color: $c-explorer-secondary;
background: $c-explorer-bg-active;
outline: none;
}
// Overrides for default link hover.
&:hover {
color: $color-white;
}
@include hover {
background: $c-explorer-bg-active;
}
}

View file

@ -1,14 +1,57 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunkMiddleware from 'redux-thunk';
import * as actions from './actions';
import explorer from './reducers/explorer';
import nodes from './reducers/nodes';
import Explorer from './Explorer';
const mockProps = {
const rootReducer = combineReducers({
explorer,
nodes,
});
};
const store = createStore(rootReducer, {}, applyMiddleware(thunkMiddleware));
describe('Explorer', () => {
it('exists', () => {
expect(Explorer).toBeDefined();
});
it('renders', () => {
expect(shallow(<Explorer store={store} />)).toMatchSnapshot();
expect(shallow(<Provider store={store}><Explorer /></Provider>)).toMatchSnapshot();
});
it('visible', () => {
store.dispatch(actions.toggleExplorer(1));
expect(shallow(<Explorer store={store} />)).toMatchSnapshot();
expect(shallow(<Explorer store={store} />).dive()).toMatchSnapshot();
});
describe('actions', () => {
let wrapper;
beforeEach(() => {
store.dispatch = jest.fn();
wrapper = shallow(<Explorer store={store} />);
});
it('pushPage', () => {
wrapper.prop('pushPage')();
expect(store.dispatch).toHaveBeenCalled();
});
it('popPage', () => {
wrapper.prop('popPage')();
expect(store.dispatch).toHaveBeenCalled();
});
it('onClose', () => {
wrapper.prop('onClose')();
expect(store.dispatch).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,37 @@
import React from 'react';
import { ADMIN_URLS, STRINGS } from '../../config/wagtailConfig';
import Button from '../../components/Button/Button';
import Icon from '../../components/Icon/Icon';
/**
* The bar at the top of the explorer, displaying the current level
* and allowing access back to the parent level.
*/
const ExplorerHeader = ({ page, depth, onClick }) => {
const isRoot = depth === 1;
return (
<Button
href={page.id ? `${ADMIN_URLS.PAGES}${page.id}/` : ADMIN_URLS.PAGES}
className="c-explorer__header"
onClick={onClick}
>
<div className="c-explorer__header__inner">
<Icon name={isRoot ? 'home' : 'arrow-left'} />
<span>{page.title || STRINGS.PAGES}</span>
</div>
</Button>
);
};
ExplorerHeader.propTypes = {
page: React.PropTypes.shape({
id: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]),
title: React.PropTypes.string,
}).isRequired,
depth: React.PropTypes.number.isRequired,
onClick: React.PropTypes.func.isRequired,
};
export default ExplorerHeader;

View file

@ -0,0 +1,36 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import ExplorerHeader from './ExplorerHeader';
const mockProps = {
page: {},
depth: 2,
transitionName: 'pop',
onClick: jest.fn(),
};
describe('ExplorerHeader', () => {
it('exists', () => {
expect(ExplorerHeader).toBeDefined();
});
it('basic', () => {
expect(shallow(<ExplorerHeader {...mockProps} />)).toMatchSnapshot();
});
it('#depth at root', () => {
expect(shallow(<ExplorerHeader {...mockProps} depth={1} />)).toMatchSnapshot();
});
it('#page', () => {
expect(shallow(<ExplorerHeader {...mockProps} page={{ id: 'a', title: 'test' }} />)).toMatchSnapshot();
});
it('#onClick', () => {
const wrapper = mount(<ExplorerHeader {...mockProps} />);
wrapper.find('Button').simulate('click');
expect(mockProps.onClick).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,59 @@
import React from 'react';
import { ADMIN_URLS, STRINGS } from '../../config/wagtailConfig';
import Icon from '../../components/Icon/Icon';
import Button from '../../components/Button/Button';
import PublicationStatus from '../../components/PublicationStatus/PublicationStatus';
const ExplorerItem = ({ item, onClick }) => {
const { id, title, meta } = item;
const hasChildren = meta.children.count > 0;
const isPublished = meta.status.live && !meta.status.has_unpublished_changes;
return (
<div className="c-explorer__item">
<Button href={`${ADMIN_URLS.PAGES}${id}/`} className="c-explorer__item__link">
{hasChildren ? (
<Icon name="folder-inverse" className={'c-explorer__children'} />
) : null}
<h3 className="c-explorer__item__title">
{title}
</h3>
{!isPublished ? (
<span className="c-explorer__meta">
<PublicationStatus status={meta.status} />
</span>
) : null}
</Button>
<Button
href={`${ADMIN_URLS.PAGES}${id}/edit/`}
className="c-explorer__item__action c-explorer__item__action--small"
>
<Icon name="edit" title={`${STRINGS.EDIT} '${title}'`} />
</Button>
{hasChildren ? (
<Button
className="c-explorer__item__action"
onClick={onClick}
>
<Icon name="arrow-right" title={STRINGS.SEE_CHILDREN} />
</Button>
) : null}
</div>
);
};
ExplorerItem.propTypes = {
item: React.PropTypes.shape({
id: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]).isRequired,
title: React.PropTypes.string.isRequired,
meta: React.PropTypes.shape({
status: React.PropTypes.object.isRequired,
}).isRequired,
}).isRequired,
onClick: React.PropTypes.func.isRequired,
};
export default ExplorerItem;

View file

@ -0,0 +1,85 @@
.c-explorer__item {
display: flex;
flex-flow: row nowrap;
border-bottom: 1px solid $c-explorer-bg-dark;
}
.c-explorer__item__link {
display: inline-flex;
align-items: center;
flex-grow: 1;
padding: 1.45em 1.75em;
cursor: pointer;
&:focus {
background: $c-explorer-bg-active;
color: $color-white;
outline: none;
}
// Overrides for default link hover.
&:hover {
color: $color-white;
}
@include hover {
background: $c-explorer-bg-active;
}
}
.c-explorer__item__link .icon {
font-size: 2em;
color: $c-explorer-secondary;
margin-right: 0.5rem;
}
.c-explorer__item__title {
margin: 0;
color: $color-white;
display: inline-block;
}
.c-explorer__item__action {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 50px;
padding: 0 .5em;
line-height: 1;
font-size: 2em;
cursor: pointer;
color: $c-explorer-secondary;
border: 0;
border-left: solid 1px $c-explorer-bg-dark;
&:focus {
background: $c-explorer-bg-active;
color: $color-white;
outline: none;
}
// Overrides for default link hover.
&:hover {
color: $c-explorer-secondary;
}
@include hover {
background: $c-explorer-bg-active;
color: $color-white;
}
.icon:before {
margin-right: 0;
}
}
.c-explorer__item__action--small {
font-size: 1.2em;
}
.c-explorer__meta {
margin-left: 0.5rem;
color: $c-explorer-secondary;
font-size: 12px;
}

View file

@ -4,13 +4,25 @@ import { shallow } from 'enzyme';
import ExplorerItem from './ExplorerItem';
const mockProps = {
data: {
item: {
id: 5,
title: 'test',
meta: {
latest_revision_created_at: null,
status: {
live: true,
status: 'test',
has_unpublished_changes: false,
},
descendants: {
count: 0,
},
children: {
count: 0,
}
}
},
},
onClick: () => {},
};
describe('ExplorerItem', () => {
@ -18,15 +30,25 @@ describe('ExplorerItem', () => {
expect(ExplorerItem).toBeDefined();
});
it('basic', () => {
expect(shallow(<ExplorerItem />)).toMatchSnapshot();
});
it('#data', () => {
it('renders', () => {
expect(shallow(<ExplorerItem {...mockProps} />)).toMatchSnapshot();
});
it('#typeName', () => {
expect(shallow(<ExplorerItem {...mockProps} typeName="Foo" />)).toMatchSnapshot();
it('children', () => {
const props = Object.assign({}, mockProps);
props.item.meta.children.count = 5;
expect(shallow(<ExplorerItem {...props} />)).toMatchSnapshot();
});
it('should show a publication status with unpublished changes', () => {
const props = Object.assign({}, mockProps);
props.item.meta.status.has_unpublished_changes = true;
expect(shallow(<ExplorerItem {...props} />)).toMatchSnapshot();
});
it('should show a publication status if not live', () => {
const props = Object.assign({}, mockProps);
props.item.meta.status.live = false;
expect(shallow(<ExplorerItem {...props} />)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,175 @@
import React from 'react';
import FocusTrap from 'focus-trap-react';
import { STRINGS, MAX_EXPLORER_PAGES } from '../../config/wagtailConfig';
import Button from '../Button/Button';
import LoadingSpinner from '../LoadingSpinner/LoadingSpinner';
import Transition, { PUSH, POP, FADE } from '../Transition/Transition';
import ExplorerHeader from './ExplorerHeader';
import ExplorerItem from './ExplorerItem';
import PageCount from './PageCount';
export default class ExplorerPanel extends React.Component {
constructor(props) {
super(props);
this.state = {
transition: PUSH,
paused: false,
};
this.onItemClick = this.onItemClick.bind(this);
this.onHeaderClick = this.onHeaderClick.bind(this);
this.clickOutside = this.clickOutside.bind(this);
}
componentWillReceiveProps(newProps) {
const { path } = this.props;
const isPush = newProps.path.length > path.length;
this.setState({
transition: isPush ? PUSH : POP,
});
}
componentDidMount() {
document.querySelector('[data-explorer-menu-item]').classList.add('submenu-active');
document.body.classList.add('explorer-open');
document.addEventListener('mousedown', this.clickOutside);
document.addEventListener('touchstart', this.clickOutside);
}
componentWillUnmount() {
document.querySelector('[data-explorer-menu-item]').classList.remove('submenu-active');
document.body.classList.remove('explorer-open');
document.removeEventListener('mousedown', this.clickOutside);
document.removeEventListener('touchstart', this.clickOutside);
}
clickOutside(e) {
const { onClose } = this.props;
const explorer = document.querySelector('[data-explorer-menu]');
const toggle = document.querySelector('[data-explorer-menu-item]');
const isInside = explorer.contains(e.target) || toggle.contains(e.target);
if (!isInside) {
onClose();
}
if (toggle.contains(e.target)) {
this.setState({
paused: true,
});
}
}
onItemClick(id, e) {
const { pushPage } = this.props;
e.preventDefault();
e.stopPropagation();
pushPage(id);
}
onHeaderClick(e) {
const { path, popPage } = this.props;
const hasBack = path.length > 1;
if (hasBack) {
e.preventDefault();
e.stopPropagation();
popPage();
}
}
renderChildren() {
const { page, nodes } = this.props;
let children;
if (!page.isFetching && !page.children.items) {
children = (
<div key="empty" className="c-explorer__placeholder">
{STRINGS.NO_RESULTS}
</div>
);
} else {
children = (
<div key="children">
{page.children.items.map((id) => (
<ExplorerItem
key={id}
item={nodes[id]}
onClick={this.onItemClick.bind(null, id)}
/>
))}
</div>
);
}
return (
<div className="c-explorer__drawer">
{children}
{page.isFetching ? (
<div key="fetching" className="c-explorer__placeholder">
<LoadingSpinner />
</div>
) : null}
{page.isError ? (
<div key="error" className="c-explorer__placeholder">
{STRINGS.SERVER_ERROR}
</div>
) : null}
</div>
);
}
render() {
const { page, onClose, path } = this.props;
const { transition, paused } = this.state;
return (
<FocusTrap
tag="nav"
className="explorer"
paused={paused || !page || page.isFetching}
focusTrapOptions={{ onDeactivate: onClose }}
>
<Button className="c-explorer__close u-hidden" onClick={onClose}>
{STRINGS.CLOSE_EXPLORER}
</Button>
<Transition name={transition} className="c-explorer">
<div key={path.length} className="c-transition-group">
<ExplorerHeader
depth={path.length}
page={page}
onClick={this.onHeaderClick}
/>
{this.renderChildren()}
{page.isError || page.children.items && page.children.count > MAX_EXPLORER_PAGES ? (
<PageCount page={page} />
) : null}
</div>
</Transition>
</FocusTrap>
);
}
}
ExplorerPanel.propTypes = {
nodes: React.PropTypes.object.isRequired,
path: React.PropTypes.array,
page: React.PropTypes.shape({
isFetching: React.PropTypes.bool,
children: React.PropTypes.shape({
items: React.PropTypes.array,
}),
}),
onClose: React.PropTypes.func.isRequired,
popPage: React.PropTypes.func.isRequired,
pushPage: React.PropTypes.func.isRequired,
};

View file

@ -0,0 +1,182 @@
import React from 'react';
import { shallow } from 'enzyme';
import ExplorerPanel from './ExplorerPanel';
const mockProps = {
page: {
children: {
items: [],
},
},
onClose: jest.fn(),
path: [],
popPage: jest.fn(),
pushPage: jest.fn(),
nodes: {},
};
describe('ExplorerPanel', () => {
it('exists', () => {
expect(ExplorerPanel).toBeDefined();
});
it('renders', () => {
expect(shallow(<ExplorerPanel {...mockProps} />)).toMatchSnapshot();
});
it('#isFetching', () => {
expect(shallow((
<ExplorerPanel
{...mockProps}
page={Object.assign({ isFetching: true }, mockProps.page)}
/>
))).toMatchSnapshot();
});
it('#isError', () => {
expect(shallow((
<ExplorerPanel
{...mockProps}
page={Object.assign({ isError: true }, mockProps.page)}
/>
))).toMatchSnapshot();
});
it('no children', () => {
expect(shallow((
<ExplorerPanel
{...mockProps}
page={{ children: {} }}
/>
))).toMatchSnapshot();
});
it('#items', () => {
expect(shallow((
<ExplorerPanel
{...mockProps}
page={{ children: { items: [1, 2] } }}
nodes={{
1: { id: 1, title: 'Test', meta: { status: {}, type: 'test' } },
2: { id: 2, title: 'Foo', meta: { status: {}, type: 'foo' } },
}}
/>
))).toMatchSnapshot();
});
describe('onHeaderClick', () => {
beforeEach(() => {
mockProps.popPage.mockReset();
});
it('calls popPage', () => {
shallow((
<ExplorerPanel {...mockProps} path={[1, 2, 3]} />
)).find('ExplorerHeader').prop('onClick')({
preventDefault() {},
stopPropagation() {},
});
expect(mockProps.popPage).toHaveBeenCalled();
});
it('does not call popPage for first page', () => {
shallow((
<ExplorerPanel {...mockProps} path={[1]} />
)).find('ExplorerHeader').prop('onClick')({
preventDefault() {},
stopPropagation() {},
});
expect(mockProps.popPage).not.toHaveBeenCalled();
});
});
describe('onItemClick', () => {
beforeEach(() => {
mockProps.pushPage.mockReset();
});
it('calls pushPage', () => {
shallow((
<ExplorerPanel
{...mockProps}
path={[1]}
page={{ children: { items: [1] } }}
nodes={{ 1: { id: 1, title: 'Test', meta: { status: {}, type: 'test' } } }}
/>
)).find('ExplorerItem').prop('onClick')({
preventDefault() {},
stopPropagation() {},
});
expect(mockProps.pushPage).toHaveBeenCalled();
});
});
describe('hooks', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(<ExplorerPanel {...mockProps} />);
});
it('componentWillReceiveProps push', () => {
expect(wrapper.setProps({ path: [1] }).state('transition')).toBe('push');
});
it('componentWillReceiveProps pop', () => {
expect(wrapper.setProps({ path: [] }).state('transition')).toBe('pop');
});
it('componentDidMount', () => {
document.body.innerHTML = '<div data-explorer-menu-item></div>';
wrapper.instance().componentDidMount();
expect(document.querySelector('[data-explorer-menu-item]').classList.contains('submenu-active')).toBe(true);
expect(document.body.classList.contains('explorer-open')).toBe(true);
});
it('componentWillUnmount', () => {
document.body.innerHTML = '<div class="submenu-active" data-explorer-menu-item></div>';
wrapper.instance().componentWillUnmount();
expect(document.querySelector('[data-explorer-menu-item]').classList.contains('submenu-active')).toBe(false);
expect(document.body.classList.contains('explorer-open')).toBe(false);
});
});
describe('clickOutside', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(<ExplorerPanel {...mockProps} />);
});
afterEach(() => {
mockProps.onClose.mockReset();
});
it('triggers onClose when click is outside', () => {
document.body.innerHTML = '<div data-explorer-menu-item></div><div data-explorer-menu></div><div id="target"></div>';
wrapper.instance().clickOutside({
target: document.querySelector('#target'),
});
expect(mockProps.onClose).toHaveBeenCalled();
});
it('does not trigger onClose when click is inside', () => {
document.body.innerHTML = '<div data-explorer-menu-item></div><div data-explorer-menu><div id="target"></div></div>';
wrapper.instance().clickOutside({
target: document.querySelector('#target'),
});
expect(mockProps.onClose).not.toHaveBeenCalled();
});
it('pauses focus trap inside toggle', () => {
document.body.innerHTML = '<div data-explorer-menu-item><div id="target"></div></div><div data-explorer-menu></div>';
wrapper.instance().clickOutside({
target: document.querySelector('#target'),
});
expect(wrapper.state('paused')).toEqual(true);
});
});
});

View file

@ -0,0 +1,36 @@
import React from 'react';
import { connect } from 'react-redux';
import * as actions from './actions';
import Button from '../../components/Button/Button';
/**
* A Button which toggles the explorer.
*/
const ExplorerToggle = ({ children, onToggle }) => (
<Button
icon={['folder-open-inverse', 'arrow-right-after']}
onClick={onToggle}
>
{children}
</Button>
);
ExplorerToggle.propTypes = {
onToggle: React.PropTypes.func.isRequired,
children: React.PropTypes.node.isRequired,
};
const mapStateToProps = () => ({});
const mapDispatchToProps = (dispatch) => ({
onToggle: (page) => dispatch(actions.toggleExplorer(page)),
});
const mergeProps = (stateProps, dispatchProps, ownProps) => ({
children: ownProps.children,
onToggle: dispatchProps.onToggle.bind(null, ownProps.startPage),
});
export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(ExplorerToggle);

View file

@ -1,11 +1,10 @@
import React from 'react';
import { createStore } from 'redux';
import { shallow } from 'enzyme';
import configureMockStore from 'redux-mock-store';
import ExplorerToggle from './ExplorerToggle';
import rootReducer from './reducers';
const store = createStore(rootReducer);
const store = configureMockStore()({});
describe('ExplorerToggle', () => {
it('exists', () => {
@ -13,19 +12,6 @@ describe('ExplorerToggle', () => {
});
it('basic', () => {
expect(shallow(<ExplorerToggle store={store} />)).toMatchSnapshot();
});
it('loading state', (done) => {
store.subscribe(() => {
expect(shallow(<ExplorerToggle store={store} />)).toMatchSnapshot();
done();
});
store.dispatch({ type: 'FETCH_START' });
});
it('#children', () => {
expect(shallow((
<ExplorerToggle store={store}>
<span>
@ -34,4 +20,18 @@ describe('ExplorerToggle', () => {
</ExplorerToggle>
))).toMatchSnapshot();
});
describe('actions', () => {
let wrapper;
beforeEach(() => {
store.dispatch = jest.fn();
wrapper = shallow(<ExplorerToggle store={store}>Test</ExplorerToggle>);
});
it('onToggle', () => {
wrapper.prop('onToggle')();
expect(store.dispatch).toHaveBeenCalled();
});
});
});

View file

@ -1,23 +1,26 @@
import React from 'react';
import { ADMIN_URLS, STRINGS } from '../../config/wagtail';
import { ADMIN_URLS, STRINGS } from '../../config/wagtailConfig';
import Icon from '../Icon/Icon';
const PageCount = ({ id, count, title }) => (
<a
href={`${ADMIN_URLS.PAGES}${id}/`}
className="c-explorer__see-more"
tabIndex={0}
>
{STRINGS.EXPLORE_ALL_IN}{' '}
<span className="c-explorer__see-more__title">{title}</span>{' '}
({count} {count !== 1 ? STRINGS.PAGES : STRINGS.PAGE})
</a>
);
const PageCount = ({ page }) => {
const count = page.children.count;
return (
<a
href={`${ADMIN_URLS.PAGES}${page.id}/`}
className="c-explorer__see-more"
tabIndex={0}
>
{STRINGS.SEE_ALL}
<span>{` ${count} ${count === 1 ? STRINGS.PAGE.toLowerCase() : STRINGS.PAGES.toLowerCase()}`}</span>
<Icon name="arrow-right" />
</a>
);
};
PageCount.propTypes = {
id: React.PropTypes.number.isRequired,
count: React.PropTypes.number.isRequired,
title: React.PropTypes.string.isRequired,
page: React.PropTypes.object.isRequired,
};
export default PageCount;

View file

@ -0,0 +1,35 @@
import React from 'react';
import { shallow } from 'enzyme';
import PageCount from './PageCount';
const mockProps = {
page: {
id: 1,
children: {
count: 1,
},
},
};
describe('PageCount', () => {
it('exists', () => {
expect(PageCount).toBeDefined();
});
it('works', () => {
expect(shallow(<PageCount {...mockProps} />)).toMatchSnapshot();
});
it('plural', () => {
const props = Object.assign({}, mockProps);
props.page.children.count = 5;
expect(shallow(<PageCount {...props} />)).toMatchSnapshot();
});
it('#title', () => {
const props = Object.assign({}, mockProps);
props.page.title = 'This is an example';
expect(shallow(<PageCount {...props} />)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,106 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Explorer renders 1`] = `
<Explorer
isVisible={false}
nodes={Object {}}
onClose={[Function]}
path={Array []}
popPage={[Function]}
pushPage={[Function]}
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
/>
`;
exports[`Explorer renders 2`] = `<Connect(Explorer) />`;
exports[`Explorer visible 1`] = `
<Explorer
isVisible={true}
nodes={
Object {
"1": Object {
"children": Object {
"count": 0,
"isFetching": true,
"items": Array [],
},
"isError": false,
"isFetching": true,
"isLoaded": true,
"meta": Object {
"children": Object {},
},
},
}
}
onClose={[Function]}
path={
Array [
1,
]
}
popPage={[Function]}
pushPage={[Function]}
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
/>
`;
exports[`Explorer visible 2`] = `
<ExplorerPanel
nodes={
Object {
"1": Object {
"children": Object {
"count": 0,
"isFetching": true,
"items": Array [],
},
"isError": false,
"isFetching": true,
"isLoaded": true,
"meta": Object {
"children": Object {},
},
},
}
}
onClose={[Function]}
page={
Object {
"children": Object {
"count": 0,
"isFetching": true,
"items": Array [],
},
"isError": false,
"isFetching": true,
"isLoaded": true,
"meta": Object {
"children": Object {},
},
}
}
path={
Array [
1,
]
}
popPage={[Function]}
pushPage={[Function]}
/>
`;

View file

@ -0,0 +1,79 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ExplorerHeader #depth at root 1`] = `
<Button
accessibleLabel={null}
className="c-explorer__header"
href="/admin/pages/"
icon=""
isLoading={false}
onClick={[Function]}
preventDefault={true}
target={null}
>
<div
className="c-explorer__header__inner"
>
<Icon
className=""
name="home"
title={null}
/>
<span>
Pages
</span>
</div>
</Button>
`;
exports[`ExplorerHeader #page 1`] = `
<Button
accessibleLabel={null}
className="c-explorer__header"
href="/admin/pages/a/"
icon=""
isLoading={false}
onClick={[Function]}
preventDefault={true}
target={null}
>
<div
className="c-explorer__header__inner"
>
<Icon
className=""
name="arrow-left"
title={null}
/>
<span>
test
</span>
</div>
</Button>
`;
exports[`ExplorerHeader basic 1`] = `
<Button
accessibleLabel={null}
className="c-explorer__header"
href="/admin/pages/"
icon=""
isLoading={false}
onClick={[Function]}
preventDefault={true}
target={null}
>
<div
className="c-explorer__header__inner"
>
<Icon
className=""
name="arrow-left"
title={null}
/>
<span>
Pages
</span>
</div>
</Button>
`;

View file

@ -1,77 +1,246 @@
exports[`ExplorerItem #data 1`] = `
<Button
accessibleLabel={null}
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ExplorerItem children 1`] = `
<div
className="c-explorer__item"
href="/admin/pages/undefined"
icon=""
isLoading={false}
onClick={null}
preventDefault={true}
target={null}>
<h3
className="c-explorer__title" />
<p
className="c-explorer__meta">
<span
className="c-explorer__meta__type" />
|
<AbsoluteDate
time="" />
|
<PublicationStatus />
</p>
</Button>
>
<Button
accessibleLabel={null}
className="c-explorer__item__link"
href="/admin/pages/5/"
icon=""
isLoading={false}
onClick={null}
preventDefault={true}
target={null}
>
<Icon
className="c-explorer__children"
name="folder-inverse"
title={null}
/>
<h3
className="c-explorer__item__title"
>
test
</h3>
</Button>
<Button
accessibleLabel={null}
className="c-explorer__item__action c-explorer__item__action--small"
href="/admin/pages/5/edit/"
icon=""
isLoading={false}
onClick={null}
preventDefault={true}
target={null}
>
<Icon
className=""
name="edit"
title="Edit 'test'"
/>
</Button>
<Button
accessibleLabel={null}
className="c-explorer__item__action"
href="#"
icon=""
isLoading={false}
onClick={[Function]}
preventDefault={true}
target={null}
>
<Icon
className=""
name="arrow-right"
title="See children"
/>
</Button>
</div>
`;
exports[`ExplorerItem #typeName 1`] = `
<Button
accessibleLabel={null}
exports[`ExplorerItem renders 1`] = `
<div
className="c-explorer__item"
href="/admin/pages/undefined"
icon=""
isLoading={false}
onClick={null}
preventDefault={true}
target={null}>
<h3
className="c-explorer__title" />
<p
className="c-explorer__meta">
>
<Button
accessibleLabel={null}
className="c-explorer__item__link"
href="/admin/pages/5/"
icon=""
isLoading={false}
onClick={null}
preventDefault={true}
target={null}
>
<h3
className="c-explorer__item__title"
>
test
</h3>
</Button>
<Button
accessibleLabel={null}
className="c-explorer__item__action c-explorer__item__action--small"
href="/admin/pages/5/edit/"
icon=""
isLoading={false}
onClick={null}
preventDefault={true}
target={null}
>
<Icon
className=""
name="edit"
title="Edit 'test'"
/>
</Button>
</div>
`;
exports[`ExplorerItem should show a publication status if not live 1`] = `
<div
className="c-explorer__item"
>
<Button
accessibleLabel={null}
className="c-explorer__item__link"
href="/admin/pages/5/"
icon=""
isLoading={false}
onClick={null}
preventDefault={true}
target={null}
>
<Icon
className="c-explorer__children"
name="folder-inverse"
title={null}
/>
<h3
className="c-explorer__item__title"
>
test
</h3>
<span
className="c-explorer__meta__type">
Foo
className="c-explorer__meta"
>
<PublicationStatus
status={
Object {
"has_unpublished_changes": true,
"live": false,
"status": "test",
}
}
/>
</span>
|
<AbsoluteDate
time="" />
|
<PublicationStatus />
</p>
</Button>
</Button>
<Button
accessibleLabel={null}
className="c-explorer__item__action c-explorer__item__action--small"
href="/admin/pages/5/edit/"
icon=""
isLoading={false}
onClick={null}
preventDefault={true}
target={null}
>
<Icon
className=""
name="edit"
title="Edit 'test'"
/>
</Button>
<Button
accessibleLabel={null}
className="c-explorer__item__action"
href="#"
icon=""
isLoading={false}
onClick={[Function]}
preventDefault={true}
target={null}
>
<Icon
className=""
name="arrow-right"
title="See children"
/>
</Button>
</div>
`;
exports[`ExplorerItem basic 1`] = `
<Button
accessibleLabel={null}
exports[`ExplorerItem should show a publication status with unpublished changes 1`] = `
<div
className="c-explorer__item"
href="/admin/pages/undefined"
icon=""
isLoading={false}
onClick={null}
preventDefault={true}
target={null}>
<h3
className="c-explorer__title" />
<p
className="c-explorer__meta">
>
<Button
accessibleLabel={null}
className="c-explorer__item__link"
href="/admin/pages/5/"
icon=""
isLoading={false}
onClick={null}
preventDefault={true}
target={null}
>
<Icon
className="c-explorer__children"
name="folder-inverse"
title={null}
/>
<h3
className="c-explorer__item__title"
>
test
</h3>
<span
className="c-explorer__meta__type" />
|
<AbsoluteDate
time={null} />
|
<PublicationStatus
status={null} />
</p>
</Button>
className="c-explorer__meta"
>
<PublicationStatus
status={
Object {
"has_unpublished_changes": true,
"live": true,
"status": "test",
}
}
/>
</span>
</Button>
<Button
accessibleLabel={null}
className="c-explorer__item__action c-explorer__item__action--small"
href="/admin/pages/5/edit/"
icon=""
isLoading={false}
onClick={null}
preventDefault={true}
target={null}
>
<Icon
className=""
name="edit"
title="Edit 'test'"
/>
</Button>
<Button
accessibleLabel={null}
className="c-explorer__item__action"
href="#"
icon=""
isLoading={false}
onClick={[Function]}
preventDefault={true}
target={null}
>
<Icon
className=""
name="arrow-right"
title="See children"
/>
</Button>
</div>
`;

View file

@ -0,0 +1,325 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ExplorerPanel #isError 1`] = `
<Component
active={true}
className="explorer"
focusTrapOptions={
Object {
"onDeactivate": [Function],
}
}
paused={false}
tag="nav"
>
<Button
accessibleLabel={null}
className="c-explorer__close u-hidden"
href="#"
icon=""
isLoading={false}
onClick={[Function]}
preventDefault={true}
target={null}
>
Close explorer
</Button>
<Transition
className="c-explorer"
component="div"
duration={210}
name="push"
>
<div
className="c-transition-group"
>
<ExplorerHeader
depth={0}
onClick={[Function]}
page={
Object {
"children": Object {
"items": Array [],
},
"isError": true,
}
}
/>
<div
className="c-explorer__drawer"
>
<div />
<div
className="c-explorer__placeholder"
>
Server Error
</div>
</div>
<PageCount
page={
Object {
"children": Object {
"items": Array [],
},
"isError": true,
}
}
/>
</div>
</Transition>
</Component>
`;
exports[`ExplorerPanel #isFetching 1`] = `
<Component
active={true}
className="explorer"
focusTrapOptions={
Object {
"onDeactivate": [Function],
}
}
paused={true}
tag="nav"
>
<Button
accessibleLabel={null}
className="c-explorer__close u-hidden"
href="#"
icon=""
isLoading={false}
onClick={[Function]}
preventDefault={true}
target={null}
>
Close explorer
</Button>
<Transition
className="c-explorer"
component="div"
duration={210}
name="push"
>
<div
className="c-transition-group"
>
<ExplorerHeader
depth={0}
onClick={[Function]}
page={
Object {
"children": Object {
"items": Array [],
},
"isFetching": true,
}
}
/>
<div
className="c-explorer__drawer"
>
<div />
<div
className="c-explorer__placeholder"
>
<LoadingSpinner />
</div>
</div>
</div>
</Transition>
</Component>
`;
exports[`ExplorerPanel #items 1`] = `
<Component
active={true}
className="explorer"
focusTrapOptions={
Object {
"onDeactivate": [Function],
}
}
paused={false}
tag="nav"
>
<Button
accessibleLabel={null}
className="c-explorer__close u-hidden"
href="#"
icon=""
isLoading={false}
onClick={[Function]}
preventDefault={true}
target={null}
>
Close explorer
</Button>
<Transition
className="c-explorer"
component="div"
duration={210}
name="push"
>
<div
className="c-transition-group"
>
<ExplorerHeader
depth={0}
onClick={[Function]}
page={
Object {
"children": Object {
"items": Array [
1,
2,
],
},
}
}
/>
<div
className="c-explorer__drawer"
>
<div>
<ExplorerItem
item={
Object {
"id": 1,
"meta": Object {
"status": Object {},
"type": "test",
},
"title": "Test",
}
}
onClick={[Function]}
/>
<ExplorerItem
item={
Object {
"id": 2,
"meta": Object {
"status": Object {},
"type": "foo",
},
"title": "Foo",
}
}
onClick={[Function]}
/>
</div>
</div>
</div>
</Transition>
</Component>
`;
exports[`ExplorerPanel no children 1`] = `
<Component
active={true}
className="explorer"
focusTrapOptions={
Object {
"onDeactivate": [Function],
}
}
paused={false}
tag="nav"
>
<Button
accessibleLabel={null}
className="c-explorer__close u-hidden"
href="#"
icon=""
isLoading={false}
onClick={[Function]}
preventDefault={true}
target={null}
>
Close explorer
</Button>
<Transition
className="c-explorer"
component="div"
duration={210}
name="push"
>
<div
className="c-transition-group"
>
<ExplorerHeader
depth={0}
onClick={[Function]}
page={
Object {
"children": Object {},
}
}
/>
<div
className="c-explorer__drawer"
>
<div
className="c-explorer__placeholder"
>
No results
</div>
</div>
</div>
</Transition>
</Component>
`;
exports[`ExplorerPanel renders 1`] = `
<Component
active={true}
className="explorer"
focusTrapOptions={
Object {
"onDeactivate": [Function],
}
}
paused={false}
tag="nav"
>
<Button
accessibleLabel={null}
className="c-explorer__close u-hidden"
href="#"
icon=""
isLoading={false}
onClick={[Function]}
preventDefault={true}
target={null}
>
Close explorer
</Button>
<Transition
className="c-explorer"
component="div"
duration={210}
name="push"
>
<div
className="c-transition-group"
>
<ExplorerHeader
depth={0}
onClick={[Function]}
page={
Object {
"children": Object {
"items": Array [],
},
}
}
/>
<div
className="c-explorer__drawer"
>
<div />
</div>
</div>
</Transition>
</Component>
`;

View file

@ -1,48 +1,11 @@
exports[`ExplorerToggle #children 1`] = `
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ExplorerToggle basic 1`] = `
<ExplorerToggle
isFetching={true}
isVisible={false}
onToggle={[Function]}
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}>
>
<span>
To infinity and beyond!
</span>
</ExplorerToggle>
`;
exports[`ExplorerToggle basic 1`] = `
<ExplorerToggle
isFetching={false}
isVisible={false}
onToggle={[Function]}
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
} />
`;
exports[`ExplorerToggle loading state 1`] = `
<ExplorerToggle
isFetching={true}
isVisible={false}
onToggle={[Function]}
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
} />
`;

View file

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

View file

@ -0,0 +1,55 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PageCount #title 1`] = `
<a
className="c-explorer__see-more"
href="/admin/pages/1/"
tabIndex={0}
>
See all
<span>
5 pages
</span>
<Icon
className=""
name="arrow-right"
title={null}
/>
</a>
`;
exports[`PageCount plural 1`] = `
<a
className="c-explorer__see-more"
href="/admin/pages/1/"
tabIndex={0}
>
See all
<span>
5 pages
</span>
<Icon
className=""
name="arrow-right"
title={null}
/>
</a>
`;
exports[`PageCount works 1`] = `
<a
className="c-explorer__see-more"
href="/admin/pages/1/"
tabIndex={0}
>
See all
<span>
1 page
</span>
<Icon
className=""
name="arrow-right"
title={null}
/>
</a>
`;

View file

@ -0,0 +1,90 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`actions pushPage creates action 1`] = `
Array [
Object {
"payload": Object {
"id": 5,
},
"type": "PUSH_PAGE",
},
]
`;
exports[`actions pushPage triggers getChildren 1`] = `
Array [
Object {
"payload": Object {
"id": 5,
},
"type": "PUSH_PAGE",
},
Object {
"payload": Object {
"id": 5,
},
"type": "GET_CHILDREN_START",
},
]
`;
exports[`actions toggleExplorer close 1`] = `
Array [
Object {
"type": "CLOSE_EXPLORER",
},
]
`;
exports[`actions toggleExplorer open 1`] = `
Array [
Object {
"payload": Object {
"id": 5,
},
"type": "OPEN_EXPLORER",
},
Object {
"payload": 5,
"type": "GET_PAGE_START",
},
]
`;
exports[`actions toggleExplorer open at root 1`] = `
Array [
Object {
"payload": Object {
"id": 1,
},
"type": "OPEN_EXPLORER",
},
Object {
"payload": Object {
"id": 1,
},
"type": "GET_CHILDREN_START",
},
]
`;
exports[`actions toggleExplorer open first time 1`] = `
Array [
Object {
"payload": Object {
"id": 5,
},
"type": "OPEN_EXPLORER",
},
Object {
"payload": Object {
"id": 5,
},
"type": "GET_CHILDREN_START",
},
Object {
"payload": 5,
"type": "GET_PAGE_START",
},
]
`;

View file

@ -0,0 +1,94 @@
import { createAction } from 'redux-actions';
import * as admin from '../../api/admin';
import { MAX_EXPLORER_PAGES } from '../../config/wagtailConfig';
const getPageStart = createAction('GET_PAGE_START');
const getPageSuccess = createAction('GET_PAGE_SUCCESS', (id, data) => ({ id, data }));
const getPageFailure = createAction('GET_PAGE_FAILURE', (id, error) => ({ id, error }));
/**
* Gets a page from the API.
*/
function getPage(id) {
return (dispatch) => {
dispatch(getPageStart(id));
return admin.getPage(id).then((data) => {
dispatch(getPageSuccess(id, data));
}, (error) => {
dispatch(getPageFailure(id, error));
});
};
}
const getChildrenStart = createAction('GET_CHILDREN_START', id => ({ id }));
const getChildrenSuccess = createAction('GET_CHILDREN_SUCCESS', (id, items, meta) => ({ id, items, meta }));
const getChildrenFailure = createAction('GET_CHILDREN_FAILURE', (id, error) => ({ id, error }));
/**
* Gets the children of a node from the API.
*/
function getChildren(id, offset = 0) {
return (dispatch) => {
dispatch(getChildrenStart(id));
return admin.getPageChildren(id, {
offset: offset,
}).then(({ items, meta }) => {
const nbPages = offset + items.length;
dispatch(getChildrenSuccess(id, items, meta));
// Load more pages if necessary. Only one request is created even though
// more might be needed, thus naturally throttling the loading.
if (nbPages < meta.total_count && nbPages < MAX_EXPLORER_PAGES) {
dispatch(getChildren(id, nbPages));
}
}, (error) => {
dispatch(getChildrenFailure(id, error));
});
};
}
const openExplorer = createAction('OPEN_EXPLORER', id => ({ id }));
export const closeExplorer = createAction('CLOSE_EXPLORER');
export function toggleExplorer(id) {
return (dispatch, getState) => {
const { explorer, nodes } = getState();
if (explorer.isVisible) {
dispatch(closeExplorer());
} else {
const page = nodes[id];
dispatch(openExplorer(id));
if (!page) {
dispatch(getChildren(id));
}
// We need to get the title of the starting page, only if it is not the site's root.
const isNotRoot = id !== 1;
if (isNotRoot) {
dispatch(getPage(id));
}
}
};
}
export const popPage = createAction('POP_PAGE');
const pushPagePrivate = createAction('PUSH_PAGE', id => ({ id }));
export function pushPage(id) {
return (dispatch, getState) => {
const { nodes } = getState();
const page = nodes[id];
dispatch(pushPagePrivate(id));
if (page && !page.children.isFetching && !page.children.isLoaded) {
dispatch(getChildren(id));
}
};
}

View file

@ -0,0 +1,99 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import * as actions from './actions';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
const stubState = {
explorer: {
isVisible: true,
},
nodes: {
5: {
children: {
isFetching: false,
isLoaded: true,
},
},
},
};
describe('actions', () => {
describe('closeExplorer', () => {
it('exists', () => {
expect(actions.closeExplorer).toBeDefined();
});
it('creates action', () => {
expect(actions.closeExplorer().type).toEqual('CLOSE_EXPLORER');
});
});
describe('toggleExplorer', () => {
it('exists', () => {
expect(actions.toggleExplorer).toBeDefined();
});
it('close', () => {
const store = mockStore(stubState);
store.dispatch(actions.toggleExplorer(5));
expect(store.getActions()).toMatchSnapshot();
});
it('open', () => {
const stub = Object.assign({}, stubState);
stub.explorer.isVisible = false;
const store = mockStore(stub);
store.dispatch(actions.toggleExplorer(5));
expect(store.getActions()).toMatchSnapshot();
});
it('open first time', () => {
const stub = { explorer: stubState.explorer, nodes: {} };
stub.explorer.isVisible = false;
const store = mockStore(stub);
store.dispatch(actions.toggleExplorer(5));
expect(store.getActions()).toMatchSnapshot();
});
it('open at root', () => {
const stub = Object.assign({}, stubState);
stub.explorer.isVisible = false;
const store = mockStore(stub);
store.dispatch(actions.toggleExplorer(1));
expect(store.getActions()).toMatchSnapshot();
});
});
describe('popPage', () => {
it('exists', () => {
expect(actions.popPage).toBeDefined();
});
it('works', () => {
expect(actions.popPage().type).toEqual('POP_PAGE');
});
});
describe('pushPage', () => {
it('exists', () => {
expect(actions.pushPage).toBeDefined();
});
it('creates action', () => {
const store = mockStore(stubState);
store.dispatch(actions.pushPage(5));
expect(store.getActions()).toMatchSnapshot();
});
it('triggers getChildren', () => {
const stub = Object.assign({}, stubState);
stub.nodes[5].children.isLoaded = false;
const store = mockStore(stub);
store.dispatch(actions.pushPage(5));
expect(store.getActions()).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,51 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { createStore, combineReducers, applyMiddleware, compose } from 'redux';
import thunkMiddleware from 'redux-thunk';
import Explorer from './Explorer';
import ExplorerToggle from './ExplorerToggle';
import explorer from './reducers/explorer';
import nodes from './reducers/nodes';
/**
* Initialises the explorer component on the given nodes.
*/
const initExplorer = (explorerNode, toggleNode) => {
const rootReducer = combineReducers({
explorer,
nodes,
});
const middleware = [
thunkMiddleware,
];
const store = createStore(rootReducer, {}, compose(
applyMiddleware(...middleware),
// Expose store to Redux DevTools extension.
window.devToolsExtension ? window.devToolsExtension() : func => func
));
const startPage = parseInt(toggleNode.getAttribute('data-explorer-start-page'), 10);
ReactDOM.render((
<Provider store={store}>
<ExplorerToggle startPage={startPage}>{toggleNode.textContent}</ExplorerToggle>
</Provider>
), toggleNode.parentNode);
ReactDOM.render((
<Provider store={store}>
<Explorer />
</Provider>
), explorerNode);
};
export default Explorer;
export {
ExplorerToggle,
initExplorer,
};

View file

@ -0,0 +1,28 @@
import Explorer, { ExplorerToggle, initExplorer } from './index';
describe('Explorer index', () => {
it('exists', () => {
expect(Explorer).toBeDefined();
});
describe('ExplorerToggle', () => {
it('exists', () => {
expect(ExplorerToggle).toBeDefined();
});
});
describe('initExplorer', () => {
it('exists', () => {
expect(initExplorer).toBeInstanceOf(Function);
});
it('works', () => {
document.body.innerHTML = '<div><div id="e"></div><div id="t">Test</div></div>';
const explorerNode = document.querySelector('#e');
const toggleNode = document.querySelector('#t');
initExplorer(explorerNode, toggleNode);
expect(document.body.innerHTML).toContain('data-reactroot');
});
});
});

View file

@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`explorer OPEN_EXPLORER 1`] = `
Object {
"isVisible": true,
"path": Array [
1,
],
}
`;
exports[`explorer POP_PAGE 1`] = `
Object {
"isVisible": false,
"path": Array [],
}
`;
exports[`explorer PUSH_PAGE 1`] = `
Object {
"isVisible": false,
"path": Array [
100,
],
}
`;

View file

@ -0,0 +1,142 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`nodes GET_CHILDREN_FAILURE 1`] = `
Object {
"1": Object {
"children": Object {
"count": 0,
"isFetching": false,
"items": Array [],
},
"isError": true,
"isFetching": false,
"isLoaded": true,
"meta": Object {
"children": Object {},
},
},
}
`;
exports[`nodes GET_CHILDREN_START 1`] = `
Object {
"1": Object {
"children": Object {
"isFetching": true,
},
"isFetching": true,
},
}
`;
exports[`nodes GET_CHILDREN_SUCCESS 1`] = `
Object {
"1": Object {
"children": Object {
"count": 3,
"isError": false,
"isFetching": false,
"isLoaded": true,
"items": Array [
3,
4,
5,
],
},
"isError": false,
"isFetching": false,
"isLoaded": true,
"meta": Object {
"children": Object {},
},
},
"3": Object {
"children": Object {
"count": 0,
"isFetching": false,
"items": Array [],
},
"id": 3,
"isError": false,
"isFetching": false,
"isLoaded": true,
"meta": Object {
"children": Object {},
},
},
"4": Object {
"children": Object {
"count": 0,
"isFetching": false,
"items": Array [],
},
"id": 4,
"isError": false,
"isFetching": false,
"isLoaded": true,
"meta": Object {
"children": Object {},
},
},
"5": Object {
"children": Object {
"count": 0,
"isFetching": false,
"items": Array [],
},
"id": 5,
"isError": false,
"isFetching": false,
"isLoaded": true,
"meta": Object {
"children": Object {},
},
},
}
`;
exports[`nodes GET_PAGE_FAILURE 1`] = `
Object {
"1": Object {
"children": Object {
"count": 0,
"isFetching": false,
"items": Array [],
},
"isError": true,
"isFetching": false,
"isLoaded": true,
"meta": Object {
"children": Object {},
},
},
}
`;
exports[`nodes GET_PAGE_SUCCESS 1`] = `
Object {
"1": Object {
"isError": false,
},
}
`;
exports[`nodes OPEN_EXPLORER 1`] = `
Object {
"1": Object {
"children": Object {
"count": 0,
"isFetching": false,
"items": Array [],
},
"isError": false,
"isFetching": false,
"isLoaded": true,
"meta": Object {
"children": Object {},
},
},
}
`;
exports[`nodes empty state 1`] = `Object {}`;

View file

@ -0,0 +1,39 @@
const defaultState = {
isVisible: false,
path: [],
};
/**
* Oversees the state of the explorer. Defines:
* - Where in the page tree the explorer is at.
* - Whether the explorer is open or not.
*/
export default function explorer(prevState = defaultState, { type, payload }) {
const state = Object.assign({}, prevState);
switch (type) {
case 'OPEN_EXPLORER':
// Provide a starting page when opening the explorer.
state.path = [payload.id];
state.isVisible = true;
break;
case 'CLOSE_EXPLORER':
state.path = [];
state.isVisible = false;
break;
case 'PUSH_PAGE':
state.path = state.path.concat([payload.id]);
break;
case 'POP_PAGE':
state.path = state.path.slice(0, -1);
break;
default:
break;
}
return state;
}

View file

@ -0,0 +1,32 @@
import explorer from './explorer';
describe('explorer', () => {
const initialState = explorer(undefined, {});
it('exists', () => {
expect(explorer).toBeDefined();
});
it('returns the initial state if no input is provided', () => {
expect(explorer(undefined, {})).toEqual(initialState);
});
it('OPEN_EXPLORER', () => {
const action = { type: 'OPEN_EXPLORER', payload: { id: 1 } };
expect(explorer(initialState, action)).toMatchSnapshot();
});
it('CLOSE_EXPLORER', () => {
expect(explorer(initialState, { type: 'CLOSE_EXPLORER' })).toEqual(initialState);
});
it('PUSH_PAGE', () => {
expect(explorer(initialState, { type: 'PUSH_PAGE', payload: { id: 100 } })).toMatchSnapshot();
});
it('POP_PAGE', () => {
const state = explorer(initialState, { type: 'PUSH_PAGE', payload: { id: 100 } });
const action = { type: 'POP_PAGE', payload: { id: 100 } };
expect(explorer(state, action)).toMatchSnapshot();
});
});

View file

@ -1,8 +0,0 @@
import * as actions from '../actions';
import rootReducer from './index';
describe('root', () => {
it('exists', () => {
expect(rootReducer).toBeDefined();
});
});

View file

@ -0,0 +1,69 @@
const defaultState = {};
const defaultPageState = {
isFetching: false,
isLoaded: true,
isError: false,
children: {
items: [],
count: 0,
isFetching: false,
},
meta: {
children: {},
},
};
export default function nodes(prevState = defaultState, { type, payload }) {
const state = Object.assign({}, prevState);
switch (type) {
case 'OPEN_EXPLORER':
state[payload.id] = Object.assign({}, defaultPageState, state[payload.id]);
break;
case 'GET_PAGE_SUCCESS':
state[payload.id] = Object.assign({}, state[payload.id], payload.data);
state[payload.id].isError = false;
break;
case 'GET_CHILDREN_START':
state[payload.id] = Object.assign({}, state[payload.id]);
state[payload.id].isFetching = true;
state[payload.id].children = Object.assign({}, state[payload.id].children);
state[payload.id].children.isFetching = true;
break;
case 'GET_CHILDREN_SUCCESS':
state[payload.id] = Object.assign({}, state[payload.id]);
state[payload.id].isFetching = false;
state[payload.id].isError = false;
state[payload.id].children = Object.assign({}, state[payload.id].children, {
items: state[payload.id].children.items.slice(),
count: payload.meta.total_count,
isFetching: false,
isLoaded: true,
isError: false,
});
payload.items.forEach((item) => {
state[item.id] = Object.assign({}, defaultPageState, state[item.id], item);
state[payload.id].children.items.push(item.id);
});
break;
case 'GET_PAGE_FAILURE':
case 'GET_CHILDREN_FAILURE':
state[payload.id] = Object.assign({}, state[payload.id]);
state[payload.id].isFetching = false;
state[payload.id].isError = true;
state[payload.id].children.isFetching = false;
break;
default:
break;
}
return state;
}

View file

@ -0,0 +1,59 @@
import nodes from './nodes';
describe('nodes', () => {
const initialState = nodes(undefined, {});
it('exists', () => {
expect(nodes).toBeDefined();
});
it('empty state', () => {
expect(initialState).toMatchSnapshot();
});
it('OPEN_EXPLORER', () => {
const action = { type: 'OPEN_EXPLORER', payload: { id: 1 } };
expect(nodes(initialState, action)).toMatchSnapshot();
});
it('GET_PAGE_SUCCESS', () => {
const action = { type: 'GET_PAGE_SUCCESS', payload: { id: 1, data: {} } };
expect(nodes(initialState, action)).toMatchSnapshot();
});
it('GET_PAGE_FAILURE', () => {
const state = nodes(initialState, { type: 'OPEN_EXPLORER', payload: { id: 1 } });
const action = { type: 'GET_PAGE_FAILURE', payload: { id: 1 } };
expect(nodes(state, action)).toMatchSnapshot();
});
it('GET_CHILDREN_START', () => {
const action = { type: 'GET_CHILDREN_START', payload: { id: 1 } };
expect(nodes(initialState, action)).toMatchSnapshot();
});
it('GET_CHILDREN_SUCCESS', () => {
const state = nodes(initialState, { type: 'OPEN_EXPLORER', payload: { id: 1 } });
const action = {
type: 'GET_CHILDREN_SUCCESS',
payload: {
id: 1,
items: [
{ id: 3 },
{ id: 4 },
{ id: 5 },
],
meta: {
total_count: 3,
},
},
};
expect(nodes(state, action)).toMatchSnapshot();
});
it('GET_CHILDREN_FAILURE', () => {
const state = nodes(initialState, { type: 'OPEN_EXPLORER', payload: { id: 1 } });
const action = { type: 'GET_CHILDREN_FAILURE', payload: { id: 1 } };
expect(nodes(state, action)).toMatchSnapshot();
});
});

View file

@ -1,7 +1,12 @@
import React from 'react';
/**
* Abstracts away the actual icon implementation (font icons, SVG icons, CSS sprite).
* Provide a `title` as an accessible label intended for screen readers.
*/
const Icon = ({ name, className, title }) => (
<span className={`icon icon-${name} ${className}`} aria-hidden={!title}>
<span>
<span className={`icon icon-${name} ${className}`} aria-hidden></span>
{title ? (
<span className="visuallyhidden">
{title}

View file

@ -1,21 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Icon #className 1`] = `
<span
aria-hidden={true}
className="icon icon-test u-test" />
<span>
<span
aria-hidden={true}
className="icon icon-test u-test"
/>
</span>
`;
exports[`Icon #name 1`] = `
<span
aria-hidden={true}
className="icon icon-test " />
<span>
<span
aria-hidden={true}
className="icon icon-test "
/>
</span>
`;
exports[`Icon #title 1`] = `
<span
aria-hidden={false}
className="icon icon-test ">
<span>
<span
className="visuallyhidden">
aria-hidden={true}
className="icon icon-test "
/>
<span
className="visuallyhidden"
>
Test title
</span>
</span>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,14 @@
import React from 'react';
import { STRINGS } from '../../config/wagtailConfig';
import Icon from '../../components/Icon/Icon';
/**
* A loading indicator with a text label next to it.
*/
const LoadingSpinner = () => (
<span>
<Icon name="spinner" className="c-spinner" />{` ${STRINGS.LOADING}`}
</span>
);
export default LoadingSpinner;

View file

@ -0,0 +1,5 @@
.c-spinner:after {
display: inline-block;
animation: spin 0.5s infinite linear;
line-height: 1;
}

View file

@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LoadingSpinner basic 1`] = `
<span>
<Icon
className="c-spinner"
name="spinner"
title={null}
/>
Loading...
</span>
`;

View file

@ -1,13 +1,19 @@
import React from 'react';
const PublicationStatus = ({ status }) => (status ? (
/**
* Displays the publication status of a page in a pill.
*/
const PublicationStatus = ({ status }) => (
<span className={`o-pill c-status${status.live ? ' c-status--live' : ''}`}>
{status.status}
</span>
) : null);
);
PublicationStatus.propTypes = {
status: React.PropTypes.object,
status: React.PropTypes.shape({
live: React.PropTypes.bool.isRequired,
status: React.PropTypes.string.isRequired,
}).isRequired,
};
export default PublicationStatus;

View file

@ -0,0 +1,6 @@
.c-status {
background: $color-grey-1;
text-transform: uppercase;
letter-spacing: .03rem;
font-size: 10px;
}

View file

@ -8,10 +8,6 @@ describe('PublicationStatus', () => {
expect(PublicationStatus).toBeDefined();
});
it('basic', () => {
expect(shallow(<PublicationStatus />)).toMatchSnapshot();
});
it('#status live', () => {
expect(shallow((
<PublicationStatus

View file

@ -1,19 +0,0 @@
# PublicationStatus
Displays the publication status of a page in a pill.
## Usage
```javascript
import { PublicationStatus } from 'wagtail';
render(
<PublicationStatus
status={status}
/>
);
```
### Available props
- `status`: status object coming from the admin API. If no status is given, component renders to null.

View file

@ -1,15 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PublicationStatus #status live 1`] = `
<span
className="o-pill c-status c-status--live">
className="o-pill c-status c-status--live"
>
live + draft
</span>
`;
exports[`PublicationStatus #status not live 1`] = `
<span
className="o-pill c-status">
className="o-pill c-status"
>
live + draft
</span>
`;
exports[`PublicationStatus basic 1`] = `null`;

View file

@ -0,0 +1,48 @@
import React from 'react';
import CSSTransitionGroup from 'react-addons-css-transition-group';
const TRANSITION_DURATION = 210;
// The available transitions. Must match the class names in CSS.
export const PUSH = 'push';
export const POP = 'pop';
export const FADE = 'fade';
/**
* Wrapper arround react-addons-css-transition-group with default values.
*/
const Transition = ({
name,
component,
className,
duration,
children,
}) => (
<CSSTransitionGroup
component={component}
transitionEnterTimeout={duration}
transitionLeaveTimeout={duration}
transitionName={`c-transition-${name}`}
className={className}
>
{children}
</CSSTransitionGroup>
);
Transition.propTypes = {
name: React.PropTypes.oneOf([PUSH, POP, FADE]).isRequired,
component: React.PropTypes.string,
className: React.PropTypes.string,
duration: React.PropTypes.number,
children: React.PropTypes.node,
};
Transition.defaultProps = {
component: 'div',
children: null,
className: null,
duration: TRANSITION_DURATION,
};
export default Transition;

View file

@ -0,0 +1,85 @@
// =============================================================================
// Transitions
// =============================================================================
$c-transition-duration: 200ms;
.c-transition-group {
position: absolute;
width: 100%;
top: 0;
}
.c-transition-push-enter {
transform: translateX(100%);
transition: transform $c-transition-duration ease, opacity $c-transition-duration linear;
opacity: 0;
}
.c-transition-push-enter-active {
transform: translateX(0);
opacity: 1;
}
.c-transition-push-leave {
transform: translateX(0);
transition: transform $c-transition-duration ease, opacity $c-transition-duration linear;
opacity: 1;
}
.c-transition-push-leave-active {
transform: translateX(-100%);
opacity: 0;
}
// =============================================================================
// Pop transition
// =============================================================================
.c-transition-pop-enter {
transform: translateX(-100%);
transition: transform $c-transition-duration ease, opacity $c-transition-duration linear;
opacity: 0;
}
.c-transition-pop-enter-active {
transform: translateX(0);
opacity: 1;
}
.c-transition-pop-leave {
transform: translateX(0);
transition: transform $c-transition-duration ease, opacity $c-transition-duration linear;
opacity: 1;
}
.c-transition-pop-leave-active {
transform: translateX(100%);
opacity: 0;
}
// =============================================================================
// Fade transition
// =============================================================================
.c-transition-fade-enter {
position: absolute;
width: 100%;
opacity: 0;
transition: opacity $c-transition-duration ease $c-transition-duration;
}
.c-transition-fade-enter-active {
opacity: 1;
}
.c-transition-fade-leave {
position: absolute;
width: 100%;
opacity: 1;
transition: opacity $c-transition-duration ease;
}
.c-transition-fade-leave-active {
opacity: 0;
}

View file

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

View file

@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Transition basic 1`] = `
<ReactCSSTransitionGroup
className={null}
component="div"
transitionAppear={false}
transitionEnter={true}
transitionEnterTimeout={210}
transitionLeave={true}
transitionLeaveTimeout={210}
transitionName="c-transition-push"
/>
`;

View file

@ -1,116 +0,0 @@
import React 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/config';
import ExplorerPanel from './ExplorerPanel';
// TODO To refactor.
class Explorer extends React.Component {
constructor(props) {
super(props);
this.init = this.init.bind(this);
}
componentDidMount() {
const { defaultPage, setDefaultPage } = this.props;
if (defaultPage) {
setDefaultPage(defaultPage);
}
}
init() {
const { page, defaultPage, onShow } = this.props;
if (page && page.isLoaded) {
return;
}
onShow(page ? page : defaultPage);
}
getPage() {
const { nodes, path } = this.props;
const id = path[path.length - 1];
return nodes[id];
}
render() {
const { isVisible, nodes, path, pageTypes, type, fetching, resolved, onPop, onClose, transport, getChildren, loadItemWithChildren, pushPage } = this.props;
return (
<CSSTransitionGroup
component="div"
transitionEnterTimeout={EXPLORER_ANIM_DURATION}
transitionLeaveTimeout={EXPLORER_ANIM_DURATION}
transitionName="explorer-toggle"
>
{isVisible ? (
<ExplorerPanel
path={path}
pageTypes={pageTypes}
page={this.getPage()}
type={type}
fetching={fetching}
nodes={nodes}
resolved={resolved}
onPop={onPop}
onClose={onClose}
transport={transport}
getChildren={getChildren}
loadItemWithChildren={loadItemWithChildren}
pushPage={pushPage}
init={this.init}
/>
) : null}
</CSSTransitionGroup>
);
}
}
Explorer.propTypes = {
isVisible: React.PropTypes.bool.isRequired,
fetching: React.PropTypes.bool.isRequired,
resolved: React.PropTypes.bool.isRequired,
path: React.PropTypes.array,
type: React.PropTypes.string.isRequired,
nodes: React.PropTypes.object.isRequired,
transport: React.PropTypes.object.isRequired,
page: React.PropTypes.any,
defaultPage: React.PropTypes.number,
onPop: React.PropTypes.func.isRequired,
setDefaultPage: React.PropTypes.func.isRequired,
onShow: React.PropTypes.func.isRequired,
onClose: React.PropTypes.func.isRequired,
getChildren: React.PropTypes.func.isRequired,
loadItemWithChildren: React.PropTypes.func.isRequired,
pushPage: React.PropTypes.func.isRequired,
pageTypes: React.PropTypes.object.isRequired,
};
const mapStateToProps = (state) => ({
isVisible: state.explorer.isVisible,
page: state.explorer.currentPage,
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,
transport: state.transport
});
const mapDispatchToProps = (dispatch) => ({
setDefaultPage: (id) => dispatch(actions.setDefaultPage(id)),
getChildren: (id) => dispatch(actions.fetchChildren(id)),
onShow: () => dispatch(actions.fetchRoot()),
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

@ -1,49 +0,0 @@
import React from 'react';
import CSSTransitionGroup from 'react-addons-css-transition-group';
import { EXPLORER_ANIM_DURATION } from '../../config/config';
import { STRINGS } from '../../config/wagtail';
import Icon from '../../components/Icon/Icon';
const ExplorerHeader = ({ page, depth, onPop, transName }) => {
const title = depth < 2 || !page ? STRINGS.PAGES : page.title;
return (
<div className="c-explorer__header">
<button
role="button"
className={`c-explorer__trigger${depth > 1 ? ' c-explorer__trigger--enabled' : ''}`}
onClick={onPop}
tabIndex={depth === 1 ? -1 : 0}
>
<span className="u-overflow c-explorer__overflow">
<CSSTransitionGroup
component="span"
transitionEnterTimeout={EXPLORER_ANIM_DURATION}
transitionLeaveTimeout={EXPLORER_ANIM_DURATION}
transitionName={`explorer-${transName}`}
className="c-explorer__rel"
>
<span className="c-explorer__parent-name" key={depth}>
{depth > 1 ? (
<span className="c-explorer__back">
<Icon name="arrow-left" />
</span>
) : null}
{title}
</span>
</CSSTransitionGroup>
</span>
</button>
</div>
);
};
ExplorerHeader.propTypes = {
page: React.PropTypes.object,
depth: React.PropTypes.number,
onPop: React.PropTypes.func,
transName: React.PropTypes.string,
};
export default ExplorerHeader;

View file

@ -1,51 +0,0 @@
import React from 'react';
import { ADMIN_URLS, STRINGS } from '../../config/wagtail';
import Icon from '../../components/Icon/Icon';
import Button from '../../components/Button/Button';
import PublicationStatus from '../../components/PublicationStatus/PublicationStatus';
import AbsoluteDate from '../../components/AbsoluteDate/AbsoluteDate';
const ExplorerItem = ({ title, typeName, data, onItemClick }) => {
const { id, meta } = data;
const status = meta ? meta.status : null;
const time = meta ? meta.latest_revision_created_at : null;
// TODO Use meta.children.count once we drop the has_children filter.
// const hasChildren = meta ? meta.children.count > 0 : false;
const hasChildren = meta ? meta.descendants.count - meta.children.count > 0 : false;
return (
<div className="c-explorer__item">
<Button href={`${ADMIN_URLS.PAGES}${id}`} className="c-explorer__item__link">
<h3 className="c-explorer__title">{title}</h3>
<p className="c-explorer__meta">
<span className="c-explorer__meta__type">{typeName}</span> | <AbsoluteDate time={time} /> | <PublicationStatus status={status} />
</p>
</Button>
{hasChildren ? (
<Button
href={`${ADMIN_URLS.PAGES}${id}`}
className="c-explorer__item__children"
onClick={onItemClick.bind(null, id)}
>
<Icon name="arrow-right" title={STRINGS.SEE_CHILDREN} />
</Button>
) : null}
</div>
);
};
ExplorerItem.propTypes = {
title: React.PropTypes.string,
data: React.PropTypes.object,
typeName: React.PropTypes.string,
onItemClick: React.PropTypes.func,
};
ExplorerItem.defaultProps = {
data: {},
onItemClick: () => {},
};
export default ExplorerItem;

View file

@ -1,193 +0,0 @@
import React from 'react';
import CSSTransitionGroup from 'react-addons-css-transition-group';
import FocusTrap from 'focus-trap-react'
import { EXPLORER_ANIM_DURATION } from '../../config/config';
import { STRINGS } from '../../config/wagtail';
import ExplorerHeader from './ExplorerHeader';
import ExplorerItem from './ExplorerItem';
import LoadingSpinner from './LoadingSpinner';
import PageCount from './PageCount';
export default class ExplorerPanel extends React.Component {
constructor(props) {
super(props);
this.onItemClick = this.onItemClick.bind(this);
this.state = {
// TODO Refactor value to constant.
animation: 'push',
};
}
componentWillReceiveProps(newProps) {
const { path } = this.props;
if (path) {
const isPush = newProps.path.length > path.length;
const animation = isPush ? 'push' : 'pop';
this.setState({
animation: animation,
});
}
}
loadChildren() {
const { page, getChildren } = this.props;
if (page && !page.children.isFetching) {
if (page.meta.children.count && !page.children.length && !page.children.isFetching && !page.children.isLoaded) {
getChildren(page.id);
}
}
}
componentDidUpdate() {
this.loadChildren();
}
componentDidMount() {
const { init } = this.props;
init();
document.body.classList.add('explorer-open');
}
componentWillUnmount() {
document.body.classList.remove('explorer-open');
}
onItemClick(id, e) {
const { nodes, pushPage, loadItemWithChildren } = this.props;
const node = nodes[id];
e.preventDefault();
e.stopPropagation();
if (node.isLoaded) {
pushPage(id);
} else {
loadItemWithChildren(id);
}
}
renderChildren(page) {
const { nodes, pageTypes } = this.props;
if (!page || !page.children.items) {
return [];
}
return page.children.items
.map(index => nodes[index])
.map((item) => {
const typeName = pageTypes[item.meta.type] ? pageTypes[item.meta.type].verbose_name : item.meta.type;
return (
<ExplorerItem
onItemClick={this.onItemClick}
parent={page}
key={item.id}
title={item.title}
typeName={typeName}
data={item}
/>
);
});
}
getContents() {
const { page } = this.props;
let ret;
if (page) {
if (page.children.items.length) {
ret = this.renderChildren(page);
} else {
ret = (
<div className="c-explorer__placeholder">
{STRINGS.NO_RESULTS}
</div>
);
}
}
return ret;
}
render() {
const { type, page, onPop, onClose, path, resolved } = this.props;
const { animation } = this.state;
return !resolved ? (
<div />
) : (
<FocusTrap
paused={page.isFetching}
focusTrapOptions={{
onDeactivate: onClose,
clickOutsideDeactivates: true,
}}
>
{/* FocusTrap gets antsy while the page is loading, so we give it something to focus on. */}
{page.isFetching && <div tabIndex={0} />}
<div className={`c-explorer ${type ? 'c-explorer--' + type : ''}`} tabIndex={-1}>
<ExplorerHeader
depth={path.length}
page={page}
onPop={onPop}
onClose={onClose}
transName={animation}
/>
<div className="c-explorer__drawer">
<CSSTransitionGroup
component="div"
transitionEnterTimeout={EXPLORER_ANIM_DURATION}
transitionLeaveTimeout={EXPLORER_ANIM_DURATION}
transitionName={`explorer-${animation}`}
>
<div key={path.length} className="c-explorer__transition-group">
<CSSTransitionGroup
component="div"
transitionEnterTimeout={EXPLORER_ANIM_DURATION}
transitionLeaveTimeout={EXPLORER_ANIM_DURATION}
transitionName="explorer-fade"
>
{page.isFetching ? (
<LoadingSpinner key={1} />
) : (
<div key={0}>
{this.getContents()}
{(page.children.count > page.children.items.length) && (
<PageCount id={page.id} count={page.meta.children.count} title={page.title} />
)}
</div>
)}
</CSSTransitionGroup>
</div>
</CSSTransitionGroup>
</div>
</div>
</FocusTrap>
);
}
}
ExplorerPanel.propTypes = {
page: React.PropTypes.object,
onPop: React.PropTypes.func.isRequired,
onClose: React.PropTypes.func.isRequired,
type: React.PropTypes.string.isRequired,
path: React.PropTypes.array,
resolved: React.PropTypes.bool.isRequired,
init: React.PropTypes.func.isRequired,
getChildren: React.PropTypes.func.isRequired,
pushPage: React.PropTypes.func.isRequired,
loadItemWithChildren: React.PropTypes.func.isRequired,
nodes: React.PropTypes.object.isRequired,
pageTypes: React.PropTypes.object.isRequired,
};

View file

@ -1,41 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import * as actions from './actions';
import Button from '../../components/Button/Button';
/**
* A Button which toggles the explorer, and doubles as a loading indicator.
*/
// TODO isVisible should not be used here, but at the moment there is a click
// binding problem between this and the ExplorerPanel clickOutside.
const ExplorerToggle = ({ isVisible, isFetching, children, onToggle }) => (
<Button
icon="folder-open-inverse"
isLoading={isFetching}
onClick={isVisible ? null : onToggle}
>
{children}
</Button>
);
ExplorerToggle.propTypes = {
isVisible: React.PropTypes.bool,
isFetching: React.PropTypes.bool,
onToggle: React.PropTypes.func,
children: React.PropTypes.node,
};
const mapStateToProps = (store) => ({
isFetching: store.explorer.isFetching,
isVisible: store.explorer.isVisible,
});
const mapDispatchToProps = (dispatch) => ({
onToggle() {
dispatch(actions.toggleExplorer());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ExplorerToggle);

View file

@ -1,11 +0,0 @@
import React from 'react';
import { STRINGS } from '../../config/wagtail';
import Icon from '../../components/Icon/Icon';
const LoadingSpinner = () => (
<div className="c-explorer__loading">
<Icon name="spinner" className="c-explorer__spinner" /> {STRINGS.LOADING}
</div>
);
export default LoadingSpinner;

View file

@ -1,99 +0,0 @@
import { createAction } from 'redux-actions';
import { PAGES_ROOT_ID } from '../../../config/config';
import * as admin from '../../../api/admin';
export const fetchStart = createAction('FETCH_START');
export const fetchSuccess = createAction('FETCH_SUCCESS', (id, body) => ({ 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) => ({ 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');
export const fetchChildrenSuccess = createAction('FETCH_CHILDREN_SUCCESS', (id, json) => ({ id, json }));
export const fetchChildrenStart = createAction('FETCH_CHILDREN_START');
/**
* Gets the children of a node from the API.
*/
export function fetchChildren(id = 'root') {
return (dispatch, getState) => {
const { explorer } = getState();
dispatch(fetchChildrenStart(id));
return admin.getChildPages(id, {
fields: explorer.fields,
}).then(json => dispatch(fetchChildrenSuccess(id, json)));
};
}
// Make this a bit better... hmm....
export function fetchTree(id = 1) {
return (dispatch) => {
dispatch(fetchBranchStart(id));
return admin.getPage(id).then((json) => {
dispatch(fetchBranchSuccess(id, json));
// Recursively walk up the tree to the root, to figure out how deep
// in the tree we are.
if (json.meta.parent) {
dispatch(fetchTree(json.meta.parent.id));
} else {
dispatch(treeResolved());
}
});
};
}
export function fetchRoot() {
return (dispatch) => {
// TODO Should not need an id.
dispatch(resetTree(PAGES_ROOT_ID));
dispatch(fetchBranchStart(PAGES_ROOT_ID));
dispatch(fetchBranchSuccess(PAGES_ROOT_ID, {
children: {},
meta: {
children: {},
},
}));
dispatch(fetchChildren(PAGES_ROOT_ID));
dispatch(treeResolved());
};
}
export const toggleExplorer = createAction('TOGGLE_EXPLORER');
/**
* 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 admin.getPage(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,92 +0,0 @@
import _ from 'lodash';
const defaultState = {
isVisible: false,
isFetching: false,
isResolved: false,
path: [],
currentPage: 1,
defaultPage: 1,
// TODO Change to include less fields (just 'descendants'?) in the next version of the admin API.
// Specificies which fields are to be fetched in the API calls.
fields: ['title', 'latest_revision_created_at', 'status', 'descendants', 'children'],
// Coming from the API in order to get translated / pluralised labels.
pageTypes: {},
};
export default function explorer(state = defaultState, action = {}) {
let newNodes = state.path;
switch (action.type) {
case 'SET_DEFAULT_PAGE':
return _.assign({}, state, {
defaultPage: action.payload
});
case 'RESET_TREE':
return _.assign({}, state, {
isFetching: true,
isResolved: false,
currentPage: action.payload,
path: [],
});
case 'TREE_RESOLVED':
return _.assign({}, state, {
isFetching: false,
isResolved: true
});
case 'TOGGLE_EXPLORER':
return _.assign({}, state, {
isVisible: !state.isVisible,
currentPage: action.payload ? action.payload : state.defaultPage,
});
case 'FETCH_START':
return _.assign({}, state, {
isFetching: true
});
case 'FETCH_BRANCH_SUCCESS':
if (state.path.indexOf(action.payload.id) < 0) {
newNodes = [action.payload.id].concat(state.path);
}
return _.assign({}, state, {
path: newNodes,
currentPage: state.currentPage ? state.currentPage : action.payload.id
});
// called on fetch page...
case 'FETCH_SUCCESS':
if (state.path.indexOf(action.payload.id) < 0) {
newNodes = state.path.concat([action.payload.id]);
}
return _.assign({}, state, {
isFetching: false,
path: newNodes,
});
case 'PUSH_PAGE':
return _.assign({}, state, {
path: state.path.concat([action.payload])
});
case 'POP_PAGE':
return _.assign({}, state, {
path: state.path.length > 1 ? state.path.slice(0, -1) : state.path,
});
case 'FETCH_CHILDREN_SUCCESS':
return _.assign({}, state, {
isFetching: false,
// eslint-disable-next-line no-underscore-dangle
pageTypes: _.assign({}, state.pageTypes, action.payload.json.__types),
});
default:
return state;
}
}

View file

@ -1,55 +0,0 @@
import * as actions from '../actions';
import _ from 'lodash';
import rootReducer from './index';
import explorer from './explorer';
describe('explorer', () => {
const initialState = {
isVisible: false,
isFetching: false,
isResolved: false,
path: [],
currentPage: 1,
defaultPage: 1,
fields: ['title', 'latest_revision_created_at', 'status', 'descendants', 'children'],
pageTypes: {},
};
it('exists', () => {
expect(explorer).toBeDefined();
});
it('returns the initial state if no input is provided', () => {
expect(explorer(undefined, undefined))
.toEqual(initialState);
});
it('sets the default page', () => {
expect(explorer(initialState, {type: 'SET_DEFAULT_PAGE', payload: 100}))
.toEqual(_.assign({}, initialState, {defaultPage: 100}))
});
it('resets the tree', () => {
expect(explorer(initialState, {type: 'RESET_TREE', payload: 100}))
.toEqual(_.assign({}, initialState, {isFetching: true, currentPage: 100}))
});
it('has resolved the tree', () => {
expect(explorer(initialState, {type: 'TREE_RESOLVED'}))
.toEqual(_.assign({}, initialState, {isResolved: true}))
});
it('toggles the explorer', () => {
expect(explorer(initialState, {type: 'TOGGLE_EXPLORER', payload: 100}))
.toEqual(
_.assign({}, initialState, {isVisible: !initialState.isVisible, currentPage: 100})
)
});
it('starts fetching', () => {
expect(explorer(initialState, {type: 'FETCH_START'}))
.toEqual(_.assign({}, initialState, {isFetching: true}))
});
it('pushes a page to the path', () => {
expect(explorer(initialState, {type: 'PUSH_PAGE', payload: 100}))
.toEqual(_.assign({}, initialState, {path: initialState.path.concat([100])}))
});
it('pops a page off the path', () => {
expect(explorer(_.assign({}, initialState, {path: initialState.path.concat(["root", 100])}), {type: 'POP_PAGE', payload: 100}))
.toEqual(_.assign({}, initialState, {path: initialState.path.concat(["root"])}))
});
});

View file

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

@ -1,93 +0,0 @@
import _ from 'lodash';
const childrenDefaultState = {
items: [],
count: 0,
isFetching: false
};
const children = (state = childrenDefaultState, action) => {
switch (action.type) {
case 'FETCH_CHILDREN_START':
return _.assign({}, state, {
isFetching: true,
});
case 'FETCH_CHILDREN_SUCCESS':
return _.assign({}, state, {
items: action.payload.json.items.map(item => item.id),
count: action.payload.json.meta.total_count,
isFetching: false,
isLoaded: true,
});
default:
return state;
}
};
const defaultState = {
isError: false,
isFetching: false,
isLoaded: false,
children: children(undefined, {})
};
// TODO Why isn't the default state used on init?
export default function nodes(state = {}, action = {}) {
switch (action.type) {
case 'FETCH_CHILDREN_START':
// TODO Very hard to understand this code. To refactor.
return _.assign({}, state, {
[action.payload]: _.assign({}, state[action.payload], {
isFetching: true,
children: children(state[action.payload] ? state[action.payload].children : undefined, action)
})
});
// eslint-disable-next-line no-case-declarations
case 'FETCH_CHILDREN_SUCCESS':
// TODO Very hard to understand this code. To refactor.
let map = {};
action.payload.json.items.forEach(item => {
map = _.assign({}, map, {
[item.id]: _.assign({}, defaultState, state[item.id], item, {
isLoaded: true
})
});
});
return _.assign({}, state, map, {
[action.payload.id]: _.assign({}, state[action.payload.id], {
isFetching: false,
children: children(state[action.payload.id].children, action)
})
});
case 'RESET_TREE':
return defaultState;
case 'FETCH_START':
return _.assign({}, state, {
[action.payload]: _.assign({}, defaultState, state[action.payload], {
isFetching: true,
isError: false,
})
});
case 'FETCH_BRANCH_SUCCESS':
return _.assign({}, state, {
[action.payload.id]: _.assign({}, defaultState, state[action.payload.id], action.payload.json, {
isFetching: false,
isError: false,
isLoaded: true
})
});
case 'FETCH_SUCCESS':
return state;
default:
return state;
}
}

View file

@ -1,72 +0,0 @@
import * as actions from '../actions';
import _ from 'lodash';
import rootReducer from './index';
import nodes from './nodes';
describe('nodes', () => {
const initialState = {
isError: false,
isFetching: false,
isLoaded: false,
children: {
items: [],
count: 0,
isFetching: false
}
};
const fetchingState = {
"any": {
isFetching: true,
isError: false,
isLoaded: false,
children: {
items: [],
count: 0,
isFetching: false
}
}
};
const fetchingChildren = {
isError: false,
isFetching: false,
isLoaded: false,
children: {
items: [],
count: 0,
isFetching: false
},
"any": {
isFetching: true,
children: {
items: [],
count: 0,
isFetching: true
}
}
};
it('exists', () => {
expect(nodes).toBeDefined();
});
it('returns empty state on no action and no input state', () => {
expect(nodes(undefined, undefined)).toEqual({});
});
it('returns initial state on no action and initial state input', () => {
expect(nodes(initialState, undefined)).toEqual(initialState);
});
it('starts fetching children', () => {
expect(nodes(initialState, {type: 'FETCH_CHILDREN_START', payload: 'any'})).toEqual(fetchingChildren);
});
it('resets the tree', () => {
expect(nodes({}, {type: 'RESET_TREE'})).toEqual(initialState);
});
it('starts fetching', () => {
expect(nodes({}, {type: 'FETCH_START', payload: 'any'})).toEqual(fetchingState)
});
it('makes a fetch success', () => {
expect(nodes({'any': 'any'}, {type: 'FETCH_SUCCESS'})).toEqual({'any': 'any'})
})
});

View file

@ -1,23 +0,0 @@
import _ from 'lodash';
const defaultState = {
error: null,
showMessage: false,
};
export default function transport(state = defaultState, action) {
switch (action.type) {
case 'FETCH_FAILURE':
return _.assign({}, state, {
error: action.payload.message,
showMessage: true
});
case 'CLEAR_TRANSPORT_ERROR':
return _.assign({}, state, {
error: null,
showMessage: false
});
default:
return state;
}
}

View file

@ -1,36 +0,0 @@
import * as actions from '../actions';
import _ from 'lodash';
import rootReducer from './index';
import transport from './transport';
describe('transport', () => {
const initialState = {
error: null,
showMessage: false,
};
it('exists', () => {
expect(transport).toBeDefined();
});
it('returns the initial state', () => {
expect(transport(undefined, {})).toEqual(initialState);
});
it('returns error message and flag', () => {
const action = actions.fetchFailure(new Error('Test error'));
expect(transport(initialState, action)).toEqual({
error: 'Test error',
showMessage: true,
});
});
it('clears previous error message and flag', () => {
const action = actions.clearError();
const errorState = {
error: 'Test error',
showMessage: true,
};
expect(transport(errorState, action)).toEqual(initialState);
});
});

View file

@ -1,323 +0,0 @@
$c-explorer-bg: #4C4E4D;
$c-explorer-secondary: #cacaca;
$c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
.c-explorer, .c-explorer * {
box-sizing: border-box;
}
.c-explorer {
width: 100%;
height: 500px;
background: $c-explorer-bg;
position: absolute;
overflow: hidden;
}
.c-explorer--sidebar {
height: 100vh;
box-shadow: 2px 2px 5px rgba(0,0,0,0.2);
z-index: 150;
}
.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: 100%;
background: none;
border: none;
text-align: left;
color: $c-explorer-secondary;
line-height: inherit;
font: inherit;
cursor: default;
}
.c-explorer__trigger--enabled {
cursor: pointer;
&:hover, &:focus {
color: $color-white;
background: rgba(0,0,0,0.2);
outline: none;
}
}
.c-explorer__back {
margin-right: .25rem;
float: left;
margin-top: -1px;
&:hover {
color: $color-white;
}
.icon {
line-height: 1;
display: inline-block;
font-size: 16px;
}
}
.c-explorer__title {
margin: 0;
color: $color-white;
}
.c-explorer__loading {
color: $color-white;
padding: 1rem;
}
.c-explorer__placeholder {
padding: 1rem;
color: $color-white;
}
.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 {
display: block;
position: relative;
border-bottom: solid 1px #676767;
&:last-child {
border-bottom: 0;
}
}
.c-explorer__item__link {
display: block;
padding: 1rem;
cursor: pointer;
&:hover, &:focus {
background: rgba(0, 0, 0, 0.25);
color: $color-white;
outline: none;
}
}
.c-explorer__item__children {
display: inline-block;
color: $color-white;
line-height: 1;
padding: .7em .3em .7em .7em;
cursor: pointer;
display: inline-block;
position: absolute;
right: 0;
top: 0;
bottom: 0;
font-size: 2em;
background: transparent;
border: 0;
&:hover, &:focus {
background: rgba(0,0,0,0.5);
color: $color-white;
outline: none;
}
}
.c-explorer__see-more {
padding: 1rem;
background: rgba(0,0,0,0.2);
color: $c-explorer-secondary;
display: block;
&:hover, &:focus {
color: $c-explorer-secondary;
background: rgba(0,0,0,0.4);
outline: none;
}
}
.c-explorer__see-more__title {
color: $color-white;
}
.c-status {
background: $color-grey-1;
color: $c-explorer-secondary;
text-transform: uppercase;
letter-spacing: .03rem;
font-size: 10px;
}
.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%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
// =============================================================================
// 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;
}
.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.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;
}
// =============================================================================
// Toggle transition
// =============================================================================
.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;
}

View file

@ -1,23 +0,0 @@
# 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"
title="Move left"
/>
);
```
### Available props
- `name`: icon name
- `className`: additional CSS classes to add to the element
- `title`: accessible label intended for screen readers

View file

@ -1,3 +0,0 @@
export const PAGES_ROOT_ID = 'root';
export const EXPLORER_ANIM_DURATION = 220;

View file

@ -1,19 +0,0 @@
import {
PAGES_ROOT_ID,
EXPLORER_ANIM_DURATION,
} from './config';
describe('config', () => {
describe('PAGES_ROOT_ID', () => {
it('exists', () => {
expect(PAGES_ROOT_ID).toBeDefined();
});
});
describe('EXPLORER_ANIM_DURATION', () => {
it('exists', () => {
expect(EXPLORER_ANIM_DURATION).toBeDefined();
});
});
});

View file

@ -2,4 +2,5 @@ export const ADMIN_API = global.wagtailConfig.ADMIN_API;
export const STRINGS = global.wagtailConfig.STRINGS;
export const ADMIN_URLS = global.wagtailConfig.ADMIN_URLS;
export const DATE_FORMAT = global.wagtailConfig.DATE_FORMATTING.DATE_FORMAT;
// Maximum number of pages to load inside the explorer menu.
export const MAX_EXPLORER_PAGES = 200;

View file

@ -2,10 +2,10 @@ import {
ADMIN_API,
STRINGS,
ADMIN_URLS,
DATE_FORMAT,
} from './wagtail';
MAX_EXPLORER_PAGES,
} from './wagtailConfig';
describe('config', () => {
describe('wagtailConfig', () => {
describe('ADMIN_API', () => {
it('exists', () => {
expect(ADMIN_API).toBeDefined();
@ -24,9 +24,9 @@ describe('config', () => {
});
});
describe('DATE_FORMAT', () => {
describe('MAX_EXPLORER_PAGES', () => {
it('exists', () => {
expect(DATE_FORMAT).toBeDefined();
expect(MAX_EXPLORER_PAGES).toBeDefined();
});
});
});

View file

@ -4,17 +4,22 @@
*/
import Button from './components/Button/Button';
import Explorer from './components/Explorer/Explorer';
import Icon from './components/Icon/Icon';
import LoadingIndicator from './components/LoadingIndicator/LoadingIndicator';
import AbsoluteDate from './components/AbsoluteDate/AbsoluteDate';
import PublicationStatus from './components/PublicationStatus/PublicationStatus';
import LoadingSpinner from './components/LoadingSpinner/LoadingSpinner';
import Transition from './components/Transition/Transition';
import Explorer, {
ExplorerToggle,
initExplorer,
} from './components/Explorer';
export {
Button,
Explorer,
Icon,
LoadingIndicator,
AbsoluteDate,
PublicationStatus,
LoadingSpinner,
Transition,
Explorer,
ExplorerToggle,
initExplorer,
};

View file

@ -1,41 +1,44 @@
import {
Button,
Explorer,
Icon,
LoadingIndicator,
AbsoluteDate,
PublicationStatus,
LoadingSpinner,
Transition,
Explorer,
ExplorerToggle,
initExplorer,
} from './index';
describe('wagtail package API', () => {
describe('Button', () => {
it('exists', () => {
expect(Button).toBeDefined();
});
it('has Button', () => {
expect(Button).toBeDefined();
});
describe('Explorer', () => {
it('exists', () => {
expect(Explorer).toBeDefined();
});
it('has Icon', () => {
expect(Icon).toBeDefined();
});
describe('Icon', () => {
it('exists', () => {
expect(Icon).toBeDefined();
});
it('has PublicationStatus', () => {
expect(PublicationStatus).toBeDefined();
});
describe('LoadingIndicator', () => {
it('exists', () => {
expect(LoadingIndicator).toBeDefined();
});
it('has LoadingSpinner', () => {
expect(LoadingSpinner).toBeDefined();
});
describe('AbsoluteDate', () => {
it('exists', () => {
expect(AbsoluteDate).toBeDefined();
});
it('has Transition', () => {
expect(Transition).toBeDefined();
});
describe('PublicationStatus', () => {
it('exists', () => {
expect(PublicationStatus).toBeDefined();
});
it('has Explorer', () => {
expect(Explorer).toBeDefined();
});
it('has ExplorerToggle', () => {
expect(ExplorerToggle).toBeDefined();
});
it('has initExplorer', () => {
expect(initExplorer).toBeDefined();
});
});

View file

@ -0,0 +1,38 @@
// Mocking the global.fetch and global.Headers
global.fetch = jest.fn();
global.Headers = jest.fn();
// Helper to mock a success response.
fetch.mockResponseSuccess = (body) => {
fetch.mockImplementationOnce(() => Promise.resolve({
json: () => Promise.resolve(JSON.parse(body)),
status: 200,
statusText: 'OK',
}));
};
// Helper to mock a failure response.
fetch.mockResponseFailure = () => {
fetch.mockImplementationOnce(() => Promise.resolve({
status: 500,
statusText: 'Internal Error',
}));
};
fetch.mockResponseCrash = () => {
fetch.mockImplementationOnce(() => Promise.reject({
status: 500,
statusText: 'Internal Error',
}));
};
// Helper to mock a timeout response.
fetch.mockResponseTimeout = () => {
fetch.mockImplementationOnce(() => {
const timeout = 1000;
return new Promise((resolve) => {
setTimeout(() => setTimeout(resolve, timeout), timeout);
});
});
};

View file

@ -9,6 +9,7 @@ global.wagtailConfig = {
DOCUMENTS: '/admin/api/v2beta/documents/',
IMAGES: '/admin/api/v2beta/images/',
PAGES: '/admin/api/v2beta/pages/',
EXTRA_CHILDREN_PARAMETERS: '',
},
ADMIN_URLS: {
PAGES: '/admin/pages/',
@ -18,11 +19,15 @@ global.wagtailConfig = {
SHORT_DATE_FORMAT: 'DD/MM/YYYY',
},
STRINGS: {
EXPLORER: 'Explorer',
EDIT: 'Edit',
PAGE: 'Page',
PAGES: 'Pages',
LOADING: 'Loading...',
SERVER_ERROR: 'Server Error',
NO_RESULTS: 'No results',
SEE_CHILDREN: 'See Children',
NO_DATE: 'No date',
SEE_CHILDREN: 'See children',
SEE_ALL: 'See all',
CLOSE_EXPLORER: 'Close explorer',
},
};

View file

@ -1,37 +1,43 @@
const path = require('path');
const glob = require('glob');
const webpack = require('webpack');
const COMMON_PATH = './wagtail/wagtailadmin/static/wagtailadmin/js/common.js';
// Generates a path to an entry file to be compiled by Webpack.
const getEntryPath = (app, filename) => path.resolve('wagtail', app, 'static_src', app, 'app', filename);
// Generates a path to the output bundle to be loaded in the browser.
const getOutputPath = (app, filename) => path.join('wagtail', app, 'static', app, 'js', filename);
function getEntryPoint(filename) {
const appName = filename.split(path.sep)[2];
const entryName = path.basename(filename, '.entry.js');
const outputPath = path.join('wagtail', appName, 'static', appName, 'js', entryName);
const entry = {};
entry[outputPath] = ['whatwg-fetch', 'babel-polyfill', filename];
return entry;
}
function entryPoints(globPath) {
const paths = glob.sync(globPath);
return paths.reduce((entries, p) => Object.assign(entries, getEntryPoint(p)), {});
}
const isVendorModule = (module) => {
const res = module.resource;
return res && res.indexOf('node_modules') >= 0 && res.match(/\.js$/);
};
module.exports = function exports() {
const entry = {
// Create a vendor chunk that will contain polyfills, and all third-party dependencies.
vendor: ['whatwg-fetch', 'babel-polyfill'],
};
entry[getOutputPath('wagtailadmin', 'wagtailadmin')] = getEntryPath('wagtailadmin', 'wagtailadmin.entry.js');
return {
entry: entryPoints('./wagtail/**/static_src/**/app/*.entry.js'),
entry: entry,
output: {
path: './',
path: '.',
filename: '[name].js',
publicPath: '/static/js/'
},
plugins: [
new webpack.optimize.CommonsChunkPlugin('common', COMMON_PATH, Infinity)
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
filename: getOutputPath('wagtailadmin', '[name].js'),
minChunks: isVendorModule,
}),
],
resolve: {
alias: {
'wagtail-client': path.resolve('.', 'client'),
},
},
module: {
loaders: [
{
@ -39,6 +45,18 @@ module.exports = function exports() {
loader: 'babel'
},
]
}
},
stats: {
// Add chunk information (setting this to `false` allows for a less verbose output)
chunks: false,
// Add the hash of the compilation
hash: false,
// `webpack --colors` equivalent
colors: true,
// Add information about the reasons why modules are included
reasons: false,
// Add webpack version information
version: false,
},
};
};

10465
npm-shrinkwrap.json generated

File diff suppressed because it is too large Load diff

View file

@ -14,9 +14,9 @@
},
"browserify-shim": {},
"jest": {
"rootDir": "client",
"setupFiles": [
"./tests/stubs.js"
"./client/tests/stubs.js",
"./client/tests/mock-fetch.js"
],
"snapshotSerializers": [
"enzyme-to-json/serializer"
@ -25,7 +25,7 @@
"devDependencies": {
"babel-cli": "^6.22.2",
"babel-core": "^6.22.1",
"babel-jest": "^18.0.0",
"babel-jest": "^19.0.0",
"babel-loader": "^6.2.10",
"babel-plugin-lodash": "^3.2.11",
"babel-polyfill": "^6.22.0",
@ -40,7 +40,6 @@
"eslint-plugin-jsx-a11y": "^1.5.3",
"eslint-plugin-react": "^5.2.2",
"exports-loader": "^0.6.3",
"glob": "^7.0.0",
"gulp": "~3.8.11",
"gulp-autoprefixer": "~3.0.2",
"gulp-rename": "^1.2.2",
@ -48,16 +47,16 @@
"gulp-sourcemaps": "~1.5.2",
"gulp-util": "~2.2.14",
"imports-loader": "^0.6.5",
"jest": "^18.1.0",
"jest": "^19.0.0",
"mustache": "^2.2.1",
"react-addons-test-utils": "^15.4.2",
"redux-mock-store": "^1.2.2",
"require-dir": "^0.3.0",
"webpack": "^1.12.14"
},
"dependencies": {
"focus-trap-react": "^3.0.2",
"lodash": "^4.17.4",
"moment": "^2.17.1",
"react": "^15.4.2",
"react-addons-css-transition-group": "^15.4.2",
"react-dom": "^15.4.2",
@ -69,8 +68,8 @@
},
"scripts": {
"postinstall": "cd ./client; npm install; cd ..",
"build": "gulp build; webpack --progress --colors --config ./client/webpack/prod.config.js",
"watch": "webpack --progress --colors --config ./client/webpack/dev.config.js & gulp watch",
"build": "gulp build; webpack --config ./client/webpack/prod.config.js",
"watch": "webpack --config ./client/webpack/dev.config.js & gulp watch",
"start": "npm run watch",
"lint:js": "eslint --max-warnings 16 ./client",
"lint": "npm run lint:js",

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