Change explorer behavior for pages w/ children, with tests

pull/3481/merge
Sævar Öfjörð Magnússon 2017-02-09 15:52:07 +00:00 zatwierdzone przez Thibaud Colas
rodzic 26695a09c8
commit 81c6f3e3b1
23 zmienionych plików z 3732 dodań i 4683 usunięć

Wyświetl plik

@ -9,9 +9,8 @@ export const getChildPages = (id, options = {}) => {
url += `&fields=${global.encodeURIComponent(options.fields.join(','))}`;
}
if (options.filter) {
url += `&${options.filter}`;
}
// Only show pages that have children for now
url += `&has_children=1`;
return get(url).then(res => res.body);
};

Wyświetl plik

@ -1,6 +1,6 @@
exports[`AbsoluteDate #time 1`] = `
<span>
19.09.2016
Sep. 19, 2016
</span>
`;

Wyświetl plik

@ -0,0 +1,22 @@
import React from 'react';
import { ADMIN_URLS, STRINGS } from 'config/wagtail';
const PageCount = ({ id, count, title }) => (
<a
href={`${ADMIN_URLS.PAGES}${id}/`}
className="c-explorer__see-more"
>
{STRINGS.EXPLORE_ALL_IN}{' '}
<span className="c-explorer__see-more__title">{title}</span>{' '}
({count} {count !== 1 ? STRINGS.PAGES : STRINGS.PAGE})
</a>
);
PageCount.propTypes = {
id: React.PropTypes.number.isRequired,
count: React.PropTypes.number.isRequired,
title: React.PropTypes.string.isRequired,
};
export default PageCount;

Wyświetl plik

@ -1,57 +1,8 @@
import * as actions from '../actions';
import rootReducer from './index';
import explorer from './explorer';
import nodes from './nodes';
import transport from './transport';
describe('explorer reducers', () => {
describe('root', () => {
it('exists', () => {
expect(rootReducer).toBeDefined();
});
});
describe('explorer', () => {
it('exists', () => {
expect(explorer).toBeDefined();
});
});
describe('nodes', () => {
it('exists', () => {
expect(nodes).toBeDefined();
});
});
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);
});
describe('root', () => {
it('exists', () => {
expect(rootReducer).toBeDefined();
});
});

Wyświetl plik

@ -34,7 +34,7 @@ class Explorer extends React.Component {
}
render() {
const { isVisible, nodes, path, pageTypes, type, filter, fetching, resolved } = this.props;
const { isVisible, nodes, path, pageTypes, type, fetching, resolved } = this.props;
const page = this.getPage();
const explorerProps = {
@ -43,14 +43,12 @@ class Explorer extends React.Component {
page,
type,
fetching,
filter,
nodes,
resolved,
ref: 'explorer',
onPop: this.props.onPop,
onClose: this.props.onClose,
transport: this.props.transport,
onFilter: this.props.onFilter,
getChildren: this.props.getChildren,
loadItemWithChildren: this.props.loadItemWithChildren,
pushPage: this.props.pushPage,
@ -78,7 +76,6 @@ Explorer.propTypes = {
resolved: React.PropTypes.bool.isRequired,
path: React.PropTypes.array,
type: React.PropTypes.string.isRequired,
filter: React.PropTypes.string.isRequired,
nodes: React.PropTypes.object.isRequired,
transport: React.PropTypes.object.isRequired,
page: React.PropTypes.any,
@ -87,7 +84,6 @@ Explorer.propTypes = {
setDefaultPage: React.PropTypes.func.isRequired,
onShow: React.PropTypes.func.isRequired,
onClose: React.PropTypes.func.isRequired,
onFilter: React.PropTypes.func.isRequired,
getChildren: React.PropTypes.func.isRequired,
loadItemWithChildren: React.PropTypes.func.isRequired,
pushPage: React.PropTypes.func.isRequired,
@ -105,7 +101,6 @@ const mapStateToProps = (state) => ({
// indexes: state.entities.indexes,
nodes: state.nodes,
animation: state.explorer.animation,
filter: state.explorer.filter,
transport: state.transport
});
@ -113,7 +108,6 @@ const mapDispatchToProps = (dispatch) => ({
setDefaultPage: (id) => dispatch(actions.setDefaultPage(id)),
getChildren: (id) => dispatch(actions.fetchChildren(id)),
onShow: () => dispatch(actions.fetchRoot()),
onFilter: (filter) => dispatch(actions.setFilter(filter)),
loadItemWithChildren: (id) => dispatch(actions.fetchPage(id)),
pushPage: (id) => dispatch(actions.pushPage(id)),
onPop: () => dispatch(actions.popPage()),

Wyświetl plik

@ -1,12 +1,11 @@
import React from 'react';
import CSSTransitionGroup from 'react-addons-css-transition-group';
import { EXPLORER_ANIM_DURATION, EXPLORER_FILTERS } from '../../config/config';
import { EXPLORER_ANIM_DURATION } from '../../config/config';
import { STRINGS } from '../../config/wagtail';
import Icon from '../../components/Icon/Icon';
import Filter from '../../components/Explorer/Filter';
const ExplorerHeader = ({ page, depth, filter, onPop, onFilter, transName }) => {
const ExplorerHeader = ({ page, depth, onPop, transName }) => {
const title = depth < 2 || !page ? STRINGS.PAGES : page.title;
const transitionProps = {
@ -34,16 +33,6 @@ const ExplorerHeader = ({ page, depth, filter, onPop, onFilter, transName }) =>
</CSSTransitionGroup>
</span>
</span>
<span className="c-explorer__filter">
{EXPLORER_FILTERS.map((item) => (
<Filter
key={item.id}
{...item}
activeFilter={filter}
onFilter={onFilter}
/>
))}
</span>
</div>
);
};
@ -51,9 +40,7 @@ const ExplorerHeader = ({ page, depth, filter, onPop, onFilter, transName }) =>
ExplorerHeader.propTypes = {
page: React.PropTypes.object,
depth: React.PropTypes.number,
filter: React.PropTypes.string,
onPop: React.PropTypes.func,
onFilter: React.PropTypes.func,
transName: React.PropTypes.string,
};

Wyświetl plik

@ -6,7 +6,7 @@ import Button from '../../components/Button/Button';
import PublicationStatus from '../../components/PublicationStatus/PublicationStatus';
import AbsoluteDate from '../../components/AbsoluteDate/AbsoluteDate';
const ExplorerItem = ({ title, typeName, data, filter, onItemClick }) => {
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;
@ -16,7 +16,7 @@ const ExplorerItem = ({ title, typeName, data, filter, onItemClick }) => {
// // TODO refactor.
let count = 0;
if (meta) {
count = filter.match(/has_children/) ? meta.descendants.count - meta.children.count : meta.children.count;
count = meta.children.count;
}
const hasChildren = count > 0;
@ -28,7 +28,7 @@ const ExplorerItem = ({ title, typeName, data, filter, onItemClick }) => {
className="c-explorer__children"
onClick={onItemClick.bind(null, id)}
>
<Icon name="folder-inverse" title={STRINGS.SEE_CHILDREN} />
<Icon name="arrow-right" title={STRINGS.SEE_CHILDREN} />
</span>
) : null}
@ -44,13 +44,11 @@ const ExplorerItem = ({ title, typeName, data, filter, onItemClick }) => {
ExplorerItem.propTypes = {
title: React.PropTypes.string,
data: React.PropTypes.object,
filter: React.PropTypes.string,
typeName: React.PropTypes.string,
onItemClick: React.PropTypes.func,
};
ExplorerItem.defaultProps = {
filter: '',
data: {},
onItemClick: () => {},
};

Wyświetl plik

@ -8,6 +8,7 @@ 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) {
@ -105,7 +106,7 @@ export default class ExplorerPanel extends React.Component {
}
renderChildren(page) {
const { nodes, pageTypes, filter } = this.props;
const { nodes, pageTypes } = this.props;
if (!page || !page.children.items) {
return [];
@ -122,7 +123,6 @@ export default class ExplorerPanel extends React.Component {
title: item.title,
typeName,
data: item,
filter,
};
return (
@ -153,8 +153,6 @@ export default class ExplorerPanel extends React.Component {
page,
onPop,
onClose,
onFilter,
filter,
path,
resolved
} = this.props;
@ -169,8 +167,6 @@ export default class ExplorerPanel extends React.Component {
page,
onPop,
onClose,
onFilter,
filter
};
const transitionTargetProps = {
@ -202,8 +198,11 @@ export default class ExplorerPanel extends React.Component {
{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>
@ -219,8 +218,6 @@ ExplorerPanel.propTypes = {
onPop: React.PropTypes.func.isRequired,
onClose: React.PropTypes.func.isRequired,
type: React.PropTypes.string.isRequired,
onFilter: React.PropTypes.func.isRequired,
filter: React.PropTypes.string.isRequired,
path: React.PropTypes.array,
resolved: React.PropTypes.bool.isRequired,
init: React.PropTypes.func.isRequired,

Wyświetl plik

@ -1,20 +0,0 @@
import React from 'react';
// TODO Do not use a span for a clickable element.
const Filter = ({ label, filter = null, activeFilter, onFilter }) => (
<span
className={`c-filter${activeFilter === filter ? ' c-filter--active' : ''}`}
onClick={onFilter.bind(this, filter)}
>
{label}
</span>
);
Filter.propTypes = {
label: React.PropTypes.string.isRequired,
filter: React.PropTypes.string,
activeFilter: React.PropTypes.string,
onFilter: React.PropTypes.func.isRequired,
};
export default Filter;

Wyświetl plik

@ -38,7 +38,6 @@ export function fetchChildren(id = 'root') {
return admin.getChildPages(id, {
fields: explorer.fields,
filter: explorer.filter,
}).then(json => dispatch(fetchChildrenSuccess(id, json)));
};
}
@ -84,23 +83,6 @@ export function fetchRoot() {
export const toggleExplorer = createAction('TOGGLE_EXPLORER');
export function setFilter(filter) {
return (dispatch, getState) => {
const { explorer } = getState();
const id = explorer.path[explorer.path.length - 1];
dispatch({
payload: {
filter,
id,
},
type: 'SET_FILTER',
});
dispatch(fetchChildren(id));
};
}
/**
* TODO: determine if page is already loaded, don't load it again, just push.
*/

Wyświetl plik

@ -10,12 +10,11 @@ const defaultState = {
// 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'],
filter: 'has_children=1',
// Coming from the API in order to get translated / pluralised labels.
pageTypes: {},
};
export default function explorer(state = defaultState, action) {
export default function explorer(state = defaultState, action = {}) {
let newNodes = state.path;
switch (action.type) {
@ -87,11 +86,6 @@ export default function explorer(state = defaultState, action) {
pageTypes: _.assign({}, state.pageTypes, action.payload.json.__types),
});
case 'SET_FILTER':
return _.assign({}, state, {
filter: action.filter
});
default:
return state;
}

Wyświetl plik

@ -0,0 +1,55 @@
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"])}))
});
});

Wyświetl plik

@ -34,7 +34,7 @@ const defaultState = {
};
// TODO Why isn't the default state used on init?
export default function nodes(state = {}, action) {
export default function nodes(state = {}, action = {}) {
switch (action.type) {
case 'FETCH_CHILDREN_START':
// TODO Very hard to understand this code. To refactor.
@ -67,27 +67,6 @@ export default function nodes(state = {}, action) {
case 'RESET_TREE':
return defaultState;
// eslint-disable-next-line no-case-declarations
case 'SET_FILTER':
// Unset all isLoaded states when the filter changes
const updatedState = {};
// TODO Do not use for in.
// TODO Very hard to understand this code. To refactor.
// eslint-disable-next-line
for (let key in state) {
if (state.hasOwnProperty(key)) {
// eslint-disable-next-line prefer-const
let obj = state[key];
obj.children.isLoaded = false;
updatedState[obj.id] = _.assign({}, obj, {
isLoaded: false,
});
}
}
return _.assign({}, updatedState);
case 'FETCH_START':
return _.assign({}, state, {
[action.payload]: _.assign({}, defaultState, state[action.payload], {

Wyświetl plik

@ -0,0 +1,72 @@
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'})
})
});

Wyświetl plik

@ -0,0 +1,36 @@
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);
});
});

Wyświetl plik

@ -1,5 +1,5 @@
$c-explorer-bg: #4C4E4D;
$c-explorer-secondary: #aaa;
$c-explorer-secondary: #cacaca;
$c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
.c-explorer, .c-explorer * {
@ -31,7 +31,7 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
padding: .5rem 1rem;
white-space: nowrap;
overflow: hidden;
width: 80%;
width: 100%;
float: left;
}
@ -44,34 +44,6 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
}
}
.c-explorer__filter {
float: right;
width: 50px;
margin-top: .5rem;
}
.c-filter {
display: inline-block;
vertical-align: middle;
padding: 0 .25em;
border: solid 1px rgba(255,255,255,0.1);
border-radius: 2px;
line-height: 1;
margin-left: .25rem;
cursor: pointer;
&:hover {
background: rgba(0,0,0,0.5);
border-color: rgba(0,0,0,0.5);
color: $color-white;
}
}
.c-filter--active {
color: $color-white;
border-color: rgba(255, 255, 255, .5);
}
.c-explorer__back {
margin-right: .25rem;
float: left;
@ -126,6 +98,7 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
.c-explorer__item {
display: block;
position: relative;
&:hover {
background: rgba(0, 0, 0, 0.25);
@ -134,25 +107,35 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
}
.c-explorer__see-more {
cursor: pointer;
padding: .5rem 1rem;
padding: 1rem;
background: rgba(0,0,0,0.2);
color: $color-white;
color: $c-explorer-secondary;
display: block;
&:hover {
color: $c-explorer-secondary;
background: rgba(0,0,0,0.4);
}
}
.c-explorer__see-more__title {
color: $color-white;
}
.c-explorer__children {
display: inline-block;
border-radius: 50rem;
border: solid 1px #aaa;
color: $color-white;
line-height: 1;
padding: .5em .3em .5em .5em;
padding: .7em .3em .7em .7em;
float: right;
cursor: pointer;
display: inline-block;
position: absolute;
right: 0;
top: 0;
bottom: 0;
font-size: 2em;
&:hover {
background: rgba(0,0,0,0.5);
@ -162,7 +145,7 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
.c-status {
background: $color-grey-1;
color: #ddd;
color: $c-explorer-secondary;
text-transform: uppercase;
letter-spacing: .03rem;
font-size: 10px;

Wyświetl plik

@ -1,10 +1,3 @@
export const PAGES_ROOT_ID = 'root';
export const EXPLORER_ANIM_DURATION = 220;
// TODO Add back in when we want to support explorer that displays pages
// without children (API call without has_children=1).
export const EXPLORER_FILTERS = [
{ id: 1, label: 'A', filter: null },
{ id: 2, label: 'B', filter: 'has_children=1' }
];

Wyświetl plik

@ -1,7 +1,6 @@
import {
PAGES_ROOT_ID,
EXPLORER_ANIM_DURATION,
EXPLORER_FILTERS,
} from './config';
describe('config', () => {
@ -17,9 +16,4 @@ describe('config', () => {
});
});
describe('EXPLORER_FILTERS', () => {
it('exists', () => {
expect(EXPLORER_FILTERS).toBeDefined();
});
});
});

Wyświetl plik

@ -2,4 +2,4 @@ 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 = 'DD.MM.YYYY';
export const DATE_FORMAT = global.wagtailConfig.DATE_FORMATTING.DATE_FORMAT;

Wyświetl plik

@ -13,6 +13,10 @@ global.wagtailConfig = {
ADMIN_URLS: {
PAGES: '/admin/pages/',
},
DATE_FORMATTING: {
DATE_FORMAT: 'MMM. D, YYYY',
SHORT_DATE_FORMAT: 'DD/MM/YYYY',
},
STRINGS: {
EXPLORER: 'Explorer',
LOADING: 'Loading...',

7951
npm-shrinkwrap.json wygenerowano

Plik diff jest za duży Load Diff

Wyświetl plik

@ -23,14 +23,14 @@
]
},
"devDependencies": {
"babel-cli": "^6.5.1",
"babel-core": "^6.5.2",
"babel-cli": "^6.22.2",
"babel-core": "^6.22.1",
"babel-jest": "^18.0.0",
"babel-loader": "^6.2.3",
"babel-plugin-lodash": "^3.2.9",
"babel-polyfill": "^6.5.0",
"babel-preset-es2015": "^6.5.0",
"babel-preset-react": "^6.5.0",
"babel-loader": "^6.2.10",
"babel-plugin-lodash": "^3.2.11",
"babel-polyfill": "^6.22.0",
"babel-preset-es2015": "^6.22.0",
"babel-preset-react": "^6.22.0",
"enzyme": "^2.3.0",
"enzyme-to-json": "^1.4.5",
"eslint": "^2.9.0",

Wyświetl plik

@ -24,12 +24,22 @@
IMAGES: '{% url "wagtailadmin_api_v1:images:listing" %}'
};
// We are using Moment.js formatting syntax for now,
// TODO: Use django settings defaults and find a way
// to parse them in moment.js
wagtailConfig.DATE_FORMATTING = {
DATE_FORMAT: 'MMM. D, YYYY',
SHORT_DATE_FORMAT: 'DD/MM/YYYY'
};
wagtailConfig.STRINGS = {
PAGE: "{% trans 'Page' %}",
PAGES: "{% trans 'Pages' %}",
LOADING: "{% trans 'Loading...' %}",
NO_RESULTS: "{% trans 'No results' %}",
SEE_CHILDREN: "{% trans 'See Children' %}",
NO_DATE: "{% trans 'No date' %}",
EXPLORE_ALL_IN: "{% trans 'Explore all in' %}",
};
wagtailConfig.ADMIN_URLS = {