From 72079f2cb99b4456afa36e113bc864c327cf25d4 Mon Sep 17 00:00:00 2001
From: Markus Scheidgen <markus.scheidgen@gmail.com>
Date: Mon, 9 Sep 2019 14:06:45 +0200
Subject: [PATCH] Implemented Dataset page.

---
 gui/src/components/App.js                     |  68 ++--
 gui/src/components/DatasetPage.js             |  84 +++++
 gui/src/components/domains.js                 |   2 +-
 gui/src/components/entry/RepoEntryView.js     |  21 +-
 gui/src/components/search/Search.js           | 272 ++++++++++++++++
 .../components/search/SearchAggregations.js   |   5 +-
 gui/src/components/search/SearchPage.js       | 290 ++----------------
 gui/src/utils.js                              |  32 ++
 nomad/api/raw.py                              |   5 +-
 nomad/api/repo.py                             |  47 ++-
 nomad/datamodel/base.py                       |   4 +-
 tests/test_api.py                             |  11 +-
 12 files changed, 521 insertions(+), 320 deletions(-)
 create mode 100644 gui/src/components/DatasetPage.js
 create mode 100644 gui/src/components/search/Search.js
 create mode 100644 gui/src/utils.js

diff --git a/gui/src/components/App.js b/gui/src/components/App.js
index 80bbd3fe11..c96d5cc0aa 100644
--- a/gui/src/components/App.js
+++ b/gui/src/components/App.js
@@ -22,13 +22,15 @@ import Calc from './entry/Calc'
 import About from './About'
 import LoginLogout from './LoginLogout'
 import { genTheme, repoTheme, archiveTheme, appBase } from '../config'
-import { DomainProvider } from './domains'
+import { DomainProvider, withDomain } from './domains'
 import {help as metainfoHelp, default as MetaInfoBrowser} from './metaInfoBrowser/MetaInfoBrowser'
 import packageJson from '../../package.json'
 import { Cookies, withCookies } from 'react-cookie'
 import Markdown from './Markdown'
 import {help as uploadHelp, default as Uploads} from './uploads/Uploads'
-import ResolvePID from './entry/ResolvePID';
+import ResolvePID from './entry/ResolvePID'
+import DatasetPage from './DatasetPage'
+import { capitalize } from '../utils'
 
 export class VersionMismatch extends Error {
   constructor(msg) {
@@ -39,27 +41,7 @@ export class VersionMismatch extends Error {
 
 const drawerWidth = 200
 
-const toolbarTitles = {
-  '/': 'About, Documentation, Getting Help',
-  '/search': 'Find and Download Data',
-  '/uploads': 'Upload and Publish Data',
-  '/metainfo': 'The NOMAD Meta Info'
-}
 
-const toolbarThemes = {
-  '/': genTheme,
-  '/search': repoTheme,
-  '/uploads': repoTheme,
-  '/entry': repoTheme,
-  '/metainfo': archiveTheme
-}
-
-const toolbarHelp = {
-  '/': null,
-  '/search': {title: 'How to find and download data', content: searchHelp},
-  '/uploads': {title: 'How to upload data', content: uploadHelp},
-  '/metainfo': {title: 'About the NOMAD meta-info', content: metainfoHelp}
-}
 
 class NavigationUnstyled extends React.Component {
   static propTypes = {
@@ -177,6 +159,31 @@ class NavigationUnstyled extends React.Component {
     }
   }
 
+  toolbarTitles = {
+    '/': 'About, Documentation, Getting Help',
+    '/search': 'Find and Download Data',
+    '/uploads': 'Upload and Publish Data',
+    '/metainfo': 'The NOMAD Meta Info',
+    '/entry': capitalize(this.props.domain.entryLabel),
+    '/dataset': 'Dataset'
+  }
+
+  toolbarThemes = {
+    '/': genTheme,
+    '/search': repoTheme,
+    '/uploads': repoTheme,
+    '/entry': repoTheme,
+    '/dataset': repoTheme,
+    '/metainfo': archiveTheme
+  }
+
+  toolbarHelp = {
+    '/': null,
+    '/search': {title: 'How to find and download data', content: searchHelp},
+    '/uploads': {title: 'How to upload data', content: uploadHelp},
+    '/metainfo': {title: 'About the NOMAD meta-info', content: metainfoHelp}
+  }
+
   componentDidMount() {
     fetch(`${appBase}/meta.json`)
       .then((response) => response.json())
@@ -198,6 +205,7 @@ class NavigationUnstyled extends React.Component {
 
   render() {
     const { classes, children, location: { pathname }, loading } = this.props
+    const { toolbarThemes, toolbarHelp, toolbarTitles } = this
 
     const selected = dct => {
       const key = Object.keys(dct).find(key => {
@@ -299,7 +307,7 @@ class NavigationUnstyled extends React.Component {
   }
 }
 
-const Navigation = compose(withRouter, withErrors, withApi(false), withStyles(NavigationUnstyled.styles))(NavigationUnstyled)
+const Navigation = compose(withRouter, withErrors, withApi(false), withDomain, withStyles(NavigationUnstyled.styles))(NavigationUnstyled)
 
 class LicenseAgreementUnstyled extends React.Component {
   static propTypes = {
@@ -392,7 +400,7 @@ export default class App extends React.Component {
     },
     'entry': {
       path: '/entry/id/:uploadId/:calcId',
-      key: (props) => `entry/${props.match.params.uploadId}/${props.match.params.uploadId}`,
+      key: (props) => `entry/id/${props.match.params.uploadId}/${props.match.params.uploadId}`,
       render: props => {
         const { match, ...rest } = props
         if (match && match.params.uploadId && match.params.calcId) {
@@ -402,6 +410,18 @@ export default class App extends React.Component {
         }
       }
     },
+    'dataset': {
+      path: '/dataset/id/:datasetId',
+      key: (props) => `dataset/id/${props.match.params.datasetId}`,
+      render: props => {
+        const { match, ...rest } = props
+        if (match && match.params.datasetId) {
+          return (<DatasetPage {...rest} datasetId={match.params.datasetId} />)
+        } else {
+          return ''
+        }
+      }
+    },
     'entry_pid': {
       path: '/entry/pid/:pid',
       key: (props) => `entry/pid/${props.match.params.pid}`,
diff --git a/gui/src/components/DatasetPage.js b/gui/src/components/DatasetPage.js
new file mode 100644
index 0000000000..de9baf5ce3
--- /dev/null
+++ b/gui/src/components/DatasetPage.js
@@ -0,0 +1,84 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { withStyles } from '@material-ui/core/styles'
+import { compose } from 'recompose'
+import { withErrors } from './errors'
+import { withApi } from './api'
+import Search from './search/Search'
+import { Typography, Link, Fab } from '@material-ui/core'
+import Download from './entry/Download'
+import DownloadIcon from '@material-ui/icons/CloudDownload'
+
+export const help = `
+This page allows you to **inspect** and **download** NOMAD datasets. It alsow allows you
+to explore a dataset with similar controls that the search page offers.
+`
+
+class DatasetPage extends React.Component {
+  static propTypes = {
+    classes: PropTypes.object.isRequired,
+    api: PropTypes.object.isRequired,
+    datasetId: PropTypes.string.isRequired,
+    raiseError: PropTypes.func.isRequired
+  }
+
+  static styles = theme => ({
+    root: {
+    },
+    description: {
+      padding: theme.spacing.unit * 3
+    },
+    downloadFab: {
+      zIndex: 1,
+      right: 32,
+      top: 56 + 32,
+      position: 'fixed !important'
+    }
+  })
+
+  state = {
+    dataset: {}
+  }
+
+  componentDidMount() {
+    const {datasetId, raiseError, api} = this.props
+    api.search({
+      owner: 'all',
+      dataset_id: datasetId,
+      page: 1, per_page: 1
+    }).then(data => {
+      const entry = data.results[0]
+      const dataset = entry ? entry.datasets.find(ds => ds.id + '' === datasetId) : {}
+      this.setState({dataset: dataset || {}})
+    }).catch(error => {
+        this.setState({dataset: {}})
+        raiseError(error)
+    })
+  }
+
+  render() {
+    const { classes, datasetId } = this.props
+    const { dataset } = this.state
+
+    return (
+      <div className={classes.root}>
+        <div className={classes.description}>
+          <Typography variant="h4">{dataset.name || 'loading ...'}</Typography>
+          <Typography>
+            dataset{dataset.doi ? <span>, with DOI <Link href={dataset.doi}>{dataset.doi}</Link></span> : ''}
+          </Typography>
+        </div>
+        <Search searchParameters={{owner: 'all', dataset_id: datasetId}} />
+        <Download
+          classes={{root: classes.downloadFab}} tooltip="download all rawfiles"
+          component={Fab} className={classes.downloadFab} color="primary" size="medium"
+          url={`raw/query?dataset_id=${datasetId}`} fileName={`${dataset.name}.json`}
+        >
+          <DownloadIcon />
+        </Download>
+      </div>
+    )
+  }
+}
+
+export default compose(withApi(false), withErrors, withStyles(DatasetPage.styles))(DatasetPage)
diff --git a/gui/src/components/domains.js b/gui/src/components/domains.js
index afaa7717fc..58cef56810 100644
--- a/gui/src/components/domains.js
+++ b/gui/src/components/domains.js
@@ -54,7 +54,7 @@ class DomainProviderBase extends React.Component {
         still be missing when you are exploring Nomad data using the new search and data
         exploring capabilities (menu items on the left).
       `,
-      entryLabel: 'calculation',
+      entryLabel: 'code run',
       searchPlaceholder: 'enter atoms, codes, functionals, or other quantity values',
       /**
        * A component that is used to render the search aggregations. The components needs
diff --git a/gui/src/components/entry/RepoEntryView.js b/gui/src/components/entry/RepoEntryView.js
index de8d201aba..9319cfaaf2 100644
--- a/gui/src/components/entry/RepoEntryView.js
+++ b/gui/src/components/entry/RepoEntryView.js
@@ -1,6 +1,6 @@
 import React from 'react'
 import PropTypes from 'prop-types'
-import { withStyles, Divider, Card, CardContent, Grid, CardHeader, Fab, Typography } from '@material-ui/core'
+import { withStyles, Divider, Card, CardContent, Grid, CardHeader, Fab, Typography, Link } from '@material-ui/core'
 import { withApi } from '../api'
 import { compose } from 'recompose'
 import Download from './Download'
@@ -8,6 +8,7 @@ import DownloadIcon from '@material-ui/icons/CloudDownload'
 import ApiDialogButton from '../ApiDialogButton'
 import Quantity from '../Quantity'
 import { withDomain } from '../domains'
+import { Link as RouterLink } from 'react-router-dom'
 
 class RepoEntryView extends React.Component {
   static styles = theme => ({
@@ -109,9 +110,11 @@ class RepoEntryView extends React.Component {
                   <Quantity column>
                     <Quantity quantity='comment' placeholder='no comment' {...quantityProps} />
                     <Quantity quantity='references' placeholder='no references' {...quantityProps}>
-                      {(calcData.references || []).map(ref => <Typography key={ref} noWrap>
-                        <a href={ref}>{ref}</a>
-                      </Typography>)}
+                      <div>
+                        {(calcData.references || []).map(ref => <Typography key={ref} noWrap>
+                          <a href={ref}>{ref}</a>
+                        </Typography>)}
+                      </div>
                     </Quantity>
                     <Quantity quantity='authors' {...quantityProps}>
                       <Typography>
@@ -119,9 +122,13 @@ class RepoEntryView extends React.Component {
                       </Typography>
                     </Quantity>
                     <Quantity quantity='datasets' placeholder='no datasets' {...quantityProps}>
-                      <Typography>
-                        {(calcData.datasets || []).map(ds => `${ds.name}${ds.doi ? ` (${ds.doi})` : ''}`).join(', ')}
-                      </Typography>
+                      <div>
+                        {(calcData.datasets || []).map(ds => (
+                          <Typography key={ds.id}>
+                            <Link component={RouterLink} to={`/dataset/id/${ds.id}`}>{ds.name}</Link>
+                            {ds.doi ? <span>&nbsp; (<Link href={ds.doi}>{ds.doi}</Link>)</span> : ''}
+                          </Typography>))}
+                      </div>
                     </Quantity>
                   </Quantity>
                 </CardContent>
diff --git a/gui/src/components/search/Search.js b/gui/src/components/search/Search.js
new file mode 100644
index 0000000000..9f338791ac
--- /dev/null
+++ b/gui/src/components/search/Search.js
@@ -0,0 +1,272 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { withStyles } from '@material-ui/core/styles'
+import { IconButton, Typography, Divider, Tooltip, Tabs, Tab } from '@material-ui/core'
+import { compose } from 'recompose'
+import { withErrors } from '../errors'
+import { withApi, DisableOnLoading } from '../api'
+import SearchBar from './SearchBar'
+import EntryList from './EntryList'
+import SearchAggregations from './SearchAggregations'
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
+import ExpandLessIcon from '@material-ui/icons/ExpandLess'
+import { withDomain } from '../domains'
+import DatasetList from './DatasetList';
+import { isEquivalent } from '../../utils';
+
+
+/**
+ * Component that comprises all search views: SearchBar, SearchAggregations (aka statistics),
+ * results (EntryList, DatasetList).
+ */
+class Search extends React.Component {
+  static propTypes = {
+    classes: PropTypes.object.isRequired,
+    api: PropTypes.object.isRequired,
+    raiseError: PropTypes.func.isRequired,
+    domain: PropTypes.object,
+    loading: PropTypes.number,
+    searchParameters: PropTypes.object,
+    showDetails: PropTypes.bool
+  }
+
+  static styles = theme => ({
+    root: {
+    },
+    searchContainer: {
+      padding: theme.spacing.unit * 3
+    },
+    resultsContainer: {
+    },
+    searchEntry: {
+      minWidth: 500,
+      maxWidth: 900,
+      margin: 'auto',
+      width: '100%'
+    },
+    search: {
+      marginTop: theme.spacing.unit * 4,
+      marginBottom: theme.spacing.unit * 8,
+      display: 'flex',
+      alignItems: 'center',
+      minWidth: 500,
+      maxWidth: 1000,
+      margin: 'auto',
+      width: '100%'
+    },
+    searchBar: {
+      width: '100%'
+    },
+    searchDivider: {
+      width: 1,
+      height: 28,
+      margin: theme.spacing.unit * 0.5
+    },
+    searchButton: {
+      padding: 10
+    },
+    searchResults: {}
+  })
+
+  static emptySearchData = {
+    results: [],
+    pagination: {
+      total: 0
+    },
+    datasets: {
+      after: null,
+      values: []
+    },
+    statistics: {
+      total: {
+        all: {
+          datasets: 0
+        }
+      }
+    }
+  }
+
+  state = {
+    data: Search.emptySearchData,
+    searchState: {
+      ...SearchAggregations.defaultState
+    },
+    entryListState: {
+      ...EntryList.defaultState
+    },
+    datasetListState: {
+      ...DatasetList.defaultState
+    },
+    showDetails: this.props.showDetails,
+    resultTab: 'entries'
+  }
+
+  constructor(props) {
+    super(props)
+
+    this.updateEntryList = this.updateEntryList.bind(this)
+    this.updateDatasetList = this.updateDatasetList.bind(this)
+    this.updateSearch = this.updateSearch.bind(this)
+    this.handleClickExpand = this.handleClickExpand.bind(this)
+
+    this._mounted = false
+  }
+
+  updateEntryList(changes) {
+    const entryListState = {
+      ...this.state.entryListState, ...changes
+    }
+    this.update({entryListState: entryListState})
+  }
+
+  updateDatasetList(changes) {
+    const datasetListState = {
+      ...this.state.datasetListState, ...changes
+    }
+    this.update({datasetListState: datasetListState})
+  }
+
+  updateSearch(changes) {
+    const searchState = {
+      ...this.state.searchState, ...changes
+    }
+    this.update({searchState: searchState})
+  }
+
+  update(changes) {
+    if (!this._mounted) {
+      return
+    }
+
+    changes = changes || {}
+    const { searchParameters } = this.props
+    const { entryListState, datasetListState, searchState } = {...this.state, ...changes}
+    const { searchValues, ...searchStateRest } = searchState
+    this.setState({...changes})
+
+    this.props.api.search({
+      datasets: true,
+      statistics: true,
+      ...entryListState,
+      ...datasetListState,
+      ...searchValues,
+      ...searchStateRest,
+      ...searchParameters
+    }).then(data => {
+      this.setState({
+        data: data || Search.emptySearchData
+      })
+    }).catch(error => {
+      if (error.name === 'NotAuthorized' && this.props.searchParameters.owner !== 'all') {
+        this.setState({data: Search.emptySearchData})
+      } else {
+        this.setState({data: Search.emptySearchData})
+        this.props.raiseError(error)
+      }
+    })
+  }
+
+  componentDidMount() {
+    this._mounted = true
+    this.update()
+  }
+
+  componentWillUnmount() {
+    this._mounted = false
+  }
+
+  componentDidUpdate(prevProps) {
+    // login/logout or changed search paraemters -> reload results
+    if (prevProps.api !== this.props.api || !isEquivalent(prevProps.searchParameters, this.props.searchParameters)) {
+      this.update()
+    }
+  }
+
+  handleClickExpand() {
+    this.setState({showDetails: !this.state.showDetails})
+  }
+
+  render() {
+    const { classes, domain, loading } = this.props
+    const { data, searchState, entryListState, datasetListState, showDetails, resultTab } = this.state
+    const { searchValues } = searchState
+    const { pagination: { total }, statistics } = data
+
+    const helperText = <span>
+      There are {Object.keys(domain.searchMetrics).filter(key => statistics.total.all[key]).map(key => {
+        return <span key={key}>
+          {domain.searchMetrics[key].renderResultString(!loading && statistics.total.all[key] !== undefined ? statistics.total.all[key] : '...')}
+        </span>
+      })}{Object.keys(searchValues).length ? ' left' : ''}.
+    </span>
+
+    return (
+      <div className={classes.root}>
+        <div className={classes.searchContainer}>
+          <DisableOnLoading>
+            <div className={classes.search}>
+              <SearchBar classes={{autosuggestRoot: classes.searchBar}}
+                fullWidth fullWidthInput={false} helperText={helperText}
+                label="search"
+                placeholder={domain.searchPlaceholder}
+                data={data} searchValues={searchValues}
+                InputLabelProps={{
+                  shrink: true
+                }}
+                onChanged={values => this.updateSearch({searchValues: values})}
+              />
+              <Divider className={classes.searchDivider} />
+              <Tooltip title={showDetails ? 'Hide statistics' : 'Show statistics'}>
+                <IconButton className={classes.searchButton} color="secondary" onClick={this.handleClickExpand}>
+                  {showDetails ? <ExpandLessIcon/> : <ExpandMoreIcon/>}
+                </IconButton>
+              </Tooltip>
+            </div>
+
+            <div className={classes.searchEntry}>
+              <SearchAggregations
+                data={data} {...searchState} onChange={this.updateSearch}
+                showDetails={showDetails}
+              />
+            </div>
+          </DisableOnLoading>
+        </div>
+        <div className={classes.resultsContainer}>
+          <Tabs
+            value={resultTab}
+            indicatorColor="primary"
+            textColor="primary"
+            onChange={(event, value) => this.setState({resultTab: value})}
+          >
+            <Tab label="Calculations" value="entries" />
+            <Tab label="Datasets" value="datasets" />
+          </Tabs>
+
+          <div className={classes.searchResults} hidden={resultTab !== 'entries'}>
+            <Typography variant="caption" style={{margin: 12}}>
+              About {total.toLocaleString()} results:
+            </Typography>
+
+            <EntryList
+              data={data} total={total}
+              onChange={this.updateEntryList}
+              {...entryListState}
+            />
+          </div>
+          <div className={classes.searchResults} hidden={resultTab !== 'datasets'}>
+            <Typography variant="caption" style={{margin: 12}}>
+              About {statistics.total.all.datasets.toLocaleString()} datasets:
+            </Typography>
+
+            <DatasetList data={data} total={statistics.total.all.datasets}
+              onChange={this.updateDatasetList}
+              {...datasetListState}
+            />
+          </div>
+        </div>
+      </div>
+    )
+  }
+}
+
+export default compose(withApi(false), withErrors, withDomain, withStyles(Search.styles))(Search)
diff --git a/gui/src/components/search/SearchAggregations.js b/gui/src/components/search/SearchAggregations.js
index 794e8801e1..41299810cd 100644
--- a/gui/src/components/search/SearchAggregations.js
+++ b/gui/src/components/search/SearchAggregations.js
@@ -44,7 +44,10 @@ class SearchAggregationsUnstyled extends React.Component {
     const firstRealQuantitiy = Object.keys(statistics).find(key => key !== 'total')
     if (firstRealQuantitiy) {
       const firstValue = Object.keys(statistics[firstRealQuantitiy])[0]
-      useMetric = Object.keys(statistics[firstRealQuantitiy][firstValue]).find(metric => metric !== 'code_runs') || 'code_runs'
+      if (firstValue) {
+        useMetric = Object.keys(statistics[firstRealQuantitiy][firstValue])
+          .find(metric => metric !== 'code_runs') || 'code_runs'
+      }
     }
 
     const metricsDefinitions = domain.searchMetrics
diff --git a/gui/src/components/search/SearchPage.js b/gui/src/components/search/SearchPage.js
index d79c7b0945..5e47f5556c 100644
--- a/gui/src/components/search/SearchPage.js
+++ b/gui/src/components/search/SearchPage.js
@@ -1,19 +1,12 @@
 import React from 'react'
 import PropTypes from 'prop-types'
 import { withStyles } from '@material-ui/core/styles'
-import { FormControl, FormControlLabel, Checkbox, FormGroup,
-  FormLabel, IconButton, Typography, Divider, Tooltip, Tabs, Tab } from '@material-ui/core'
+import { FormControl, FormControlLabel, Checkbox, FormGroup, FormLabel, Tooltip } from '@material-ui/core'
 import { compose } from 'recompose'
 import { withErrors } from '../errors'
 import { withApi, DisableOnLoading } from '../api'
-import SearchBar from './SearchBar'
-import EntryList from './EntryList'
-import SearchAggregations from './SearchAggregations'
-import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
-import ExpandLessIcon from '@material-ui/icons/ExpandLess'
-import { withDomain } from '../domains'
 import { appBase } from '../../config'
-import DatasetList from './DatasetList';
+import Search from './Search';
 
 export const help = `
 This page allows you to **search** in NOMAD's data. The upper part of this page
@@ -57,181 +50,26 @@ all parsed data. The *log* tab, will show you a log of the entry's processing.
 class SearchPage extends React.Component {
   static propTypes = {
     classes: PropTypes.object.isRequired,
-    match: PropTypes.any,
     api: PropTypes.object.isRequired,
     user: PropTypes.object,
-    raiseError: PropTypes.func.isRequired,
-    domain: PropTypes.object,
-    loading: PropTypes.number
+    raiseError: PropTypes.func.isRequired
   }
 
   static styles = theme => ({
     root: {
     },
-    searchContainer: {
-      padding: theme.spacing.unit * 3
-    },
-    resultsContainer: {
-    },
     searchEntry: {
-      minWidth: 500,
-      maxWidth: 900,
-      margin: 'auto',
-      width: '100%'
-    },
-    search: {
-      marginTop: theme.spacing.unit * 4,
-      marginBottom: theme.spacing.unit * 8,
-      display: 'flex',
-      alignItems: 'center',
-      minWidth: 500,
-      maxWidth: 1000,
-      margin: 'auto',
-      width: '100%'
-    },
-    searchBar: {
-      width: '100%'
-    },
-    searchDivider: {
-      width: 1,
-      height: 28,
-      margin: theme.spacing.unit * 0.5
-    },
-    searchButton: {
-      padding: 10
-    },
-    searchResults: {}
-  })
-
-  static emptySearchData = {
-    results: [],
-    pagination: {
-      total: 0
-    },
-    datasets: {
-      after: null,
-      values: []
-    },
-    statistics: {
-      total: {
-        all: {
-          datasets: 0
-        }
-      }
+      padding: theme.spacing.unit * 3
     }
-  }
+  })
 
   state = {
-    data: SearchPage.emptySearchData,
-    owner: 'all',
-    searchState: {
-      ...SearchAggregations.defaultState
-    },
-    entryListState: {
-      ...EntryList.defaultState
-    },
-    datasetListState: {
-      ...DatasetList.defaultState
-    },
-    showDetails: true,
-    resultTab: 'entries'
-  }
-
-  constructor(props) {
-    super(props)
-
-    this.updateEntryList = this.updateEntryList.bind(this)
-    this.updateDatasetList = this.updateDatasetList.bind(this)
-    this.updateSearch = this.updateSearch.bind(this)
-    this.handleClickExpand = this.handleClickExpand.bind(this)
-
-    this._mounted = false
-  }
-
-  updateEntryList(changes) {
-    const entryListState = {
-      ...this.state.entryListState, ...changes
-    }
-    this.update({entryListState: entryListState})
-  }
-
-  updateDatasetList(changes) {
-    const datasetListState = {
-      ...this.state.datasetListState, ...changes
-    }
-    this.update({datasetListState: datasetListState})
-  }
-
-  updateSearch(changes) {
-    const searchState = {
-      ...this.state.searchState, ...changes
-    }
-    this.update({searchState: searchState})
-  }
-
-  update(changes) {
-    if (!this._mounted) {
-      return
-    }
-
-    changes = changes || {}
-    const { owner, entryListState, datasetListState, searchState } = {...this.state, ...changes}
-    const { searchValues, ...searchStateRest } = searchState
-    this.setState({...changes})
-
-    this.props.api.search({
-      owner: owner,
-      ...entryListState,
-      ...datasetListState,
-      ...searchValues,
-      ...searchStateRest
-    }).then(data => {
-      this.setState({
-        data: data || SearchPage.emptySearchData
-      })
-    }).catch(error => {
-      if (error.name === 'NotAuthorized' && owner !== 'all') {
-        this.setState({data: SearchPage.emptySearchData, owner: 'all'})
-      } else {
-        this.setState({data: SearchPage.emptySearchData, owner: owner})
-        this.props.raiseError(error)
-      }
-    })
-  }
-
-  componentDidMount() {
-    this._mounted = true
-    this.update()
-  }
-
-  componentWillUnmount() {
-    this._mounted = false
-  }
-
-  componentDidUpdate(prevProps) {
-    if (prevProps.api !== this.props.api) { // login/logout case, reload results
-      this.update()
-    } else if (prevProps.match.path !== this.props.match.path) { // navigation case
-      // update if we went back to the search
-      if (this.props.match.path === '/search' && this.props.match.isExact) {
-        this.update()
-      }
-    }
-  }
-
-  handleOwnerChange(owner) {
-    this.update({owner: owner})
-  }
-
-  handleClickExpand() {
-    this.setState({showDetails: !this.state.showDetails})
+    owner: 'all'
   }
 
   render() {
-    const { classes, user, domain, loading } = this.props
-    const { data, searchState, entryListState, datasetListState, showDetails, resultTab } = this.state
-    const { searchValues } = searchState
-    const { pagination: { total }, statistics } = data
+    const { classes, user } = this.props
+    const { owner } = this.state
 
     const ownerLabel = {
       all: 'All entries',
@@ -249,101 +87,33 @@ class SearchPage extends React.Component {
 
     const withoutLogin = ['all']
 
-    const helperText = <span>
-      There are {Object.keys(domain.searchMetrics).filter(key => statistics.total.all[key]).map(key => {
-        return <span key={key}>
-          {domain.searchMetrics[key].renderResultString(!loading && statistics.total.all[key] !== undefined ? statistics.total.all[key] : '...')}
-        </span>
-      })}{Object.keys(searchValues).length ? ' left' : ''}.
-    </span>
-
     return (
       <div className={classes.root}>
-        <div className={classes.searchContainer}>
-          <DisableOnLoading>
-            <div className={classes.searchEntry}>
-              <FormControl>
-                <FormLabel>Filter entries and show: </FormLabel>
-                <FormGroup row>
-                  {['all', 'public', 'user', 'staging']
-                    .filter(key => user || withoutLogin.indexOf(key) !== -1)
-                    .map(owner => (
-                      <Tooltip key={owner} title={ownerTooltips[owner] + (user ? '' : 'You need to be logged-in for more options.')}>
-                        <FormControlLabel
-                          control={
-                            <Checkbox checked={this.state.owner === owner} onChange={() => this.handleOwnerChange(owner)} value="owner" />
-                          }
-                          label={ownerLabel[owner]}
-                        />
-                      </Tooltip>
-                    ))}
-                </FormGroup>
-              </FormControl>
-            </div>
-
-            <div className={classes.search}>
-              <SearchBar classes={{autosuggestRoot: classes.searchBar}}
-                fullWidth fullWidthInput={false} helperText={helperText}
-                label="search"
-                placeholder={domain.searchPlaceholder}
-                data={data} searchValues={searchValues}
-                InputLabelProps={{
-                  shrink: true
-                }}
-                onChanged={values => this.updateSearch({searchValues: values})}
-              />
-              <Divider className={classes.searchDivider} />
-              <Tooltip title={showDetails ? 'Hide statistics' : 'Show statistics'}>
-                <IconButton className={classes.searchButton} color="secondary" onClick={this.handleClickExpand}>
-                  {showDetails ? <ExpandLessIcon/> : <ExpandMoreIcon/>}
-                </IconButton>
-              </Tooltip>
-            </div>
-
-            <div className={classes.searchEntry}>
-              <SearchAggregations
-                data={data} {...searchState} onChange={this.updateSearch}
-                showDetails={showDetails}
-              />
-            </div>
-          </DisableOnLoading>
-        </div>
-        <div className={classes.resultsContainer}>
-          <Tabs
-            value={resultTab}
-            indicatorColor="primary"
-            textColor="primary"
-            onChange={(event, value) => this.setState({resultTab: value})}
-          >
-            <Tab label="Calculations" value="entries" />
-            <Tab label="Datasets" value="datasets" />
-          </Tabs>
-
-          <div className={classes.searchResults} hidden={resultTab !== 'entries'}>
-            <Typography variant="caption" style={{margin: 12}}>
-              About {total.toLocaleString()} results:
-            </Typography>
-
-            <EntryList
-              data={data} total={total}
-              onChange={this.updateEntryList}
-              {...entryListState}
-            />
-          </div>
-          <div className={classes.searchResults} hidden={resultTab !== 'datasets'}>
-            <Typography variant="caption" style={{margin: 12}}>
-              About {statistics.total.all.datasets.toLocaleString()} datasets:
-            </Typography>
-
-            <DatasetList data={data} total={statistics.total.all.datasets}
-              onChange={this.updateDatasetList}
-              {...datasetListState}
-            />
+        <DisableOnLoading>
+          <div className={classes.searchEntry}>
+            <FormControl>
+              <FormLabel>Filter entries and show: </FormLabel>
+              <FormGroup row>
+                {['all', 'public', 'user', 'staging']
+                  .filter(key => user || withoutLogin.indexOf(key) !== -1)
+                  .map(owner => (
+                    <Tooltip key={owner} title={ownerTooltips[owner] + (user ? '' : 'You need to be logged-in for more options.')}>
+                      <FormControlLabel
+                        control={
+                          <Checkbox checked={this.state.owner === owner} onChange={() => this.setState({owner: owner})} value="owner" />
+                        }
+                        label={ownerLabel[owner]}
+                      />
+                    </Tooltip>
+                  ))}
+              </FormGroup>
+            </FormControl>
           </div>
-        </div>
+        </DisableOnLoading>
+        <Search searchParameters={{owner: owner}} showDetails />
       </div>
     )
   }
 }
 
-export default compose(withApi(false), withErrors, withDomain, withStyles(SearchPage.styles))(SearchPage)
+export default compose(withApi(false), withErrors, withStyles(SearchPage.styles))(SearchPage)
diff --git a/gui/src/utils.js b/gui/src/utils.js
new file mode 100644
index 0000000000..5d8368caeb
--- /dev/null
+++ b/gui/src/utils.js
@@ -0,0 +1,32 @@
+export const isEquivalent = (a, b) => {
+  // Create arrays of property names
+  var aProps = Object.getOwnPropertyNames(a)
+  var bProps = Object.getOwnPropertyNames(b)
+
+  // If number of properties is different,
+  // objects are not equivalent
+  if (aProps.length !== bProps.length) {
+      return false;
+  }
+
+  for (var i = 0; i < aProps.length; i++) {
+      var propName = aProps[i]
+
+      // If values of same property are not equal,
+      // objects are not equivalent
+      if (a[propName] !== b[propName]) {
+          return false
+      }
+  }
+
+  // If we made it this far, objects
+  // are considered equivalent
+  return true
+}
+
+export const capitalize = (s) => {
+  if (typeof s !== 'string') {
+    return ''
+  }
+  return s.charAt(0).toUpperCase() + s.slice(1)
+}
\ No newline at end of file
diff --git a/nomad/api/raw.py b/nomad/api/raw.py
index 4bfa795046..3ac3869cd2 100644
--- a/nomad/api/raw.py
+++ b/nomad/api/raw.py
@@ -25,7 +25,7 @@ import magic
 import sys
 import contextlib
 
-from nomad import search
+from nomad import search, utils
 from nomad.files import UploadFiles, Restricted
 from nomad.processing import Calc
 
@@ -383,7 +383,8 @@ class RawFileQueryResource(Resource):
                 upload_files = UploadFiles.get(
                     upload_id, create_authorization_predicate(upload_id))
                 if upload_files is None:
-                    pass  # this should not happen, TODO log error
+                    utils.get_logger(__name__).error('upload files do not exist', upload_id=upload_id)
+                    continue
 
                 if hasattr(upload_files, 'zipfile_cache'):
                     zipfile_cache = upload_files.zipfile_cache()
diff --git a/nomad/api/repo.py b/nomad/api/repo.py
index d70b40efa4..24323a6722 100644
--- a/nomad/api/repo.py
+++ b/nomad/api/repo.py
@@ -69,7 +69,7 @@ class RepoCalcResource(Resource):
 
 
 repo_calcs_model = api.model('RepoCalculations', {
-    'pagination': fields.Nested(pagination_model, allow_null=True),
+    'pagination': fields.Nested(pagination_model, skip_none=True),
     'scroll': fields.Nested(allow_null=True, skip_none=True, model=api.model('Scroll', {
         'total': fields.Integer(description='The total amount of hits for the search.'),
         'scroll_id': fields.String(allow_null=True, description='The scroll_id that can be used to retrieve the next page.'),
@@ -85,7 +85,7 @@ repo_calcs_model = api.model('RepoCalculations', {
     'datasets': fields.Raw(api.model('RepoDatasets', {
         'after': fields.String(description='The after value that can be used to retrieve the next datasets.'),
         'values': fields.Raw(description='A dict with names as key. The values are dicts with "total" and "examples" keys.')
-    }), allow_null=True)
+    }), skip_none=True)
 })
 
 
@@ -125,6 +125,10 @@ repo_request_parser.add_argument(
     'metrics', type=str, action='append', help=(
         'Metrics to aggregate over all quantities and their values as comma separated list. '
         'Possible values are %s.' % ', '.join(datamodel.Domain.instance.metrics_names)))
+repo_request_parser.add_argument(
+    'datasets', type=bool, help=('Return dataset information.'))
+repo_request_parser.add_argument(
+    'statistics', type=bool, help=('Return statistics.'))
 
 
 search_request_parser = api.parser()
@@ -216,6 +220,9 @@ class RepoCalcsResource(Resource):
             if bool(request.args.get('date_histogram', False)):
                 search_request.date_histogram()
             metrics: List[str] = request.args.getlist('metrics')
+
+            with_datasets = request.args.get('datasets', False)
+            with_statistics = request.args.get('statistics', False)
         except Exception:
             abort(400, message='bad parameter types')
 
@@ -232,31 +239,37 @@ class RepoCalcsResource(Resource):
             if metric not in search.metrics_names:
                 abort(400, message='there is no metric %s' % metric)
 
-        search_request.default_statistics(metrics_to_use=metrics)
-        if 'datasets' not in metrics:
-            total_metrics = metrics + ['datasets']
-        else:
-            total_metrics = metrics
-        search_request.totals(metrics_to_use=total_metrics)
-        search_request.statistic('authors', 1000)
+        if with_statistics:
+            search_request.default_statistics(metrics_to_use=metrics)
+            if 'datasets' not in metrics:
+                total_metrics = metrics + ['datasets']
+            else:
+                total_metrics = metrics
+            search_request.totals(metrics_to_use=total_metrics)
+            search_request.statistic('authors', 1000)
 
         try:
             if scroll:
                 results = search_request.execute_scrolled(scroll_id=scroll_id, size=per_page)
 
             else:
-                search_request.quantity(
-                    'dataset_ids', size=per_page, examples=1, after=request.args.get('datasets_after', None))
+                if with_datasets:
+                    search_request.quantity(
+                        'dataset_id', size=per_page, examples=1,
+                        after=request.args.get('datasets_after', None))
+
                 results = search_request.execute_paginated(
                     per_page=per_page, page=page, order=order, order_by=order_by)
 
                 # TODO just a work around to make things prettier
-                statistics = results['statistics']
-                if 'code_name' in statistics and 'currupted mainfile' in statistics['code_name']:
-                    del(statistics['code_name']['currupted mainfile'])
-
-                datasets = results.pop('quantities')['dataset_ids']
-                results['datasets'] = datasets
+                if with_statistics:
+                    statistics = results['statistics']
+                    if 'code_name' in statistics and 'currupted mainfile' in statistics['code_name']:
+                        del(statistics['code_name']['currupted mainfile'])
+
+                if with_datasets:
+                    datasets = results.pop('quantities')['dataset_id']
+                    results['datasets'] = datasets
 
             return results, 200
         except search.ScrollIdNotFound:
diff --git a/nomad/datamodel/base.py b/nomad/datamodel/base.py
index 62a808bb0d..088d3ea513 100644
--- a/nomad/datamodel/base.py
+++ b/nomad/datamodel/base.py
@@ -267,10 +267,10 @@ class Domain:
         calc_id=DomainQuantity(description='Search for the calc_id.'),
         pid=DomainQuantity(description='Search for the pid.'),
         mainfile=DomainQuantity(description='Search for the mainfile.'),
-        datasets=DomainQuantity(
+        dataset=DomainQuantity(
             elastic_field='datasets.name', multi=True, elastic_search_type='match',
             description='Search for a particular dataset by name.'),
-        dataset_ids=DomainQuantity(
+        dataset_id=DomainQuantity(
             elastic_field='datasets.id', multi=True,
             description='Search for a particular dataset by its id.'),
         doi=DomainQuantity(
diff --git a/tests/test_api.py b/tests/test_api.py
index d26c3ea3c9..53ce3b8db7 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -718,7 +718,7 @@ class TestRepo():
         assert rv.status_code == 404
 
     def test_search_datasets(self, client, example_elastic_calcs, no_warn, other_test_user_auth):
-        rv = client.get('/repo/?owner=all', headers=other_test_user_auth)
+        rv = client.get('/repo/?owner=all&datasets=true', headers=other_test_user_auth)
         data = self.assert_search(rv, 4)
 
         datasets = data.get('datasets', None)
@@ -790,7 +790,7 @@ class TestRepo():
         (0, 'quantities', 'dos')
     ])
     def test_search_parameters(self, client, example_elastic_calcs, no_warn, test_user_auth, calcs, quantity, value):
-        query_string = urlencode({quantity: value}, doseq=True)
+        query_string = urlencode({quantity: value, 'statistics': True}, doseq=True)
 
         rv = client.get('/repo/?%s' % query_string, headers=test_user_auth)
         logger.debug('run search quantities test', query_string=query_string)
@@ -819,7 +819,7 @@ class TestRepo():
 
     @pytest.mark.parametrize('metrics', metrics_permutations)
     def test_search_total_metrics(self, client, example_elastic_calcs, no_warn, metrics):
-        rv = client.get('/repo/?%s' % urlencode(dict(metrics=metrics), doseq=True))
+        rv = client.get('/repo/?%s' % urlencode(dict(metrics=metrics, statistics=True, datasets=True), doseq=True))
         assert rv.status_code == 200, str(rv.data)
         data = json.loads(rv.data)
         total_metrics = data.get('statistics', {}).get('total', {}).get('all', None)
@@ -830,7 +830,7 @@ class TestRepo():
 
     @pytest.mark.parametrize('metrics', metrics_permutations)
     def test_search_aggregation_metrics(self, client, example_elastic_calcs, no_warn, metrics):
-        rv = client.get('/repo/?%s' % urlencode(dict(metrics=metrics), doseq=True))
+        rv = client.get('/repo/?%s' % urlencode(dict(metrics=metrics, statistics=True, datasets=True), doseq=True))
         assert rv.status_code == 200
         data = json.loads(rv.data)
         for name, quantity in data.get('statistics').items():
@@ -844,7 +844,6 @@ class TestRepo():
 
     def test_search_date_histogram(self, client, example_elastic_calcs, no_warn):
         rv = client.get('/repo/?date_histogram=true&metrics=total_energies')
-        print(rv.data)
         assert rv.status_code == 200
         data = json.loads(rv.data)
         histogram = data.get('statistics').get('date_histogram')
@@ -852,7 +851,7 @@ class TestRepo():
 
     @pytest.mark.parametrize('n_results, page, per_page', [(2, 1, 5), (1, 1, 1), (0, 2, 3)])
     def test_search_pagination(self, client, example_elastic_calcs, no_warn, n_results, page, per_page):
-        rv = client.get('/repo/?page=%d&per_page=%d' % (page, per_page))
+        rv = client.get('/repo/?page=%d&per_page=%d&statistics=true' % (page, per_page))
         assert rv.status_code == 200
         data = json.loads(rv.data)
         results = data.get('results', None)
-- 
GitLab