Ver código fonte

Implement locale selector in React page explorer (#6481)

Karl Hobley 4 anos atrás
pai
commit
d58f74f7bd

+ 10 - 4
client/src/api/admin.test.js

@@ -20,23 +20,29 @@ describe('admin API', () => {
   describe('getPageChildren', () => {
     it('works', () => {
       getPageChildren(3);
-      expect(client.get).toBeCalledWith(`${ADMIN_API.PAGES}?child_of=3&for_explorer=1&fields=parent`);
+      expect(client.get).toBeCalledWith(`${ADMIN_API.PAGES}?child_of=3&for_explorer=1&fields=parent,translations`);
     });
 
     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&for_explorer=1&fields=parent,title%2Clatest_revision_created_at`);
+      expect(client.get).toBeCalledWith(
+        `${ADMIN_API.PAGES}?child_of=3&for_explorer=1&fields=parent,translations,title%2Clatest_revision_created_at`
+      );
     });
 
     it('#onlyWithChildren', () => {
       getPageChildren(3, { onlyWithChildren: true });
-      expect(client.get).toBeCalledWith(`${ADMIN_API.PAGES}?child_of=3&for_explorer=1&fields=parent&has_children=1`);
+      expect(client.get).toBeCalledWith(
+        `${ADMIN_API.PAGES}?child_of=3&for_explorer=1&fields=parent,translations&has_children=1`
+      );
     });
 
     it('#offset', () => {
       getPageChildren(3, { offset: 5 });
-      expect(client.get).toBeCalledWith(`${ADMIN_API.PAGES}?child_of=3&for_explorer=1&fields=parent&offset=5`);
+      expect(client.get).toBeCalledWith(
+        `${ADMIN_API.PAGES}?child_of=3&for_explorer=1&fields=parent,translations&offset=5`
+      );
     });
   });
 

+ 24 - 2
client/src/api/admin.ts

@@ -16,6 +16,7 @@ export interface WagtailPageAPI {
       id: number;
     } | null;
     locale?: string;
+    translations?: any;
   };
   /* eslint-disable-next-line camelcase */
   admin_display_title?: string;
@@ -46,9 +47,9 @@ export const getPageChildren: GetPageChildren = (id, options = {}) => {
   let url = `${ADMIN_API.PAGES}?child_of=${id}&for_explorer=1`;
 
   if (options.fields) {
-    url += `&fields=parent,${window.encodeURIComponent(options.fields.join(','))}`;
+    url += `&fields=parent,translations,${window.encodeURIComponent(options.fields.join(','))}`;
   } else {
-    url += '&fields=parent';
+    url += '&fields=parent,translations';
   }
 
   if (options.onlyWithChildren) {
@@ -63,3 +64,24 @@ export const getPageChildren: GetPageChildren = (id, options = {}) => {
 
   return get(url);
 };
+
+interface GetPageTranslationsOptions {
+  fields?: string[];
+  onlyWithChildren?: boolean;
+}
+type GetPageTranslations = (id: number, options: GetPageTranslationsOptions) => Promise<WagtailPageListAPI>;
+export const getPageTranslations: GetPageTranslations = (id, options = {}) => {
+  let url = `${ADMIN_API.PAGES}?translation_of=${id}&limit=20`;
+
+  if (options.fields) {
+    url += `&fields=parent,${global.encodeURIComponent(options.fields.join(','))}`;
+  } else {
+    url += '&fields=parent';
+  }
+
+  if (options.onlyWithChildren) {
+    url += '&has_children=1';
+  }
+
+  return get(url);
+};

+ 53 - 14
client/src/components/Explorer/Explorer.scss

@@ -136,22 +136,61 @@ $menu-footer-height: 50px;
 }
 
 .c-explorer__header__select {
-    float: right;
+    $margin: 10px;
     position: relative;
-    padding: 1em 0;
-    padding-right: 2em;
-    text-align: right;
 
-    span {
-        background-color: $c-explorer-bg-active;
-        display: inline-block;
-        padding: 0.2em 0.5em;
-        border-radius: 0.25em;
-        vertical-align: middle;
-        line-height: 1.5;
-        text-transform: uppercase;
-        letter-spacing: 0.03rem;
-        font-size: 12px;
+    > select {
+        width: calc(30% - #{$margin * 2});
+        height: calc(100% - #{$margin * 2});
+        margin-top: $margin;
+        margin-right: $margin;
+        float: right;
+        padding: 0;
+        padding-left: 10px;
+
+        background-color: $c-explorer-bg-dark;
+        border-radius: 0;
+        border-color: #4c4e4d;
+        color: $color-white;
+
+        &:disabled {
+            border: 0;
+        }
+
+        &:hover:enabled {
+            cursor: pointer;
+        }
+
+        &:hover:disabled {
+            color: inherit;
+            background-color: inherit;
+            cursor: inherit;
+        }
+
+        // Reset the arrow on `<select>`s in IE10+.
+        &::-ms-expand {
+            display: none;
+        }
+    }
+
+    // Add select arrow back on browsers where native ui has been removed
+    > span:after {
+        z-index: 0;
+        position: absolute;
+        right: $margin;
+        top: $margin + 3px;
+        bottom: 0;
+        width: 2em;
+        font-family: wagtail;
+        content: map-get($icons, 'arrow-down');
+        text-align: center;
+        font-size: 1.2em;
+        pointer-events: none;
+        color: $color-grey-3;
+
+        .ie & {
+            display: none;
+        }
     }
 }
 

+ 37 - 9
client/src/components/Explorer/ExplorerHeader.tsx

@@ -1,23 +1,52 @@
 /* eslint-disable react/prop-types */
 
 import React from 'react';
-import { ADMIN_URLS, STRINGS, LOCALE_NAMES } from '../../config/wagtailConfig';
+import { ADMIN_URLS, STRINGS } from '../../config/wagtailConfig';
 
 import Button from '../../components/Button/Button';
 import Icon from '../../components/Icon/Icon';
 import { PageState } from './reducers/nodes';
 
+interface SelectLocaleProps {
+  locale?: string;
+  translations: Map<string, number>;
+  gotoPage(id: number, transition: number): void;
+}
+
+const SelectLocale: React.FunctionComponent<SelectLocaleProps> = ({ locale, translations, gotoPage }) => {
+  const options = wagtailConfig.LOCALES
+    .filter(({ code }) => code === locale || translations.get(code))
+    /* eslint-disable-next-line camelcase */
+    .map(({ code, display_name }) => <option key={code} value={code}>{display_name}</option>);
+
+  const onChange = (e) => {
+    e.preventDefault();
+    const translation = translations.get(e.target.value);
+    if (translation) {
+      gotoPage(translation, 0);
+    }
+  };
+
+  return (
+    <div className="c-explorer__header__select">
+      <select value={locale} onChange={onChange} disabled={options.length < 2}>{options}</select>
+      <span></span>
+    </div>
+  );
+};
+
 interface ExplorerHeaderProps {
   page: PageState;
   depth: number;
-  onClick(eL: any): void
+  onClick(e: any): void
+  gotoPage(id: number, transition: number): void;
 }
 
 /**
  * The bar at the top of the explorer, displaying the current level
  * and allowing access back to the parent level.
  */
-const ExplorerHeader: React.FunctionComponent<ExplorerHeaderProps> = ({ page, depth, onClick }) => {
+const ExplorerHeader: React.FunctionComponent<ExplorerHeaderProps> = ({ page, depth, onClick, gotoPage }) => {
   const isRoot = depth === 0;
   const isSiteRoot = page.id === 0;
 
@@ -25,10 +54,10 @@ const ExplorerHeader: React.FunctionComponent<ExplorerHeaderProps> = ({ page, de
     <div className="c-explorer__header">
       <Button
         href={!isSiteRoot ? `${ADMIN_URLS.PAGES}${page.id}/` : ADMIN_URLS.PAGES}
-        className="c-explorer__header__title "
+        className="c-explorer__header__title"
         onClick={onClick}
       >
-        <div className="c-explorer__header__title__inner ">
+        <div className="c-explorer__header__title__inner">
           <Icon
             name={isRoot ? 'home' : 'arrow-left'}
             className="icon--explorer-header"
@@ -37,10 +66,9 @@ const ExplorerHeader: React.FunctionComponent<ExplorerHeaderProps> = ({ page, de
         </div>
       </Button>
       {!isSiteRoot && page.meta.locale &&
-        <div className="c-explorer__header__select">
-          <span>{(LOCALE_NAMES.get(page.meta.locale) || page.meta.locale)}</span>
-        </div>
-      }
+      page.translations &&
+      page.translations.size > 0 &&
+        <SelectLocale locale={page.meta.locale} translations={page.translations} gotoPage={gotoPage} />}
     </div>
   );
 };

+ 5 - 4
client/src/components/Explorer/ExplorerPanel.tsx

@@ -114,7 +114,7 @@ class ExplorerPanel extends React.Component<ExplorerPanelProps, ExplorerPanelSta
     const { page, nodes } = this.props;
     let children;
 
-    if (!page.isFetching && !page.children.items) {
+    if (!page.isFetchingChildren && !page.children.items) {
       children = (
         <div key="empty" className="c-explorer__placeholder">
           {STRINGS.NO_RESULTS}
@@ -137,7 +137,7 @@ class ExplorerPanel extends React.Component<ExplorerPanelProps, ExplorerPanelSta
     return (
       <div className="c-explorer__drawer">
         {children}
-        {page.isFetching ? (
+        {page.isFetchingChildren || page.isFetchingTranslations ? (
           <div key="fetching" className="c-explorer__placeholder">
             <LoadingSpinner />
           </div>
@@ -152,7 +152,7 @@ class ExplorerPanel extends React.Component<ExplorerPanelProps, ExplorerPanelSta
   }
 
   render() {
-    const { page, onClose, depth } = this.props;
+    const { page, onClose, depth, gotoPage } = this.props;
     const { transition, paused } = this.state;
 
     return (
@@ -160,7 +160,7 @@ class ExplorerPanel extends React.Component<ExplorerPanelProps, ExplorerPanelSta
         tag="div"
         role="dialog"
         className="explorer"
-        paused={paused || !page || page.isFetching}
+        paused={paused || !page || page.isFetchingChildren || page.isFetchingTranslations}
         focusTrapOptions={{
           initialFocus: '.c-explorer__header__title',
           onDeactivate: onClose,
@@ -175,6 +175,7 @@ class ExplorerPanel extends React.Component<ExplorerPanelProps, ExplorerPanelSta
               depth={depth}
               page={page}
               onClick={this.onHeaderClick}
+              gotoPage={gotoPage}
             />
 
             {this.renderChildren()}

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

@@ -57,7 +57,8 @@ exports[`Explorer visible 1`] = `
         },
         "id": 0,
         "isError": false,
-        "isFetching": true,
+        "isFetchingChildren": true,
+        "isFetchingTranslations": false,
         "meta": Object {
           "children": Object {},
           "parent": null,
@@ -116,7 +117,8 @@ exports[`Explorer visible 2`] = `
         },
         "id": 0,
         "isError": false,
-        "isFetching": true,
+        "isFetchingChildren": true,
+        "isFetchingTranslations": false,
         "meta": Object {
           "children": Object {},
           "parent": null,
@@ -138,7 +140,8 @@ exports[`Explorer visible 2`] = `
       },
       "id": 0,
       "isError": false,
-      "isFetching": true,
+      "isFetchingChildren": true,
+      "isFetchingTranslations": false,
       "meta": Object {
         "children": Object {},
         "parent": null,

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

@@ -6,7 +6,7 @@ exports[`ExplorerHeader #depth at root 1`] = `
 >
   <Button
     accessibleLabel={null}
-    className="c-explorer__header__title "
+    className="c-explorer__header__title"
     dialogTrigger={false}
     href="/admin/pages/undefined/"
     isLoading={false}
@@ -15,7 +15,7 @@ exports[`ExplorerHeader #depth at root 1`] = `
     target={null}
   >
     <div
-      className="c-explorer__header__title__inner "
+      className="c-explorer__header__title__inner"
     >
       <Icon
         className="icon--explorer-header"
@@ -36,7 +36,7 @@ exports[`ExplorerHeader #page 1`] = `
 >
   <Button
     accessibleLabel={null}
-    className="c-explorer__header__title "
+    className="c-explorer__header__title"
     dialogTrigger={false}
     href="/admin/pages/a/"
     isLoading={false}
@@ -45,7 +45,7 @@ exports[`ExplorerHeader #page 1`] = `
     target={null}
   >
     <div
-      className="c-explorer__header__title__inner "
+      className="c-explorer__header__title__inner"
     >
       <Icon
         className="icon--explorer-header"
@@ -66,7 +66,7 @@ exports[`ExplorerHeader basic 1`] = `
 >
   <Button
     accessibleLabel={null}
-    className="c-explorer__header__title "
+    className="c-explorer__header__title"
     dialogTrigger={false}
     href="/admin/pages/undefined/"
     isLoading={false}
@@ -75,7 +75,7 @@ exports[`ExplorerHeader basic 1`] = `
     target={null}
   >
     <div
-      className="c-explorer__header__title__inner "
+      className="c-explorer__header__title__inner"
     >
       <Icon
         className="icon--explorer-header"

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

@@ -40,6 +40,7 @@ exports[`ExplorerPanel general rendering #isError 1`] = `
     >
       <ExplorerHeader
         depth={1}
+        gotoPage={[MockFunction]}
         onClick={[Function]}
         page={
           Object {
@@ -95,7 +96,7 @@ exports[`ExplorerPanel general rendering #isFetching 1`] = `
       "onDeactivate": [MockFunction],
     }
   }
-  paused={true}
+  paused={false}
   role="dialog"
   tag="div"
 >
@@ -124,6 +125,7 @@ exports[`ExplorerPanel general rendering #isFetching 1`] = `
     >
       <ExplorerHeader
         depth={1}
+        gotoPage={[MockFunction]}
         onClick={[Function]}
         page={
           Object {
@@ -143,12 +145,6 @@ exports[`ExplorerPanel general rendering #isFetching 1`] = `
         <div
           key="children"
         />
-        <div
-          className="c-explorer__placeholder"
-          key="fetching"
-        >
-          <LoadingSpinner />
-        </div>
       </div>
     </div>
   </Transition>
@@ -195,6 +191,7 @@ exports[`ExplorerPanel general rendering #items 1`] = `
     >
       <ExplorerHeader
         depth={1}
+        gotoPage={[MockFunction]}
         onClick={[Function]}
         page={
           Object {
@@ -288,6 +285,7 @@ exports[`ExplorerPanel general rendering no children 1`] = `
     >
       <ExplorerHeader
         depth={1}
+        gotoPage={[MockFunction]}
         onClick={[Function]}
         page={
           Object {
@@ -350,6 +348,7 @@ exports[`ExplorerPanel general rendering renders 1`] = `
     >
       <ExplorerHeader
         depth={1}
+        gotoPage={[MockFunction]}
         onClick={[Function]}
         page={
           Object {

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

@@ -9,6 +9,18 @@ Array [
     },
     "type": "GOTO_PAGE",
   },
+  Object {
+    "payload": Object {
+      "id": 5,
+    },
+    "type": "GET_CHILDREN_START",
+  },
+  Object {
+    "payload": Object {
+      "id": 5,
+    },
+    "type": "GET_TRANSLATIONS_START",
+  },
 ]
 `;
 
@@ -27,6 +39,12 @@ Array [
     },
     "type": "GET_CHILDREN_START",
   },
+  Object {
+    "payload": Object {
+      "id": 5,
+    },
+    "type": "GET_TRANSLATIONS_START",
+  },
 ]
 `;
 
@@ -81,5 +99,11 @@ Array [
     },
     "type": "GET_CHILDREN_START",
   },
+  Object {
+    "payload": Object {
+      "id": 5,
+    },
+    "type": "GET_TRANSLATIONS_START",
+  },
 ]
 `;

+ 28 - 1
client/src/components/Explorer/actions.ts

@@ -53,6 +53,25 @@ function getChildren(id: number, offset = 0): ThunkActionType {
   };
 }
 
+const getTranslationsStart = createAction('GET_TRANSLATIONS_START', id => ({ id }));
+const getTranslationsSuccess = createAction('GET_TRANSLATIONS_SUCCESS', (id, items, meta) => ({ id, items, meta }));
+const getTranslationsFailure = createAction('GET_TRANSLATIONS_FAILURE', (id, error) => ({ id, error }));
+
+/**
+ * Gets the translations of a node from the API.
+ */
+function getTranslations(id) {
+  return (dispatch) => {
+    dispatch(getTranslationsStart(id));
+
+    return admin.getPageTranslations(id, { onlyWithChildren: true }).then(({ items, meta }) => {
+      dispatch(getTranslationsSuccess(id, items, meta));
+    }, (error) => {
+      dispatch(getTranslationsFailure(id, error));
+    });
+  };
+}
+
 const openExplorer = createAction('OPEN_EXPLORER', id => ({ id }));
 export const closeExplorer = createAction('CLOSE_EXPLORER');
 
@@ -69,6 +88,10 @@ export function toggleExplorer(id: number): ThunkActionType {
 
       if (!page) {
         dispatch(getChildren(id));
+
+        if (id !== 1) {
+          dispatch(getTranslations(id));
+        }
       }
 
       // We need to get the title of the starting page, only if it is not the site's root.
@@ -89,8 +112,12 @@ export function gotoPage(id: number, transition: number): ThunkActionType {
 
     dispatch(gotoPagePrivate(id, transition));
 
-    if (page && !page.isFetching && !(page.children.count > 0)) {
+    if (page && !page.isFetchingChildren  && !(page.children.count > 0)) {
       dispatch(getChildren(id));
     }
+
+    if (page && !page.isFetchingTranslations && page.translations == null) {
+      dispatch(getTranslations(id));
+    }
   };
 }

+ 18 - 9
client/src/components/Explorer/reducers/__snapshots__/nodes.test.js.snap

@@ -9,7 +9,8 @@ Object {
     },
     "id": 0,
     "isError": true,
-    "isFetching": false,
+    "isFetchingChildren": false,
+    "isFetchingTranslations": true,
     "meta": Object {
       "children": Object {},
       "parent": null,
@@ -32,7 +33,8 @@ Object {
     },
     "id": 0,
     "isError": false,
-    "isFetching": true,
+    "isFetchingChildren": true,
+    "isFetchingTranslations": false,
     "meta": Object {
       "children": Object {},
       "parent": null,
@@ -59,7 +61,8 @@ Object {
     },
     "id": 0,
     "isError": false,
-    "isFetching": false,
+    "isFetchingChildren": false,
+    "isFetchingTranslations": false,
     "meta": Object {
       "children": Object {},
       "parent": null,
@@ -77,7 +80,8 @@ Object {
     },
     "id": 3,
     "isError": false,
-    "isFetching": false,
+    "isFetchingChildren": false,
+    "isFetchingTranslations": false,
     "meta": Object {
       "children": Object {},
       "parent": null,
@@ -95,7 +99,8 @@ Object {
     },
     "id": 4,
     "isError": false,
-    "isFetching": false,
+    "isFetchingChildren": false,
+    "isFetchingTranslations": false,
     "meta": Object {
       "children": Object {},
       "parent": null,
@@ -113,7 +118,8 @@ Object {
     },
     "id": 5,
     "isError": false,
-    "isFetching": false,
+    "isFetchingChildren": false,
+    "isFetchingTranslations": false,
     "meta": Object {
       "children": Object {},
       "parent": null,
@@ -136,7 +142,8 @@ Object {
     },
     "id": 0,
     "isError": true,
-    "isFetching": false,
+    "isFetchingChildren": false,
+    "isFetchingTranslations": true,
     "meta": Object {
       "children": Object {},
       "parent": null,
@@ -159,7 +166,8 @@ Object {
     },
     "id": 0,
     "isError": false,
-    "isFetching": false,
+    "isFetchingChildren": false,
+    "isFetchingTranslations": false,
     "meta": Object {
       "children": Object {},
       "parent": null,
@@ -182,7 +190,8 @@ Object {
     },
     "id": 0,
     "isError": false,
-    "isFetching": false,
+    "isFetchingChildren": false,
+    "isFetchingTranslations": false,
     "meta": Object {
       "children": Object {},
       "parent": null,

+ 83 - 13
client/src/components/Explorer/reducers/nodes.ts

@@ -1,18 +1,21 @@
 import { WagtailPageAPI } from '../../../api/admin';
-import { OPEN_EXPLORER } from './explorer';
+import { OPEN_EXPLORER, CLOSE_EXPLORER } from './explorer';
 
 export interface PageState extends WagtailPageAPI {
-  isFetching: boolean;
+  isFetchingChildren: boolean,
+  isFetchingTranslations: boolean,
   isError: boolean;
   children: {
     items: any[];
     count: number;
   };
+  translations?: Map<string, number>;
 }
 
 const defaultPageState: PageState = {
   id: 0,
-  isFetching: false,
+  isFetchingChildren: false,
+  isFetchingTranslations: false,
   isError: false,
   children: {
     items: [],
@@ -36,6 +39,10 @@ interface OpenExplorerAction {
   }
 }
 
+interface CloseExplorerAction {
+  type: typeof CLOSE_EXPLORER;
+}
+
 export const GET_PAGE_SUCCESS = 'GET_PAGE_SUCCESS';
 interface GetPageSuccess {
   type: typeof GET_PAGE_SUCCESS;
@@ -66,6 +73,27 @@ interface GetChildrenSuccess {
   };
 }
 
+export const GET_TRANSLATIONS_START = 'GET_TRANSLATIONS_START';
+interface GetTranslationsStart {
+  type: typeof GET_TRANSLATIONS_START;
+  payload: {
+    id: number;
+  };
+}
+
+export const GET_TRANSLATIONS_SUCCESS = 'GET_TRANSLATIONS_SUCCESS';
+interface GetTranslationsSuccess {
+  type: typeof GET_TRANSLATIONS_SUCCESS;
+  payload: {
+    id: number;
+    meta: {
+      /* eslint-disable-next-line camelcase */
+      total_count: number;
+    };
+    items: WagtailPageAPI[];
+  };
+}
+
 export const GET_PAGE_FAILURE = 'GET_PAGE_FAILURE';
 interface GetPageFailure {
   type: typeof GET_PAGE_FAILURE;
@@ -82,21 +110,30 @@ interface GetChildrenFailure {
   };
 }
 
+export const GET_TRANSLATIONS_FAILURE = 'GET_TRANSLATIONS_FAILURE';
+interface GetTranslationsFailure {
+  type: typeof GET_TRANSLATIONS_FAILURE;
+  payload: {
+    id: number;
+  };
+}
+
 export type Action = OpenExplorerAction
+                   | CloseExplorerAction
                    | GetPageSuccess
                    | GetChildrenStart
                    | GetChildrenSuccess
+                   | GetTranslationsStart
+                   | GetTranslationsSuccess
                    | GetPageFailure
-                   | GetChildrenFailure;
+                   | GetChildrenFailure
+                   | GetTranslationsFailure;
 
 /**
  * A single page node in the explorer.
  */
-const node = (state = defaultPageState, action: Action) => {
+const node = (state = defaultPageState, action: Action): PageState => {
   switch (action.type) {
-  case OPEN_EXPLORER:
-    return state || defaultPageState;
-
   case GET_PAGE_SUCCESS:
     return Object.assign({}, state, action.payload.data, {
       isError: false,
@@ -104,12 +141,17 @@ const node = (state = defaultPageState, action: Action) => {
 
   case GET_CHILDREN_START:
     return Object.assign({}, state, {
-      isFetching: true,
+      isFetchingChildren: true,
+    });
+
+  case GET_TRANSLATIONS_START:
+    return Object.assign({}, state, {
+      isFetchingTranslations: true,
     });
 
   case GET_CHILDREN_SUCCESS:
     return Object.assign({}, state, {
-      isFetching: false,
+      isFetchingChildren: false,
       isError: false,
       children: {
         items: state.children.items.slice().concat(action.payload.items.map(item => item.id)),
@@ -117,10 +159,26 @@ const node = (state = defaultPageState, action: Action) => {
       },
     });
 
+  case GET_TRANSLATIONS_SUCCESS:
+    // eslint-disable-next-line no-case-declarations
+    const translations = new Map();
+
+    action.payload.items.forEach(item => {
+      translations.set(item.meta.locale, item.id);
+    });
+
+    return Object.assign({}, state, {
+      isFetchingTranslations: false,
+      isError: false,
+      translations,
+    });
+
   case GET_PAGE_FAILURE:
   case GET_CHILDREN_FAILURE:
+  case GET_TRANSLATIONS_FAILURE:
     return Object.assign({}, state, {
-      isFetching: false,
+      isFetchingChildren: false,
+      isFetchingTranslations: true,
       isError: true,
     });
 
@@ -140,17 +198,25 @@ const defaultState: State = {};
  */
 export default function nodes(state = defaultState, action: Action) {
   switch (action.type) {
-  case OPEN_EXPLORER:
+  case OPEN_EXPLORER: {
+    return Object.assign({}, state, {
+      [action.payload.id]: Object.assign({}, defaultPageState),
+    });
+  }
+
   case GET_PAGE_SUCCESS:
   case GET_CHILDREN_START:
+  case GET_TRANSLATIONS_START:
   case GET_PAGE_FAILURE:
   case GET_CHILDREN_FAILURE:
+  case GET_TRANSLATIONS_FAILURE:
     return Object.assign({}, state, {
       // Delegate logic to single-node reducer.
       [action.payload.id]: node(state[action.payload.id], action),
     });
 
-  case 'GET_CHILDREN_SUCCESS':
+  case GET_CHILDREN_SUCCESS:
+  case GET_TRANSLATIONS_SUCCESS:
     // eslint-disable-next-line no-case-declarations
     const newState = Object.assign({}, state, {
       [action.payload.id]: node(state[action.payload.id], action),
@@ -162,6 +228,10 @@ export default function nodes(state = defaultState, action: Action) {
 
     return newState;
 
+  case CLOSE_EXPLORER: {
+    return defaultState;
+  }
+
   default:
     return state;
   }