Răsfoiți Sursa

Tidy up new React + API explorer for mobile (fixes #3607) (#3635)

* Remove useless CSS declaration

* Remove commented out styles

* Merge duplicate declarations

* Remove even more commented out code

* Move footer mq to footer declaration

* Remove more useless code

* Stop vendor prefixing for IE below 11

* Remove useless vendor prefixing

* Merge identical declarations

* Fix 1px overflow in content wrapper

* Fix explorer scrolling when open on mobile

* Remove unused import

* Add Redux performance measurements to explorer menu

* Rewrite explorer reducer to avoid unnecessary operations

* Stop changing reducer state on every action regardless of type

* Remove redundant children.isFetching property in nodes reducer

* Remove redundant children.isLoaded property in nodes reducer

* Remove redundant children.isError property in nodes reducer

* Refactor nodes explorer reducer with sub-reducer

* Fix linting issue

* Remove unused class name

* Change default icon className from empty string to null

* Remove old TODO comment

* Hoist icons in ExplorerItem component for better performance

* Add comment

* Add tooling for performance measurement of React components

* Clean up explorer panel component definition

* Make performance measurements opt-in

* Improve alignment of page explorer menu on mobile

* Close explorer on touchend rather than touchstart

* Comment out performance measurement code

* Remove fade transition completely
Thibaud Colas 7 ani în urmă
părinte
comite
61b6de2e4e
24 a modificat fișierele cu 274 adăugiri și 207 ștergeri
  1. 17 5
      client/src/components/Explorer/Explorer.scss
  2. 20 5
      client/src/components/Explorer/ExplorerItem.js
  3. 5 1
      client/src/components/Explorer/ExplorerItem.scss
  4. 17 7
      client/src/components/Explorer/ExplorerPanel.js
  5. 0 6
      client/src/components/Explorer/__snapshots__/Explorer.test.js.snap
  6. 3 3
      client/src/components/Explorer/__snapshots__/ExplorerHeader.test.js.snap
  7. 14 14
      client/src/components/Explorer/__snapshots__/ExplorerItem.test.js.snap
  8. 5 0
      client/src/components/Explorer/__snapshots__/ExplorerPanel.test.js.snap
  9. 3 3
      client/src/components/Explorer/__snapshots__/PageCount.test.js.snap
  10. 1 1
      client/src/components/Explorer/actions.js
  11. 3 5
      client/src/components/Explorer/actions.test.js
  12. 6 0
      client/src/components/Explorer/index.js
  13. 14 17
      client/src/components/Explorer/reducers/__snapshots__/nodes.test.js.snap
  14. 14 15
      client/src/components/Explorer/reducers/explorer.js
  15. 54 37
      client/src/components/Explorer/reducers/nodes.js
  16. 2 2
      client/src/components/Icon/Icon.js
  17. 1 2
      client/src/components/Transition/Transition.js
  18. 0 26
      client/src/components/Transition/Transition.scss
  19. 86 0
      client/src/utils/performance.js
  20. 1 1
      gulpfile.js/tasks/styles.js
  21. 1 0
      package.json
  22. 2 7
      wagtail/wagtailadmin/static_src/wagtailadmin/scss/components/_main-nav.scss
  23. 5 49
      wagtail/wagtailadmin/static_src/wagtailadmin/scss/core.scss
  24. 0 1
      wagtail/wagtailadmin/templates/wagtailadmin/admin_base.html

+ 17 - 5
client/src/components/Explorer/Explorer.scss

@@ -3,6 +3,7 @@ $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);
+$menu-footer-height: 50px;
 
 @import 'ExplorerItem';
 
@@ -92,7 +93,6 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
 
 .c-explorer__header {
     display: block;
-    height: 50px;
     background-color: $c-explorer-bg-dark;
     border-bottom: 1px solid $c-explorer-bg-dark;
     color: $color-white;
@@ -114,7 +114,7 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
 }
 
 .c-explorer__header__inner {
-    padding: 1rem;
+    padding: 1em .75em;
     overflow: hidden;
     text-overflow: ellipsis;
     white-space: nowrap;
@@ -124,17 +124,24 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
         margin-right: .25rem;
         font-size: 1rem;
     }
+
+    @include medium {
+        padding: 1em 1.5em;
+    }
 }
 
 .c-explorer__placeholder {
-    padding: 1rem;
+    padding: 1em;
     color: $color-white;
+
+    @include medium {
+        padding: 1em 1.75em;
+    }
 }
 
 .c-explorer__see-more {
     display: block;
-    padding: 1rem;
-    height: 50px;
+    padding: 1em;
     background: rgba(0,0,0,0.3);
     color: $color-white;
 
@@ -152,4 +159,9 @@ $c-explorer-easing: cubic-bezier(0.075, 0.820, 0.165, 1.000);
     @include hover {
         background: $c-explorer-bg-active;
     }
+
+    @include medium {
+        padding: 1em 1.75em;
+        height: $menu-footer-height;
+    }
 }

+ 20 - 5
client/src/components/Explorer/ExplorerItem.js

@@ -6,6 +6,23 @@ import Icon from '../../components/Icon/Icon';
 import Button from '../../components/Button/Button';
 import PublicationStatus from '../../components/PublicationStatus/PublicationStatus';
 
+// Hoist icons in the explorer item, as it is re-rendered many times.
+const childrenIcon = (
+  <Icon name="folder-inverse" />
+);
+
+const editIcon = (
+  <Icon name="edit" title={STRINGS.EDIT} />
+);
+
+const nextIcon = (
+  <Icon name="arrow-right" title={STRINGS.SEE_CHILDREN} />
+);
+
+/**
+ * One menu item in the page explorer, with different available actions
+ * and information depending on the metadata of the page.
+ */
 const ExplorerItem = ({ item, onClick }) => {
   const { id, title, meta } = item;
   const hasChildren = meta.children.count > 0;
@@ -14,9 +31,7 @@ const ExplorerItem = ({ item, onClick }) => {
   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}
+        {hasChildren ? childrenIcon : null}
 
         <h3 className="c-explorer__item__title">
           {title}
@@ -32,14 +47,14 @@ const ExplorerItem = ({ item, onClick }) => {
         href={`${ADMIN_URLS.PAGES}${id}/edit/`}
         className="c-explorer__item__action c-explorer__item__action--small"
       >
-        <Icon name="edit" title={`${STRINGS.EDIT} '${title}'`} />
+        {editIcon}
       </Button>
       {hasChildren ? (
         <Button
           className="c-explorer__item__action"
           onClick={onClick}
         >
-          <Icon name="arrow-right" title={STRINGS.SEE_CHILDREN} />
+          {nextIcon}
         </Button>
       ) : null}
     </div>

+ 5 - 1
client/src/components/Explorer/ExplorerItem.scss

@@ -8,7 +8,7 @@
     display: inline-flex;
     align-items: center;
     flex-grow: 1;
-    padding: 1.45em 1.75em;
+    padding: 1.45em 1em;
     cursor: pointer;
 
     &:focus {
@@ -25,6 +25,10 @@
     @include hover {
         background: $c-explorer-bg-active;
     }
+
+    @include medium {
+        padding: 1.45em 1.75em;
+    }
 }
 
 .c-explorer__item__link .icon {

+ 17 - 7
client/src/components/Explorer/ExplorerPanel.js

@@ -6,12 +6,16 @@ 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 Transition, { PUSH, POP } from '../Transition/Transition';
 import ExplorerHeader from './ExplorerHeader';
 import ExplorerItem from './ExplorerItem';
 import PageCount from './PageCount';
 
-export default class ExplorerPanel extends React.Component {
+/**
+ * The main panel of the page explorer menu, with heading,
+ * menu items, and special states.
+ */
+class ExplorerPanel extends React.Component {
   constructor(props) {
     super(props);
 
@@ -38,14 +42,14 @@ export default class ExplorerPanel extends React.Component {
     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);
+    document.addEventListener('touchend', 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);
+    document.removeEventListener('touchend', this.clickOutside);
   }
 
   clickOutside(e) {
@@ -136,7 +140,10 @@ export default class ExplorerPanel extends React.Component {
         tag="nav"
         className="explorer"
         paused={paused || !page || page.isFetching}
-        focusTrapOptions={{ onDeactivate: onClose }}
+        focusTrapOptions={{
+          initialFocus: '.c-explorer__close',
+          onDeactivate: onClose,
+        }}
       >
         <Button className="c-explorer__close u-hidden" onClick={onClose}>
           {STRINGS.CLOSE_EXPLORER}
@@ -163,14 +170,17 @@ export default class ExplorerPanel extends React.Component {
 
 ExplorerPanel.propTypes = {
   nodes: PropTypes.object.isRequired,
-  path: PropTypes.array,
+  path: PropTypes.array.isRequired,
   page: PropTypes.shape({
     isFetching: PropTypes.bool,
     children: PropTypes.shape({
+      count: PropTypes.number,
       items: PropTypes.array,
     }),
-  }),
+  }).isRequired,
   onClose: PropTypes.func.isRequired,
   popPage: PropTypes.func.isRequired,
   pushPage: PropTypes.func.isRequired,
 };
+
+export default ExplorerPanel;

+ 0 - 6
client/src/components/Explorer/__snapshots__/Explorer.test.js.snap

@@ -46,12 +46,10 @@ exports[`Explorer visible 1`] = `
       "1": Object {
         "children": Object {
           "count": 0,
-          "isFetching": true,
           "items": Array [],
         },
         "isError": false,
         "isFetching": true,
-        "isLoaded": true,
         "meta": Object {
           "children": Object {},
         },
@@ -101,12 +99,10 @@ exports[`Explorer visible 2`] = `
       "1": Object {
         "children": Object {
           "count": 0,
-          "isFetching": true,
           "items": Array [],
         },
         "isError": false,
         "isFetching": true,
-        "isLoaded": true,
         "meta": Object {
           "children": Object {},
         },
@@ -118,12 +114,10 @@ exports[`Explorer visible 2`] = `
     Object {
       "children": Object {
         "count": 0,
-        "isFetching": true,
         "items": Array [],
       },
       "isError": false,
       "isFetching": true,
-      "isLoaded": true,
       "meta": Object {
         "children": Object {},
       },

+ 3 - 3
client/src/components/Explorer/__snapshots__/ExplorerHeader.test.js.snap

@@ -15,7 +15,7 @@ exports[`ExplorerHeader #depth at root 1`] = `
     className="c-explorer__header__inner"
   >
     <Icon
-      className=""
+      className={null}
       name="home"
       title={null}
     />
@@ -41,7 +41,7 @@ exports[`ExplorerHeader #page 1`] = `
     className="c-explorer__header__inner"
   >
     <Icon
-      className=""
+      className={null}
       name="arrow-left"
       title={null}
     />
@@ -67,7 +67,7 @@ exports[`ExplorerHeader basic 1`] = `
     className="c-explorer__header__inner"
   >
     <Icon
-      className=""
+      className={null}
       name="arrow-left"
       title={null}
     />

+ 14 - 14
client/src/components/Explorer/__snapshots__/ExplorerItem.test.js.snap

@@ -15,7 +15,7 @@ exports[`ExplorerItem children 1`] = `
     target={null}
   >
     <Icon
-      className="c-explorer__children"
+      className={null}
       name="folder-inverse"
       title={null}
     />
@@ -36,9 +36,9 @@ exports[`ExplorerItem children 1`] = `
     target={null}
   >
     <Icon
-      className=""
+      className={null}
       name="edit"
-      title="Edit 'test'"
+      title="Edit"
     />
   </Button>
   <Button
@@ -52,7 +52,7 @@ exports[`ExplorerItem children 1`] = `
     target={null}
   >
     <Icon
-      className=""
+      className={null}
       name="arrow-right"
       title="See children"
     />
@@ -91,9 +91,9 @@ exports[`ExplorerItem renders 1`] = `
     target={null}
   >
     <Icon
-      className=""
+      className={null}
       name="edit"
-      title="Edit 'test'"
+      title="Edit"
     />
   </Button>
 </div>
@@ -114,7 +114,7 @@ exports[`ExplorerItem should show a publication status if not live 1`] = `
     target={null}
   >
     <Icon
-      className="c-explorer__children"
+      className={null}
       name="folder-inverse"
       title={null}
     />
@@ -148,9 +148,9 @@ exports[`ExplorerItem should show a publication status if not live 1`] = `
     target={null}
   >
     <Icon
-      className=""
+      className={null}
       name="edit"
-      title="Edit 'test'"
+      title="Edit"
     />
   </Button>
   <Button
@@ -164,7 +164,7 @@ exports[`ExplorerItem should show a publication status if not live 1`] = `
     target={null}
   >
     <Icon
-      className=""
+      className={null}
       name="arrow-right"
       title="See children"
     />
@@ -187,7 +187,7 @@ exports[`ExplorerItem should show a publication status with unpublished changes
     target={null}
   >
     <Icon
-      className="c-explorer__children"
+      className={null}
       name="folder-inverse"
       title={null}
     />
@@ -221,9 +221,9 @@ exports[`ExplorerItem should show a publication status with unpublished changes
     target={null}
   >
     <Icon
-      className=""
+      className={null}
       name="edit"
-      title="Edit 'test'"
+      title="Edit"
     />
   </Button>
   <Button
@@ -237,7 +237,7 @@ exports[`ExplorerItem should show a publication status with unpublished changes
     target={null}
   >
     <Icon
-      className=""
+      className={null}
       name="arrow-right"
       title="See children"
     />

+ 5 - 0
client/src/components/Explorer/__snapshots__/ExplorerPanel.test.js.snap

@@ -7,6 +7,7 @@ exports[`ExplorerPanel #isError 1`] = `
   className="explorer"
   focusTrapOptions={
     Object {
+      "initialFocus": ".c-explorer__close",
       "onDeactivate": [Function],
     }
   }
@@ -78,6 +79,7 @@ exports[`ExplorerPanel #isFetching 1`] = `
   className="explorer"
   focusTrapOptions={
     Object {
+      "initialFocus": ".c-explorer__close",
       "onDeactivate": [Function],
     }
   }
@@ -139,6 +141,7 @@ exports[`ExplorerPanel #items 1`] = `
   className="explorer"
   focusTrapOptions={
     Object {
+      "initialFocus": ".c-explorer__close",
       "onDeactivate": [Function],
     }
   }
@@ -224,6 +227,7 @@ exports[`ExplorerPanel no children 1`] = `
   className="explorer"
   focusTrapOptions={
     Object {
+      "initialFocus": ".c-explorer__close",
       "onDeactivate": [Function],
     }
   }
@@ -281,6 +285,7 @@ exports[`ExplorerPanel renders 1`] = `
   className="explorer"
   focusTrapOptions={
     Object {
+      "initialFocus": ".c-explorer__close",
       "onDeactivate": [Function],
     }
   }

+ 3 - 3
client/src/components/Explorer/__snapshots__/PageCount.test.js.snap

@@ -11,7 +11,7 @@ exports[`PageCount #title 1`] = `
      5 pages
   </span>
   <Icon
-    className=""
+    className={null}
     name="arrow-right"
     title={null}
   />
@@ -29,7 +29,7 @@ exports[`PageCount plural 1`] = `
      5 pages
   </span>
   <Icon
-    className=""
+    className={null}
     name="arrow-right"
     title={null}
   />
@@ -47,7 +47,7 @@ exports[`PageCount works 1`] = `
      1 page
   </span>
   <Icon
-    className=""
+    className={null}
     name="arrow-right"
     title={null}
   />

+ 1 - 1
client/src/components/Explorer/actions.js

@@ -87,7 +87,7 @@ export function pushPage(id) {
 
     dispatch(pushPagePrivate(id));
 
-    if (page && !page.children.isFetching && !page.children.isLoaded) {
+    if (page && !page.isFetching && !page.children.count > 0) {
       dispatch(getChildren(id));
     }
   };

+ 3 - 5
client/src/components/Explorer/actions.test.js

@@ -12,10 +12,8 @@ const stubState = {
   },
   nodes: {
     5: {
-      children: {
-        isFetching: false,
-        isLoaded: true,
-      },
+      isFetching: true,
+      children: {},
     },
   },
 };
@@ -90,7 +88,7 @@ describe('actions', () => {
 
     it('triggers getChildren', () => {
       const stub = Object.assign({}, stubState);
-      stub.nodes[5].children.isLoaded = false;
+      stub.nodes[5].isFetching = false;
       const store = mockStore(stub);
       store.dispatch(actions.pushPage(5));
       expect(store.getActions()).toMatchSnapshot();

+ 6 - 0
client/src/components/Explorer/index.js

@@ -4,6 +4,7 @@ import { Provider } from 'react-redux';
 import { createStore, combineReducers, applyMiddleware, compose } from 'redux';
 import thunkMiddleware from 'redux-thunk';
 
+// import { perfMiddleware } from '../../utils/performance';
 import Explorer from './Explorer';
 import ExplorerToggle from './ExplorerToggle';
 import explorer from './reducers/explorer';
@@ -22,6 +23,11 @@ const initExplorer = (explorerNode, toggleNode) => {
     thunkMiddleware,
   ];
 
+  // Uncomment this to use performance measurements.
+  // if (process.env.NODE_ENV !== 'production') {
+  //   middleware.push(perfMiddleware);
+  // }
+
   const store = createStore(rootReducer, {}, compose(
     applyMiddleware(...middleware),
     // Expose store to Redux DevTools extension.

+ 14 - 17
client/src/components/Explorer/reducers/__snapshots__/nodes.test.js.snap

@@ -5,12 +5,10 @@ Object {
   "1": Object {
     "children": Object {
       "count": 0,
-      "isFetching": false,
       "items": Array [],
     },
     "isError": true,
     "isFetching": false,
-    "isLoaded": true,
     "meta": Object {
       "children": Object {},
     },
@@ -22,9 +20,14 @@ exports[`nodes GET_CHILDREN_START 1`] = `
 Object {
   "1": Object {
     "children": Object {
-      "isFetching": true,
+      "count": 0,
+      "items": Array [],
     },
+    "isError": false,
     "isFetching": true,
+    "meta": Object {
+      "children": Object {},
+    },
   },
 }
 `;
@@ -34,9 +37,6 @@ Object {
   "1": Object {
     "children": Object {
       "count": 3,
-      "isError": false,
-      "isFetching": false,
-      "isLoaded": true,
       "items": Array [
         3,
         4,
@@ -45,7 +45,6 @@ Object {
     },
     "isError": false,
     "isFetching": false,
-    "isLoaded": true,
     "meta": Object {
       "children": Object {},
     },
@@ -53,13 +52,11 @@ Object {
   "3": Object {
     "children": Object {
       "count": 0,
-      "isFetching": false,
       "items": Array [],
     },
     "id": 3,
     "isError": false,
     "isFetching": false,
-    "isLoaded": true,
     "meta": Object {
       "children": Object {},
     },
@@ -67,13 +64,11 @@ Object {
   "4": Object {
     "children": Object {
       "count": 0,
-      "isFetching": false,
       "items": Array [],
     },
     "id": 4,
     "isError": false,
     "isFetching": false,
-    "isLoaded": true,
     "meta": Object {
       "children": Object {},
     },
@@ -81,13 +76,11 @@ Object {
   "5": Object {
     "children": Object {
       "count": 0,
-      "isFetching": false,
       "items": Array [],
     },
     "id": 5,
     "isError": false,
     "isFetching": false,
-    "isLoaded": true,
     "meta": Object {
       "children": Object {},
     },
@@ -100,12 +93,10 @@ Object {
   "1": Object {
     "children": Object {
       "count": 0,
-      "isFetching": false,
       "items": Array [],
     },
     "isError": true,
     "isFetching": false,
-    "isLoaded": true,
     "meta": Object {
       "children": Object {},
     },
@@ -116,7 +107,15 @@ Object {
 exports[`nodes GET_PAGE_SUCCESS 1`] = `
 Object {
   "1": Object {
+    "children": Object {
+      "count": 0,
+      "items": Array [],
+    },
     "isError": false,
+    "isFetching": false,
+    "meta": Object {
+      "children": Object {},
+    },
   },
 }
 `;
@@ -126,12 +125,10 @@ Object {
   "1": Object {
     "children": Object {
       "count": 0,
-      "isFetching": false,
       "items": Array [],
     },
     "isError": false,
     "isFetching": false,
-    "isLoaded": true,
     "meta": Object {
       "children": Object {},
     },

+ 14 - 15
client/src/components/Explorer/reducers/explorer.js

@@ -9,31 +9,30 @@ const defaultState = {
  * - 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;
+    return {
+      isVisible: true,
+      path: [payload.id],
+    };
 
   case 'CLOSE_EXPLORER':
-    state.path = [];
-    state.isVisible = false;
-    break;
+    return defaultState;
 
   case 'PUSH_PAGE':
-    state.path = state.path.concat([payload.id]);
-    break;
+    return {
+      isVisible: prevState.isVisible,
+      path: prevState.path.concat([payload.id]),
+    };
 
   case 'POP_PAGE':
-    state.path = state.path.slice(0, -1);
-    break;
+    return {
+      isVisible: prevState.isVisible,
+      path: prevState.path.slice(0, -1),
+    };
 
   default:
-    break;
+    return prevState;
   }
-
-  return state;
 }

+ 54 - 37
client/src/components/Explorer/reducers/nodes.js

@@ -1,69 +1,86 @@
-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);
-
+/**
+ * A single page node in the explorer.
+ */
+const node = (state = defaultPageState, { type, payload }) => {
   switch (type) {
   case 'OPEN_EXPLORER':
-    state[payload.id] = Object.assign({}, defaultPageState, state[payload.id]);
-    break;
+    return state || defaultPageState;
 
   case 'GET_PAGE_SUCCESS':
-    state[payload.id] = Object.assign({}, state[payload.id], payload.data);
-    state[payload.id].isError = false;
-    break;
+    return Object.assign({}, state, payload.data, {
+      isError: false,
+    });
 
   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;
+    return Object.assign({}, state, {
+      isFetching: true,
+    });
 
   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,
+    return Object.assign({}, state, {
       isFetching: false,
-      isLoaded: true,
       isError: false,
+      children: {
+        items: state.children.items.slice().concat(payload.items.map(item => item.id)),
+        count: payload.meta.total_count,
+      },
     });
 
-    payload.items.forEach((item) => {
-      state[item.id] = Object.assign({}, defaultPageState, state[item.id], item);
-
-      state[payload.id].children.items.push(item.id);
+  case 'GET_PAGE_FAILURE':
+  case 'GET_CHILDREN_FAILURE':
+    return Object.assign({}, state, {
+      isFetching: false,
+      isError: true,
     });
-    break;
 
+  default:
+    return state;
+  }
+};
+
+const defaultState = {};
+
+/**
+ * Contains all of the page nodes in one object.
+ */
+export default function nodes(state = defaultState, { type, payload }) {
+  switch (type) {
+
+  case 'OPEN_EXPLORER':
+  case 'GET_PAGE_SUCCESS':
+  case 'GET_CHILDREN_START':
   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;
+    return Object.assign({}, state, {
+      // Delegate logic to single-node reducer.
+      [payload.id]: node(state[payload.id], { type, payload }),
+    });
+
+  // eslint-disable-next-line no-case-declarations
+  case 'GET_CHILDREN_SUCCESS':
+    const newState = Object.assign({}, state, {
+      [payload.id]: node(state[payload.id], { type, payload }),
+    });
+
+    payload.items.forEach((item) => {
+      newState[item.id] = Object.assign({}, defaultPageState, item);
+    });
+
+    return newState;
 
   default:
-    break;
+    return state;
   }
-
-  return state;
 }

+ 2 - 2
client/src/components/Icon/Icon.js

@@ -7,7 +7,7 @@ import React from 'react';
  */
 const Icon = ({ name, className, title }) => (
   <span>
-    <span className={`icon icon-${name} ${className}`} aria-hidden></span>
+    <span className={`icon icon-${name} ${className || ''}`} aria-hidden></span>
     {title ? (
       <span className="visuallyhidden">
         {title}
@@ -23,7 +23,7 @@ Icon.propTypes = {
 };
 
 Icon.defaultProps = {
-  className: '',
+  className: null,
   title: null,
 };
 

+ 1 - 2
client/src/components/Transition/Transition.js

@@ -8,7 +8,6 @@ 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-transition-group with default values.
@@ -32,7 +31,7 @@ const Transition = ({
 );
 
 Transition.propTypes = {
-  name: PropTypes.oneOf([PUSH, POP, FADE]).isRequired,
+  name: PropTypes.oneOf([PUSH, POP]).isRequired,
   component: PropTypes.string,
   className: PropTypes.string,
   duration: PropTypes.number,

+ 0 - 26
client/src/components/Transition/Transition.scss

@@ -57,29 +57,3 @@ $c-transition-duration: 200ms;
     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;
-}

+ 86 - 0
client/src/utils/performance.js

@@ -0,0 +1,86 @@
+import React, { Component } from 'react';
+
+/* eslint-disable import/no-mutable-exports */
+let perfMiddleware;
+
+if (process.env.NODE_ENV !== 'production') {
+  /**
+   * Performance middleware for use with a Redux store.
+   * Will log the time taken by every action across all
+   * of the reducers of the store.
+   */
+  perfMiddleware = () => {
+    /* eslint-disable no-console */
+    // `next` is a function that takes an 'action' and sends it through to the 'reducers'.
+    const middleware = (next) => (action) => {
+      let result;
+
+      if (!!console.time) {
+        console.time(action.type);
+        result = next(action);
+        console.timeEnd(action.type);
+      } else {
+        result = next(action);
+      }
+
+      return result;
+    };
+
+    return middleware;
+  };
+}
+
+let perfComponent;
+
+if (process.env.NODE_ENV !== 'production') {
+  /**
+   * Wraps the passed in `Component` in a higher-order component. It can then
+   * measure the performance of every render of the `Component`.
+   *
+   * Can also be used as an ES2016 decorator.
+   * @param  {ReactComponent} Component the component to wrap
+   * @return {ReactComponent}           the wrapped component
+   * See https://github.com/sheepsteak/react-perf-component
+   */
+  perfComponent = (Target) => {
+    if (process.env.NODE_ENV === 'production') {
+      return Target;
+    }
+
+    // eslint-disable-next-line global-require
+    const ReactPerf = require('react-addons-perf');
+
+    class Perf extends Component {
+      componentDidMount() {
+        ReactPerf.start();
+      }
+
+      componentDidUpdate() {
+        ReactPerf.stop();
+
+        const measurements = ReactPerf.getLastMeasurements();
+
+        ReactPerf.printWasted(measurements);
+        ReactPerf.start();
+      }
+
+      componentWillUnmount() {
+        ReactPerf.stop();
+      }
+
+      render() {
+        return <Target {...this.props} />;
+      }
+    }
+
+    Perf.displayName = `perf(${Target.displayName || Target.name || 'Component'})`;
+    Perf.WrappedComponent = Target;
+
+    return Perf;
+  };
+}
+
+export {
+  perfMiddleware,
+  perfComponent,
+};

+ 1 - 1
gulpfile.js/tasks/styles.js

@@ -33,7 +33,7 @@ gulp.task('styles:sass', function () {
             outputStyle: 'expanded'
         }).on('error', sass.logError))
         .pipe(autoprefixer({
-            browsers: ['last 3 versions', 'not ie <= 8'],
+            browsers: ['last 3 versions', 'ie 11'],
             cascade: false
         }))
         .pipe(gulp.dest(function(file) {

+ 1 - 0
package.json

@@ -49,6 +49,7 @@
     "imports-loader": "^0.7.1",
     "jest": "^20.0.4",
     "mustache": "^2.2.1",
+    "react-addons-perf": "^15.4.2",
     "react-addons-test-utils": "^15.4.2",
     "react-test-renderer": "^15.5.4",
     "redux-mock-store": "^1.2.2",

+ 2 - 7
wagtail/wagtailadmin/static_src/wagtailadmin/scss/components/_main-nav.scss

@@ -213,7 +213,6 @@ $footer-submenu: $submenu-color;
 body.nav-open {
     .wrapper {
         transform: translate3d($menu-width, 0, 0);
-        -webkit-transform: translate3d($menu-width, 0, 0);
     }
 
     .content-wrapper {
@@ -242,13 +241,8 @@ body.explorer-open {
 }
 
 @media screen and (min-width: $breakpoint-mobile) {
-    body.explorer-open {
-        overflow: hidden;
-    }
-
     .wrapper,
     body.nav-open .wrapper {
-        -webkit-transform: none;
         transform: none;
         padding-left: $menu-width;
     }
@@ -416,13 +410,14 @@ body.explorer-open {
     }
 
     body.explorer-open {
+        overflow: hidden;
+
         &:after {
             opacity: 1;
             visibility: visible;
         }
 
         .wrapper {
-            -webkit-transform: none;
             transform: none;
         }
 

+ 5 - 49
wagtail/wagtailadmin/static_src/wagtailadmin/scss/core.scss

@@ -85,11 +85,6 @@ body {
     transition: transform 0.2s ease;
 }
 
-.nav-wrapper {
-    // See components/main-nav.scss
-}
-
-
 .logo {
     display: block;
     text-align: left;
@@ -112,6 +107,7 @@ body {
 }
 
 .content-wrapper {
+    box-sizing: border-box;
     width: 100%;
     height: 100%; // this has no effect on desktop, but on mobile it helps aesthetics of menu popout action
     float: left;
@@ -198,30 +194,14 @@ footer {
             white-space: normal;
         }
     }
-}
-
-// Let's not, for now...
-
-// ::-webkit-scrollbar {
-//     height: 10px;
-//     width: 10px;
-//     background: $color-grey-1;
-// }
 
-// ::-webkit-scrollbar-thumb {
-//     background: $color-grey-2;
-//     -webkit-border-radius: 1ex;
-// }
-
-// ::-webkit-scrollbar-corner {
-//     background: $color-grey-1;
-// }
-
-.breadcrumb {
-    @include unlist();
+    @media screen and (min-width: 90em) {
+        width: 90em;
+    }
 }
 
 .breadcrumb {
+    @include unlist();
     @include clearfix();
     overflow: hidden;
     padding-top: 1.4em;
@@ -509,27 +489,3 @@ footer,
         z-index: 200;
     }
 }
-
-@media screen and (min-width: 90em) {
-    .wrapper {
-        // width: 100%;
-    }
-
-    footer {
-        width: 90em;
-    }
-}
-
-// Transitions (resolution agnostic)
-.content-wrapper,
-.nav-main,
-.nav-toggle,
-footer,
-.logo {
-    // @include transition(all 0.2s ease);
-}
-
-// .nav-main a,
-// a {
-    // @include transition(color 0.2s ease, background-color 0.2s ease);
-// }

+ 0 - 1
wagtail/wagtailadmin/templates/wagtailadmin/admin_base.html

@@ -24,7 +24,6 @@
                 IMAGES: '{% url "wagtailadmin_api_v1:images:listing" %}',
                 {# // Use this to add an extra query string on all API requests. #}
                 {# // Example value: '&order=-id' #}
-                {# // TODO Hook it up to Django settings #}
                 EXTRA_CHILDREN_PARAMETERS: '',
             };