From 9c1d8fcf9ebd82c95ebb6eb9f22c48c43d86910f Mon Sep 17 00:00:00 2001
From: Markus Scheidgen <markus.scheidgen@gmail.com>
Date: Fri, 23 Apr 2021 09:20:52 +0200
Subject: [PATCH] Updated README.md, added breadcrumb navigation.

---
 README.md                               |   3 +-
 gui/src/components/UserdataPage.js      |   5 +-
 gui/src/components/nav/AppBar.js        |  18 +--
 gui/src/components/nav/MainMenu.js      |  34 +----
 gui/src/components/nav/MainMenu.spec.js |   2 +-
 gui/src/components/nav/Routes.js        | 185 ++++++++++++++++--------
 gui/src/components/search/EntryList.js  |  11 +-
 gui/src/components/uploads/Upload.js    |  17 +--
 8 files changed, 160 insertions(+), 115 deletions(-)

diff --git a/README.md b/README.md
index ca5f40ed81..c294863d3e 100644
--- a/README.md
+++ b/README.md
@@ -48,8 +48,9 @@ Omitted versions are plain bugfix releases with only minor changes and fixes.
 
 ### v0.10.3
 - fixes in the VASP parser
-- new tubemole parser
+- new turbemole parser
 - property placeholders while loading entry page
+- improved UI navigation with breadcrumbs
 
 ### v0.10.2
 - fixes small parser and normalizer issues
diff --git a/gui/src/components/UserdataPage.js b/gui/src/components/UserdataPage.js
index b8ad5a5e85..b638a048fd 100644
--- a/gui/src/components/UserdataPage.js
+++ b/gui/src/components/UserdataPage.js
@@ -77,7 +77,10 @@ function UserdataPage() {
     initialRequest={{order_by: 'upload_time', uploads_grouped: true}}
     initialResultTab="uploads"
     availableResultTabs={['uploads', 'datasets', 'entries', ...(encyclopediaEnabled ? ['materials'] : [])]}
-    resultListProps={{selectedColumnsKey: 'userEntries', selectedColumns: ['formula', 'upload_time', 'mainfile', 'published', 'co_authors', 'references', 'datasets']}}
+    resultListProps={{
+      selectedColumnsKey: 'userEntries',
+      entryPagePathPrefix: '/userdata',
+      selectedColumns: ['formula', 'upload_time', 'mainfile', 'published', 'co_authors', 'references', 'datasets']}}
   />
 }
 
diff --git a/gui/src/components/nav/AppBar.js b/gui/src/components/nav/AppBar.js
index 335fc225a6..7cc7e3a5d4 100644
--- a/gui/src/components/nav/AppBar.js
+++ b/gui/src/components/nav/AppBar.js
@@ -17,10 +17,10 @@
  */
 
 import React, { useCallback, useContext, useEffect, useState } from 'react'
-import { useLocation } from 'react-router-dom'
+import { Link as RouterLink } from 'react-router-dom'
 import { Typography,
   AppBar as MuiAppBar, Toolbar, Link, LinearProgress, makeStyles } from '@material-ui/core'
-import { allRoutes as routes } from './Routes'
+import { useRoute } from './Routes'
 import LoginLogout from '../LoginLogout'
 import HelpDialog from '../Help'
 import MainMenu from './MainMenu'
@@ -77,14 +77,7 @@ const useAppBarStyles = makeStyles(theme => ({
 
 export default function AppBar() {
   const classes = useAppBarStyles()
-  const {pathname} = useLocation()
-  const selectedRouteKey = Object.keys(routes).find(key => {
-    const route = routes[key]
-    return pathname.startsWith(route.path)
-  })
-  const selectedRoute = routes[selectedRouteKey]
-  const help = selectedRoute.appBarHelp
-  const title = selectedRoute.appBarTitle
+  const {help, title, breadCrumbs} = useRoute()
 
   return <MuiAppBar position="fixed" className={classes.root}>
     <Toolbar classes={{root: classes.toolbar}} disableGutters>
@@ -93,6 +86,11 @@ export default function AppBar() {
           <img alt="The NOMAD logo" className={classes.logo} src={`${guiBase}/nomad.png`}></img>
         </Link>
         <Typography variant="h6" color="inherit" noWrap>
+          {
+            breadCrumbs && breadCrumbs.map((breadCrumb, index) => <React.Fragment key={index}>
+              <Link component={RouterLink} to={breadCrumb.path}>{breadCrumb.title}</Link>&nbsp;›&nbsp;
+            </React.Fragment>)
+          }
           {title}
         </Typography>
         {help ? <HelpDialog color="inherit" maxWidth="md" classes={{root: classes.helpButton}} {...help}/> : ''}
diff --git a/gui/src/components/nav/MainMenu.js b/gui/src/components/nav/MainMenu.js
index ee432196b8..b4b440368f 100644
--- a/gui/src/components/nav/MainMenu.js
+++ b/gui/src/components/nav/MainMenu.js
@@ -19,10 +19,9 @@
 import { Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel,
   FormGroup,
   Switch} from '@material-ui/core'
-import React, { useEffect, useMemo, useRef, useState } from 'react'
-import { useLocation } from 'react-router-dom'
+import React, { useEffect, useMemo, useState } from 'react'
 import Markdown from '../Markdown'
-import { allRoutes as routes } from './Routes'
+import { useRoute } from './Routes'
 import { matomo } from '../App'
 import { useCookies } from 'react-cookie'
 import { MenuBar, MenuBarItem, MenuBarMenu } from './MenuBar'
@@ -118,27 +117,8 @@ function Consent(moreProps) {
 }
 
 export default function MainMenu() {
-  // We keep the URL of those path where components keep meaningful state in the URL.
-  // If the menu is used to comeback, the old URL is used. Therefore, it appears as
-  // if the same component instance with the same state is still there.
-  const {pathname, search} = useLocation()
-  const historyRef = useRef({
-    search: '/search',
-    userdata: '/userdata'
-  })
-  const history = {...historyRef.current}
-  Object.keys(historyRef.current).forEach(key => {
-    if (pathname.startsWith('/' + key)) {
-      historyRef.current[key] = pathname + (search || '')
-      history[key] = '/' + key
-    }
-  })
-  const route = Object.keys(routes).find(routeKey => pathname.startsWith(routes[routeKey].path))
-  const routeNavPath = route && routes[route].navPath
-  if (routeNavPath) {
-    historyRef.current.navPath = routeNavPath
-  }
-  const selected = (route && routes[route].navPath) || historyRef.current.navPath || (route && routes[route].defaultNavPath) || 'publish/uploads'
+  const route = useRoute()
+  const selected = (route?.navPath) || 'publish/uploads'
 
   return <MenuBar selected={selected}>
     <MenuBarMenu name="publish" label="Publish" route="/uploads" icon={<BackupIcon/>}>
@@ -147,13 +127,13 @@ export default function MainMenu() {
         tooltip="Upload and publish new data" icon={<SearchIcon />}
       />
       <MenuBarItem
-        label="Your data" name="userdata" route={history.userdata}
+        label="Your data" name="userdata" route="/userdata"
         tooltip="Manage your uploaded data" icon={<UserDataIcon />}
       />
     </MenuBarMenu>
-    <MenuBarMenu name="explore" route={history.search} icon={<SearchIcon/>}>
+    <MenuBarMenu name="explore" route="/search" icon={<SearchIcon/>}>
       <MenuBarItem
-        name="search" route={history.search}
+        name="search" route="/search"
         tooltip="Find and download data"
       />
       {encyclopediaEnabled && <MenuBarItem
diff --git a/gui/src/components/nav/MainMenu.spec.js b/gui/src/components/nav/MainMenu.spec.js
index bc4f8de3f7..9a86430d7f 100644
--- a/gui/src/components/nav/MainMenu.spec.js
+++ b/gui/src/components/nav/MainMenu.spec.js
@@ -22,7 +22,7 @@ import 'regenerator-runtime/runtime'
 // import { render, screen, within } from '@testing-library/react'
 // import { MemoryRouter } from 'react-router-dom'
 // import MainMenu from './MainMenu'
-// import { allRoutes as routes } from './Routes'
+// import { routes } from './Routes'
 
 // expect.extend({ toBeInTheDocument })
 
diff --git a/gui/src/components/nav/Routes.js b/gui/src/components/nav/Routes.js
index e075f850e0..b9136d16c6 100644
--- a/gui/src/components/nav/Routes.js
+++ b/gui/src/components/nav/Routes.js
@@ -17,7 +17,7 @@
  */
 
 import React from 'react'
-import { Route } from 'react-router-dom'
+import { Route, useLocation } from 'react-router-dom'
 import About from '../About'
 import APIs from '../APIs'
 import AIToolkitPage from '../aitoolkit/AIToolkitPage'
@@ -33,63 +33,86 @@ import UploadPage, {help as uploadHelp} from '../uploads/UploadPage'
 import UserdataPage, {help as userdataHelp} from '../UserdataPage'
 import { ErrorBoundary } from '../errors'
 
-export const routes = {
-  'faq': {
+function createEntryRoute(props) {
+  return ({
+    path: '/entry',
+    title: 'Entry',
+    help: {
+      title: 'The entry page',
+      content: entryHelp
+    },
+    ...props,
+    routes: [
+      {
+        path: '/id',
+        component: EntryPage
+      },
+      {
+        path: '/query',
+        exact: true,
+        component: EntryQuery
+      },
+      {
+        path: '/pid',
+        component: ResolvePID
+      }
+    ]
+  })
+}
+
+const routeSpecs = [
+  {
     path: '/faq',
     exact: true,
-    appBarTitle: 'Frequently Asked Questions',
+    title: 'Frequently Asked Questions',
     component: FAQ
   },
-  'search': {
+  {
     path: '/search',
     exact: true,
-    appBarTitle: 'Find and Download Data',
-    appBarHelp: {
+    title: 'Search and Download Data',
+    help: {
       title: 'How to find and download data',
       content: searchHelp
     },
     navPath: 'explore/search',
     component: SearchPage
   },
-  'userdata': {
+  {
     path: '/userdata',
     exact: true,
-    appBarTitle: 'Manage Your Data',
-    appBarHelp: {
+    title: 'Manage Your Data',
+    help: {
       title: 'How to manage your data',
       content: userdataHelp
     },
     navPath: 'publish/userdata',
-    component: UserdataPage
-  },
-  'entry': {
-    path: '/entry',
-    appBarTitle: 'Entry',
-    appBarHelp: {
-      title: 'The entry page',
-      content: entryHelp
-    },
-    defaultNavPath: 'explore/search',
+    component: UserdataPage,
     routes: [
+      createEntryRoute({
+        navPath: 'publish/userdata',
+        breadCrumbs: [
+          {
+            title: 'Your Data',
+            path: '/userdata'
+          }
+        ]
+      })
+    ]
+  },
+  createEntryRoute({
+    navPath: 'explore/search',
+    breadCrumbs: [
       {
-        path: '/id',
-        component: EntryPage
-      },
-      {
-        path: '/query',
-        exact: true,
-        component: EntryQuery
-      },
-      {
-        path: '/pid',
-        component: ResolvePID
+        title: 'Search',
+        path: '/search'
       }
     ]
-  },
-  'dataset': {
+  }),
+  {
     path: '/dataset',
-    appBarTitle: 'Dataset',
-    defaultNavPath: 'explore/search',
+    title: 'Dataset',
+    navPath: 'explore/search',
     routes: [
       {
         path: '/id',
@@ -101,68 +124,93 @@ export const routes = {
       }
     ]
   },
-  'uploads': {
+  {
     path: '/uploads',
     exact: true,
-    appBarTitle: 'Upload and Publish Data',
-    appBarHelp: {
+    title: 'Upload and Publish Data',
+    help: {
       title: 'How to upload data',
       content: uploadHelp
     },
     navPath: 'publish/uploads',
-    component: UploadPage
+    component: UploadPage,
+    routes: [
+      createEntryRoute({
+        navPath: 'publish/uploads',
+        breadCrumbs: [
+          {
+            title: 'Uploads',
+            path: '/uploads'
+          }
+        ]
+      })
+    ]
   },
-  'metainfo': {
+  {
     path: '/metainfo',
-    appBarTitle: 'The NOMAD Meta Info',
-    appBarHelp: {
+    title: 'The NOMAD Meta Info',
+    help: {
       title: 'About the NOMAD meta-info',
       content: metainfoHelp
     },
     navPath: 'analyze/metainfo',
     component: MetainfoPage
   },
-  'aitoolkit': {
+  {
     path: '/aitoolkit',
-    appBarTitle: 'Artificial Intelligence Toolkit',
+    title: 'Artificial Intelligence Toolkit',
     navPath: 'analyze/aitoolkit',
     component: AIToolkitPage
   },
-  'apis': {
+  {
     exact: true,
     path: '/apis',
-    appBarTitle: 'APIs',
+    title: 'APIs',
     navPath: 'analyze/apis',
     component: APIs
   },
-  'about': {
+  {
     exact: true,
     path: '/',
-    appBarTitle: 'About, Documentation, Getting Help',
+    title: 'About, Documentation, Getting Help',
     navPath: 'about/info',
     component: About
   }
-}
+]
 
-export const allRoutes = Object.keys(routes).map(key => {
-  const route = routes[key]
-  return route.routes
-    ? route.routes.map(subRoute => ({
+function flattenRouteSpecs(routeSpecs, parent, results) {
+  results = results || []
+  parent = parent || {}
+  routeSpecs.forEach(route => {
+    const flatRoute = {
+      ...parent,
+      component: null,
+      exact: false,
       ...route,
-      ...subRoute,
-      path: `${route.path}/${subRoute.path.replace(/^\/+/, '')}`,
+      path: parent.path ? `${parent.path}/${route.path.replace(/^\/+/, '')}` : route.path,
       routes: undefined
-    }))
-    : route
-}).flat()
+    }
+
+    if (flatRoute.component) {
+      results.push(flatRoute)
+    }
+
+    if (route.routes) {
+      flattenRouteSpecs(route.routes, flatRoute, results)
+    }
+  })
+  return results
+}
+
+export const routes = flattenRouteSpecs(routeSpecs)
+routes.sort((a, b) => (a.path > b.path) ? -1 : 1)
 
 export default function Routes() {
   return <React.Fragment>
-    {Object.keys(allRoutes).map(routeKey => {
-      const route = allRoutes[routeKey]
-      const { path, exact } = route
+    {routes.map(route => {
+      const {path, exact} = route
       const children = childProps => childProps.match && <route.component {...childProps} />
-      return <ErrorBoundary key={routeKey}>
+      return <ErrorBoundary key={path}>
         <Route exact={exact} path={path}
           // eslint-disable-next-line react/no-children-prop
           children={children}
@@ -171,3 +219,16 @@ export default function Routes() {
     })}
   </React.Fragment>
 }
+
+export function useRoute() {
+  const {pathname, search} = useLocation()
+  routes.forEach(route => {
+    (route.breadCrumbs || []).forEach(breadCrumb => {
+      if (breadCrumb.path.startsWith(pathname)) {
+        breadCrumb.path = pathname + (search || '')
+      }
+    })
+  })
+  const route = routes.find(route => pathname.startsWith(route.path))
+  return route
+}
diff --git a/gui/src/components/search/EntryList.js b/gui/src/components/search/EntryList.js
index ff15e88e74..c711f79578 100644
--- a/gui/src/components/search/EntryList.js
+++ b/gui/src/components/search/EntryList.js
@@ -96,7 +96,8 @@ export class EntryListUnstyled extends React.Component {
     selectedColumns: PropTypes.arrayOf(PropTypes.string),
     domain: PropTypes.object,
     user: PropTypes.object,
-    showAccessColumn: PropTypes.bool
+    showAccessColumn: PropTypes.bool,
+    entryPagePathPrefix: PropTypes.string
   }
 
   static styles = theme => ({
@@ -227,7 +228,9 @@ export class EntryListUnstyled extends React.Component {
   }
 
   handleClickCalc(calc) {
-    this.props.history.push(`/entry/id/${calc.upload_id}/${calc.calc_id}`)
+    const prefix = this.props.entryPagePathPrefix || ''
+    const url = `${prefix}/entry/id/${calc.upload_id}/${calc.calc_id}`
+    this.props.history.push(url)
   }
 
   handleChangePage = (event, page) => {
@@ -317,7 +320,9 @@ export class EntryListUnstyled extends React.Component {
 
   handleViewEntryPage(event, row) {
     event.stopPropagation()
-    this.props.history.push(`/entry/id/${row.upload_id}/${row.calc_id}`)
+    const prefix = this.props.entryPagePathPrefix || ''
+    const url = `${prefix}/entry/id/${row.upload_id}/${row.calc_id}`
+    this.props.history.push(url)
   }
 
   renderEntryActions(row, selected) {
diff --git a/gui/src/components/uploads/Upload.js b/gui/src/components/uploads/Upload.js
index 001ef2b423..7296d0899b 100644
--- a/gui/src/components/uploads/Upload.js
+++ b/gui/src/components/uploads/Upload.js
@@ -246,8 +246,7 @@ class Upload extends React.Component {
     showPublishDialog: false,
     showPublishToCentralNomadDialog: false,
     showDeleteDialog: false,
-    columns: {},
-    expanded: null
+    columns: {}
   }
 
   _unmounted = false
@@ -267,10 +266,6 @@ class Upload extends React.Component {
   }
 
   componentDidUpdate(prevProps, prevState) {
-    if (prevProps.open !== this.props.open && this.props.open) {
-      this.setState({expanded: null})
-    }
-
     if (prevProps.domain !== this.props.domain) {
       this.updateColumns()
     }
@@ -710,6 +705,7 @@ class Upload extends React.Component {
       onEdit={this.handleChange}
       actions={actions}
       showEntryActions={entry => entry.processed || !running}
+      entryPagePathPrefix="/uploads"
       {...this.state.params}
     />
   }
@@ -737,17 +733,18 @@ class Upload extends React.Component {
 
   render() {
     const { classes, open } = this.props
-    const { upload, showPublishDialog, showDeleteDialog, showPublishToCentralNomadDialog, expanded } = this.state
+    const { upload, showPublishDialog, showDeleteDialog, showPublishToCentralNomadDialog } = this.state
     const { errors, last_status_message } = upload
 
     if (this.state.upload) {
       return (
         <div className={classes.root}>
           <Accordion
-            expanded={expanded === null ? open : expanded}
-            onChange={(event, expanded) => {
-              this.setState({expanded: expanded})
+            expanded={open}
+            onChange={(event, open) => {
               if (open) {
+                this.props.history.push(`/uploads?open=${upload.upload_id}`)
+              } else {
                 this.props.history.push('/uploads')
               }
             }}
-- 
GitLab