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> › + </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