mirror of
https://github.com/Hopiu/wagtail.git
synced 2026-03-16 22:10:28 +00:00
Update explorer for latest scope, UI, with tests
This commit is contained in:
parent
a0e4b0bafa
commit
2ff4a5aad1
116 changed files with 10216 additions and 9203 deletions
|
|
@ -3,5 +3,13 @@
|
|||
|
||||
"env": {
|
||||
"jest": true
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"webpack": {
|
||||
"config": "client/webpack/prod.config.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
14
client/scss/_tools.breakpoints.scss
Normal file
14
client/scss/_tools.breakpoints.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
3
client/scss/_utilities.scss
Normal file
3
client/scss/_utilities.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.u-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
.o-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
.is-spinning {
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -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';
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
.u-text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 15em) {
|
||||
.u-text-center\@sm {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
21
client/src/api/__snapshots__/client.test.js.snap
Normal file
21
client/src/api/__snapshots__/client.test.js.snap
Normal 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]`;
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
53
client/src/api/admin.test.js
Normal file
53
client/src/api/admin.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
43
client/src/api/client.test.js
Normal file
43
client/src/api/client.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
exports[`AbsoluteDate #time 1`] = `
|
||||
<span>
|
||||
Sep. 19, 2016
|
||||
</span>
|
||||
`;
|
||||
|
||||
exports[`AbsoluteDate basic 1`] = `
|
||||
<span>
|
||||
No date
|
||||
</span>
|
||||
`;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
`;
|
||||
|
|
|
|||
52
client/src/components/Explorer/Explorer.js
Normal file
52
client/src/components/Explorer/Explorer.js
Normal 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);
|
||||
124
client/src/components/Explorer/Explorer.scss
Normal file
124
client/src/components/Explorer/Explorer.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
37
client/src/components/Explorer/ExplorerHeader.js
Normal file
37
client/src/components/Explorer/ExplorerHeader.js
Normal 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;
|
||||
36
client/src/components/Explorer/ExplorerHeader.test.js
Normal file
36
client/src/components/Explorer/ExplorerHeader.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
59
client/src/components/Explorer/ExplorerItem.js
Normal file
59
client/src/components/Explorer/ExplorerItem.js
Normal 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;
|
||||
85
client/src/components/Explorer/ExplorerItem.scss
Normal file
85
client/src/components/Explorer/ExplorerItem.scss
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
175
client/src/components/Explorer/ExplorerPanel.js
Normal file
175
client/src/components/Explorer/ExplorerPanel.js
Normal 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,
|
||||
};
|
||||
182
client/src/components/Explorer/ExplorerPanel.test.js
Normal file
182
client/src/components/Explorer/ExplorerPanel.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
36
client/src/components/Explorer/ExplorerToggle.js
Normal file
36
client/src/components/Explorer/ExplorerToggle.js
Normal 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);
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
35
client/src/components/Explorer/PageCount.test.js
Normal file
35
client/src/components/Explorer/PageCount.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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]}
|
||||
/>
|
||||
`;
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -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],
|
||||
}
|
||||
} />
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
exports[`LoadingSpinner basic 1`] = `
|
||||
<div
|
||||
className="c-explorer__loading">
|
||||
<Icon
|
||||
className="c-explorer__spinner"
|
||||
name="spinner"
|
||||
title={null} />
|
||||
|
||||
Loading...
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
`;
|
||||
94
client/src/components/Explorer/actions.js
Normal file
94
client/src/components/Explorer/actions.js
Normal 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));
|
||||
}
|
||||
};
|
||||
}
|
||||
99
client/src/components/Explorer/actions.test.js
Normal file
99
client/src/components/Explorer/actions.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
51
client/src/components/Explorer/index.js
Normal file
51
client/src/components/Explorer/index.js
Normal 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,
|
||||
};
|
||||
28
client/src/components/Explorer/index.test.js
Normal file
28
client/src/components/Explorer/index.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
|
@ -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 {}`;
|
||||
39
client/src/components/Explorer/reducers/explorer.js
Normal file
39
client/src/components/Explorer/reducers/explorer.js
Normal 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;
|
||||
}
|
||||
32
client/src/components/Explorer/reducers/explorer.test.js
Normal file
32
client/src/components/Explorer/reducers/explorer.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import * as actions from '../actions';
|
||||
import rootReducer from './index';
|
||||
|
||||
describe('root', () => {
|
||||
it('exists', () => {
|
||||
expect(rootReducer).toBeDefined();
|
||||
});
|
||||
});
|
||||
69
client/src/components/Explorer/reducers/nodes.js
Normal file
69
client/src/components/Explorer/reducers/nodes.js
Normal 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;
|
||||
}
|
||||
59
client/src/components/Explorer/reducers/nodes.test.js
Normal file
59
client/src/components/Explorer/reducers/nodes.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
exports[`LoadingIndicator basic 1`] = `
|
||||
<div
|
||||
className="o-icon c-indicator is-spinning">
|
||||
<span
|
||||
ariaRole="presentation">
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
14
client/src/components/LoadingSpinner/LoadingSpinner.js
Normal file
14
client/src/components/LoadingSpinner/LoadingSpinner.js
Normal 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;
|
||||
5
client/src/components/LoadingSpinner/LoadingSpinner.scss
Normal file
5
client/src/components/LoadingSpinner/LoadingSpinner.scss
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.c-spinner:after {
|
||||
display: inline-block;
|
||||
animation: spin 0.5s infinite linear;
|
||||
line-height: 1;
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
.c-status {
|
||||
background: $color-grey-1;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .03rem;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
|
@ -8,10 +8,6 @@ describe('PublicationStatus', () => {
|
|||
expect(PublicationStatus).toBeDefined();
|
||||
});
|
||||
|
||||
it('basic', () => {
|
||||
expect(shallow(<PublicationStatus />)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('#status live', () => {
|
||||
expect(shallow((
|
||||
<PublicationStatus
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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`;
|
||||
|
|
|
|||
48
client/src/components/Transition/Transition.js
Normal file
48
client/src/components/Transition/Transition.js
Normal 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;
|
||||
85
client/src/components/Transition/Transition.scss
Normal file
85
client/src/components/Transition/Transition.scss
Normal 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;
|
||||
}
|
||||
14
client/src/components/Transition/Transition.test.js
Normal file
14
client/src/components/Transition/Transition.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
/>
|
||||
`;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"])}))
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'})
|
||||
})
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export const PAGES_ROOT_ID = 'root';
|
||||
|
||||
export const EXPLORER_ANIM_DURATION = 220;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
38
client/tests/mock-fetch.js
Normal file
38
client/tests/mock-fetch.js
Normal 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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
10465
npm-shrinkwrap.json
generated
File diff suppressed because it is too large
Load diff
15
package.json
15
package.json
|
|
@ -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
Loading…
Reference in a new issue