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> (<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