Repository: reactioncommerce/reaction-next-starterkit
Pull request #33 (2018-04-06 21:30:37)

[WIP] feat: add top level navigation items
## Issue

Add UI for top-level navigation items and drawer

## Testing

[WIP]


Patch:
diff --git a/package.json b/package.json
index 51ae943d..9c06cdc5 100644
--- a/package.json
+++ b/package.json
@@ -72,6 +72,7 @@
   "jest": {
     "roots": [
       "./src"
-    ]
+    ],
+    "setupTestFrameworkScriptFile": "<rootDir>/src/setupTests.js"
   }
 }
diff --git a/src/components/Header/Header.js b/src/components/Header/Header.js
index d107c6a0..6c7a7b85 100644
--- a/src/components/Header/Header.js
+++ b/src/components/Header/Header.js
@@ -1,24 +1,61 @@
 import React, { Component } from "react";
 import PropTypes from "prop-types";
 import AppBar from "material-ui/AppBar";
+import Button from "material-ui/Button";
+import MenuList from "material-ui/Menu/MenuList";
 import Toolbar from "material-ui/Toolbar";
 import Typography from "material-ui/Typography";
 import IconButton from "material-ui/IconButton";
 import Drawer from "material-ui/Drawer";
 import CartIcon from "mdi-material-ui/Cart";
-
+import MenuIcon from "mdi-material-ui/Menu";
 import { inject, observer } from "mobx-react";
+import { observable, computed } from "mobx";
 import { withStyles } from "material-ui/styles";
+import VerticalNavigationItem from "../NavigationItem/VerticalNavigationItem";
 
 const styles = () => ({
-  title: {
-    flex: 1
-  },
   cart: {
     width: 320
+  },
+  menu: {
+    flex: 1
   }
 });
 
+// TODO: Get tag data from GraphQL, this is just a sample
+const tags = [
+  { name: "men", title: "Men" },
+  { name: "women", title: "Women" },
+  {
+    name: "kids",
+    title: "Kids",
+    relatedTags: [
+      {
+        name: "test1",
+        title: "Test 1",
+        relatedTags: [
+          { name: "men", title: "Men" },
+          { name: "women", title: "Women" },
+          { name: "men", title: "Men" },
+          { name: "women", title: "Women" }
+        ]
+      },
+      {
+        name: "test2",
+        title: "Test 2",
+        relatedTags: [
+          { name: "men", title: "Men" },
+          { name: "women", title: "Women" },
+          { name: "men", title: "Men" },
+          { name: "women", title: "Women" }
+        ]
+      },
+      { name: "test3", title: "Test 3" }
+    ]
+  }
+];
+
 @withStyles(styles)
 @inject("uiStore")
 @observer
@@ -27,17 +64,42 @@ class Header extends Component {
     classes: PropTypes.object,
     uiStore: PropTypes.object
   }
+
+  @observable _open = false
+
+  @computed get() { return this._open; }
+  set(value) { this._open = value; }
+
   render() {
     const { classes, uiStore } = this.props;
 
     return (
-      <AppBar position="static">
+      <AppBar position="static" elevation={0}>
         <Toolbar>
-          <Typography className={classes.title} variant="title">Reaction Commerce</Typography>
-          <IconButton onClick={uiStore.toggleCartOpen}>
+          <IconButton color="inherit" onClick={uiStore.toggleMenuDrawerOpen}>
+            <MenuIcon/>
+          </IconButton>
+          <Typography className={classes.title} color="inherit" variant="title">Reaction</Typography>
+
+          <nav className={classes.menu}>
+            {tags.map((tag, index) => (
+              <Button key={index} href={`/tag/${tag.name}`} color="inherit">{tag.name}</Button>
+            ))}
+          </nav>
+
+          <IconButton color="inherit" onClick={uiStore.toggleCartOpen}>
             <CartIcon />
           </IconButton>
         </Toolbar>
+        <Drawer open={uiStore.menuDrawerOpen} onClose={uiStore.toggleMenuDrawerOpen}>
+          <div className={classes.cart}>
+            <MenuList>
+              {tags.map((tag, index) => (
+                <VerticalNavigationItem key={index} menuItem={tag} />
+              ))}
+            </MenuList>
+          </div>
+        </Drawer>
         <Drawer anchor="right" open={uiStore.cartOpen} onClose={uiStore.toggleCartOpen}>
           <div className={classes.cart}>Cart</div>
         </Drawer>
diff --git a/src/components/NavigationItem/VerticalNavigationItem.js b/src/components/NavigationItem/VerticalNavigationItem.js
new file mode 100644
index 00000000..5195ae48
--- /dev/null
+++ b/src/components/NavigationItem/VerticalNavigationItem.js
@@ -0,0 +1,124 @@
+import React, { Component, Fragment } from "react";
+import PropTypes from "prop-types";
+import Router from "next/router";
+import Divider from "material-ui/Divider";
+import ListItemIcon from "material-ui/List/ListItemIcon";
+import ListItemText from "material-ui/List/ListItemText";
+import Collapse from "material-ui/transitions/Collapse";
+import MenuList from "material-ui/Menu/MenuList";
+import MenuItem from "material-ui/Menu/MenuItem";
+import ChevronDownIcon from "mdi-material-ui/ChevronDown";
+import ChevronUpIcon from "mdi-material-ui/ChevronUp";
+
+
+import { observer } from "mobx-react";
+import { action, computed, observable } from "mobx";
+import { withStyles } from "material-ui/styles";
+
+const styles = (theme) => ({
+  cart: {
+    width: 320
+  },
+  menu: {
+    flex: 1
+  },
+  // nested: {
+  //   marginLeft: theme.spacing.unit * 2
+  // },
+  subMenuGroup: {
+    marginBottom: theme.spacing.unit * 2
+  },
+  listItemTextInset: {
+    "&:first-child": {
+      paddingLeft: theme.spacing.unit * 3
+    }
+  }
+});
+
+@withStyles(styles)
+@observer
+class VerticalNavigationItem extends Component {
+  static propTypes = {
+    classes: PropTypes.object,
+    menuItem: PropTypes.object
+  }
+
+  @observable _open = false
+
+  @computed get open() { return this._open; }
+  set open(value) { this._open = value; }
+
+  @action handleMenuItemClick = () => {
+    const { menuItem } = this.props;
+    const { relatedTags } = menuItem;
+    const hasRelatedTags = Array.isArray(relatedTags) && relatedTags.length > 0;
+
+    if (hasRelatedTags) {
+      this.open = !this.open;
+    } else {
+      Router.push(`/tag/${menuItem.name}`);
+    }
+  }
+
+  render() {
+    const { classes, menuItem } = this.props;
+    const { relatedTags } = menuItem;
+    const hasRelatedTags = Array.isArray(relatedTags) && relatedTags.length > 0;
+
+    return (
+      <Fragment>
+        <MenuItem
+          color="inherit"
+          onClick={this.handleMenuItemClick}
+        >
+          <ListItemText classes={{ primary: classes.primary }} primary={menuItem.name} />
+          {hasRelatedTags &&
+            <ListItemIcon className={classes.icon}>
+              {this.open ? <ChevronUpIcon /> : <ChevronDownIcon />}
+            </ListItemIcon>
+          }
+        </MenuItem>
+
+        {hasRelatedTags &&
+          <Collapse in={this.open} timeout="auto" unmountOnExit>
+            <MenuList component="div" disablePadding>
+              {relatedTags.map((menuItemGroup, index) => (
+                <MenuList disablePadding key={index}>
+                  <MenuItem inset className={classes.nested}>
+                    <ListItemText
+                      classes={{ inset: classes.listItemTextInset }}
+                      inset
+                      primary={menuItemGroup.name}
+                    />
+                  </MenuItem>
+
+                  {Array.isArray(menuItemGroup.relatedTags) &&
+                    <div className={classes.subMenuGroup}>
+                      <Divider />
+                      {menuItemGroup.relatedTags.map((menuItemGroupItem, index) => (
+                        <MenuItem
+                          className={classes.nested}
+                          dense
+                          inset
+                          key={index}
+                        >
+                          <ListItemText
+                            classes={{ inset: classes.listItemTextInset }}
+                            inset
+                            primary={menuItemGroupItem.name}
+                          />
+                        </MenuItem>
+                      ))}
+                    </div>
+                  }
+                </MenuList>
+              ))}
+            </MenuList>
+          </Collapse>
+        }
+      </Fragment>
+    );
+  }
+}
+
+export default VerticalNavigationItem;
diff --git a/src/components/NavigationItem/index.js b/src/components/NavigationItem/index.js
new file mode 100644
index 00000000..3aa6f59d
--- /dev/null
+++ b/src/components/NavigationItem/index.js
@@ -0,0 +1,1 @@
+export { default as VerticalNavigationItem } from "./VerticalNavigationItem";
diff --git a/src/lib/stores/UIStore.js b/src/lib/stores/UIStore.js
index c10f155a..a717ce6f 100644
--- a/src/lib/stores/UIStore.js
+++ b/src/lib/stores/UIStore.js
@@ -2,6 +2,7 @@ import { observable, computed, action } from "mobx";
 
 class UIStore {
   @observable _cartOpen = false;
+  @observable _menuDrawerOpen = false;
 
   @computed get cartOpen() {
     return this._cartOpen;
@@ -14,6 +15,18 @@ class UIStore {
   @action toggleCartOpen = () => {
     this.cartOpen = !this.cartOpen;
   }
+
+  @computed get menuDrawerOpen() {
+    return this._menuDrawerOpen;
+  }
+
+  set menuDrawerOpen(value) {
+    this._menuDrawerOpen = value;
+  }
+
+  @action toggleMenuDrawerOpen = () => {
+    this.menuDrawerOpen = !this.menuDrawerOpen;
+  }
 }
 
 export default UIStore;
diff --git a/src/server.js b/src/server.js
index 5217c4b5..df4b4e09 100644
--- a/src/server.js
+++ b/src/server.js
@@ -22,6 +22,12 @@ app.prepare()
     //   app.render(req, res, actualPage, queryParams);
     // });
 
+    server.get("/tag/:slug", (req, res) => {
+      const actualPage = "/";
+      const queryParams = { slug: req.params.slug };
+      app.render(req, res, actualPage, queryParams);
+    });
+
     server.get("*", (req, res) => handle(req, res));
 
     /* END EXPRESS ROUTES */


Test patch:
diff --git a/src/components/Header/Header.test.js b/src/components/Header/Header.test.js
index a014cc00..f666d094 100644
--- a/src/components/Header/Header.test.js
+++ b/src/components/Header/Header.test.js
@@ -1,10 +1,11 @@
 
 import React from "react";
 import renderer from "react-test-renderer";
+import { shallow } from "enzyme";
 import Header from "./Header";
 
 test("basic snapshot", () => {
-  const component = renderer.create(<Header />);
+  const component = renderer.create(shallow(<Header />).get(3));
 
   const tree = component.toJSON();
   expect(tree).toMatchSnapshot();
diff --git a/src/components/NavigationItem/VerticalNavigationItem.test.js b/src/components/NavigationItem/VerticalNavigationItem.test.js
new file mode 100644
index 00000000..2ada1158
--- /dev/null
+++ b/src/components/NavigationItem/VerticalNavigationItem.test.js
@@ -0,0 +1,34 @@
+import React from "react";
+import renderer from "react-test-renderer";
+import VerticalNavigationItem from "./VerticalNavigationItem";
+
+const testMenuItem = {
+  _id: "123",
+  name: "Shop",
+  slug: "shop"
+}
+
+const testMenuItemWithRelatedTags = {
+  _id: "123",
+  name: "Shop",
+  slug: "shop",
+  relatedTags: [
+    { _id: "111", name: "Clothes", slug: "clothes" },
+    { _id: "222", name: "Shoes", slug: "shoes" },
+    { _id: "333", name: "Accessories", slug: "accessories" }
+  ]
+}
+
+test("basic snapshot", () => {
+  const component = renderer.create(<VerticalNavigationItem menuItem={testMenuItem} />);
+
+  const tree = component.toJSON();
+  expect(tree).toMatchSnapshot();
+});
+
+test("snapshot with related items", () => {
+  const component = renderer.create(<VerticalNavigationItem menuItem={testMenuItemWithRelatedTags} />);
+
+  const tree = component.toJSON();
+  expect(tree).toMatchSnapshot();
+});
diff --git a/src/components/NavigationItem/__snapshots__/VerticalNavigationItem.test.js.snap b/src/components/NavigationItem/__snapshots__/VerticalNavigationItem.test.js.snap
new file mode 100644
index 00000000..f6219bea
--- /dev/null
+++ b/src/components/NavigationItem/__snapshots__/VerticalNavigationItem.test.js.snap
@@ -0,0 +1,78 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`basic snapshot 1`] = `
+<li
+  className="MuiButtonBase-root-17 MuiListItem-root-7 MuiListItem-default-10 MuiListItem-gutters-14 MuiListItem-button-15 MuiMenuItem-root-5"
+  color="inherit"
+  onBlur={[Function]}
+  onClick={[Function]}
+  onFocus={[Function]}
+  onKeyDown={[Function]}
+  onKeyUp={[Function]}
+  onMouseDown={[Function]}
+  onMouseLeave={[Function]}
+  onMouseUp={[Function]}
+  onTouchEnd={[Function]}
+  onTouchMove={[Function]}
+  onTouchStart={[Function]}
+  role="menuitem"
+  tabIndex={-1}
+>
+  <div
+    className="MuiListItemText-root-20"
+  >
+    <h3
+      className="MuiTypography-root-26 MuiTypography-subheading-33 MuiListItemText-primary-23"
+    >
+      Shop
+    </h3>
+  </div>
+  <span
+    className="MuiTouchRipple-root-50"
+  />
+</li>
+`;
+
+exports[`snapshot with related items 1`] = `
+<li
+  className="MuiButtonBase-root-17 MuiListItem-root-7 MuiListItem-default-10 MuiListItem-gutters-14 MuiListItem-button-15 MuiMenuItem-root-5"
+  color="inherit"
+  onBlur={[Function]}
+  onClick={[Function]}
+  onFocus={[Function]}
+  onKeyDown={[Function]}
+  onKeyUp={[Function]}
+  onMouseDown={[Function]}
+  onMouseLeave={[Function]}
+  onMouseUp={[Function]}
+  onTouchEnd={[Function]}
+  onTouchMove={[Function]}
+  onTouchStart={[Function]}
+  role="menuitem"
+  tabIndex={-1}
+>
+  <div
+    className="MuiListItemText-root-20"
+  >
+    <h3
+      className="MuiTypography-root-26 MuiTypography-subheading-33 MuiListItemText-primary-23"
+    >
+      Shop
+    </h3>
+  </div>
+  <svg
+    aria-hidden="true"
+    className="MuiSvgIcon-root-58 MuiListItemIcon-root-57"
+    color={undefined}
+    focusable="false"
+    viewBox="0 0 24 24"
+  >
+    <path
+      d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z"
+    />
+  </svg>
+  <span
+    className="MuiTouchRipple-root-50"
+  />
+</li>
+`;
diff --git a/src/setupTests.js b/src/setupTests.js
new file mode 100644
index 00000000..ee60053c
--- /dev/null
+++ b/src/setupTests.js
@@ -0,0 +1,4 @@
+import { configure } from "enzyme";
+import Adapter from "enzyme-adapter-react-16";
+
+configure({ adapter: new Adapter() });
\ No newline at end of file