diff --git a/gui/src/components/MetaInfoRepository.js b/gui/src/components/MetaInfoRepository.js index 4300848329c960fc11dc7b8178c88fa2d75fd40d..6a9c2eb48febb6252f4b3ef62004f26b7ee3cc12 100644 --- a/gui/src/components/MetaInfoRepository.js +++ b/gui/src/components/MetaInfoRepository.js @@ -242,13 +242,12 @@ export default class MetaInfoRepository { return definition } - const metaInfos = json || [] + const metaInfos = json.metaInfos || [] const pkg = { mType: schema.pkg, name: name, - definitions: metaInfos - .filter(metaInfo => !metaInfo.name.startsWith('x_')) - .map(transformMetaInfo) + description: json.description, + definitions: metaInfos.map(transformMetaInfo) } this.addName(pkg) diff --git a/gui/src/components/api.js b/gui/src/components/api.js index c2a998aa8dc416dfbccc70ca5d87529d059c91bf..ea216149a4152aad6fae966b17130d38e38689a9 100644 --- a/gui/src/components/api.js +++ b/gui/src/components/api.js @@ -262,23 +262,28 @@ class Api { .finally(this.onFinishLoading) } - _metaInfoRepository = null + _metaInfoRepositories = {} - async getMetaInfo() { - if (this._metaInfoRepository) { - return this._metaInfoRepository + async getMetaInfo(pkg) { + pkg = pkg || 'all.nomadmetainfo.json' + + const metaInfoRepository = this._metaInfoRepositories[pkg] + + if (metaInfoRepository) { + return metaInfoRepository } else { this.onStartLoading() const loadMetaInfo = async(path) => { const client = await this.swaggerPromise - return client.apis.archive.get_metainfo({metainfo_path: path}) + return client.apis.archive.get_metainfo({metainfo_package_name: path}) .catch(this.handleApiError) .then(response => response.body) } - const metaInfos = await loadMetaInfo('all.nomadmetainfo.json') - this._metaInfoRepository = new MetaInfoRepository({'all.nomadmetainfo.json': metaInfos}) + const metaInfo = await loadMetaInfo(pkg) + const metaInfoRepository = new MetaInfoRepository(metaInfo) + this._metaInfoRepositories[pkg] = metaInfoRepository this.onFinishLoading() - return this._metaInfoRepository + return metaInfoRepository } } diff --git a/gui/src/components/metaInfoBrowser/DefinitionCard.js b/gui/src/components/metaInfoBrowser/DefinitionCard.js index 97d4321cb3e686d3fd2dce7058b8459bc17f6a82..e333d41cc5d9ab3d175d8d0198b7bce0ada2d943 100644 --- a/gui/src/components/metaInfoBrowser/DefinitionCard.js +++ b/gui/src/components/metaInfoBrowser/DefinitionCard.js @@ -1,7 +1,8 @@ import React from 'react' import PropTypes from 'prop-types' import { withStyles } from '@material-ui/core/styles' -import { Paper, Typography, Link } from '@material-ui/core' +import { Paper, Typography } from '@material-ui/core' +import { Link } from 'react-router-dom' import { schema } from '../MetaInfoRepository' import ReactJson from 'react-json-view' import { Card, CardButton, CardCompartment, PopoverCardButton } from './util/cards' @@ -93,8 +94,9 @@ class DefinitionCardUnstyled extends React.Component { onClick={() => toggleDefinition(definition.parent, !parentIsVisible)}/> : '' } - <CardButton position="center" size="tiny" icon="launch" component={Link} - to={`/metainfo/${definition.name}`} /> + <CardButton position="center" size="tiny" icon="launch" + component={props => <Link to={`/metainfo/${definition.name}`} {...props} />} + /> <PopoverCardButton position="center" icon="code" classes={{content: classes.source}} size="tiny"> <ReactJson src={definition.miJson} /> </PopoverCardButton> @@ -123,20 +125,9 @@ class DefinitionCardUnstyled extends React.Component { } renderDescription(description) { - // const {classes} = this.props description = description.replace(/(([A-Za-z0-9]+_)+[A-Za-z0-9]+)/g, '`$1`') - // const paragraph = (props) => ( - // <Typography classes={{root: classes.descriptionParagraph}}>{props.children}</Typography> - // ) - // // apply mono space to definition names and add invisible zero space to each _ to allow line breaks - // const inlineCode = (props) => ( - // <a href={`/${props.children}`} style={{fontFamily: 'RobotoMono, monospace', fontWeight: 'bold'}}> - // {props.children.replace(/_/g, `\u200B_`)} - // </a> - // ) return ( <Markdown>{description}</Markdown> - // <ReactMarkdown source={description} className={classes.description} renderers={{paragraph: paragraph, inlineCode: inlineCode}}/> ) } } diff --git a/gui/src/components/metaInfoBrowser/MetaInfoBrowser.js b/gui/src/components/metaInfoBrowser/MetaInfoBrowser.js index f1beec63ea15836dd614f87a4b6c11c3e0d4e5ed..5ebf9b1b47ad02cb73e5059c2f2d07c86d335909 100644 --- a/gui/src/components/metaInfoBrowser/MetaInfoBrowser.js +++ b/gui/src/components/metaInfoBrowser/MetaInfoBrowser.js @@ -1,42 +1,161 @@ import React, { Component } from 'react' +import { withRouter } from 'react-router-dom' import Viewer from './Viewer' import PropTypes from 'prop-types' import { withApi } from '../api' +import { Help } from '../help' +import MetainfoSearch from './MetainfoSearch' +import { FormControl, withStyles, Select, Input, MenuItem, ListItemText, InputLabel, FormGroup } from '@material-ui/core' +import { compose } from 'recompose' + +const ITEM_HEIGHT = 48 +const ITEM_PADDING_TOP = 8 +const MenuProps = { + PaperProps: { + style: { + maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, + width: 300 + } + } +} class MetaInfoBrowser extends Component { static propTypes = { + classes: PropTypes.object.isRequired, metainfo: PropTypes.string, api: PropTypes.object.isRequired, - raiseError: PropTypes.func.isRequired + loading: PropTypes.number, + raiseError: PropTypes.func.isRequired, + history: PropTypes.object.isRequired } + static styles = theme => ({ + root: {}, + forms: { + padding: `${theme.spacing.unit * 3}px ${theme.spacing.unit * 3}px 0 ${theme.spacing.unit * 3}px` + }, + packageSelect: { + width: 300 + }, + search: { + marginLeft: theme.spacing.unit * 3 + } + }) + state = { - metainfos: null + metainfos: null, + allMetainfos: null, + selectedPackage: null, + loadedPackage: null } - componentDidMount() { - this.props.api.getMetaInfo().then(metainfos => { - this.setState({metainfos: metainfos}) + constructor(props) { + super(props) + this.handleSelectedPackageChanged = this.handleSelectedPackageChanged.bind(this) + this.handleSearch = this.handleSearch.bind(this) + } + + update(pkg) { + this.props.api.getMetaInfo(pkg).then(metainfos => { + const metainfoName = this.props.metainfo || 'section_run' + const definition = metainfos.get(metainfoName) + if (!definition) { + this.props.history.push('/metainfo/section_run') + } else { + this.setState({loadedPackage: pkg, metainfos: metainfos}) + } + }).catch(error => { + this.props.raiseError(error) + }) + } + + init() { + this.props.api.getMetaInfo('all.nomadmetainfo.json').then(metainfos => { + const metainfoName = this.props.metainfo || 'section_run' + const definition = metainfos.get(metainfoName) + this.setState({allMetainfos: metainfos, selectedPackage: definition.package.name}) + this.update(definition.package.name) }).catch(error => { this.props.raiseError(error) }) } + componentDidUpdate(prevProps) { + if (this.props.metainfo !== prevProps.metainfo) { + this.init() + } + } + + componentDidMount() { + this.init() + } + + handleSelectedPackageChanged(event) { + this.setState({selectedPackage: event.target.value}) + this.update(event.target.value) + } + + handleSearch(term) { + if (this.state.metainfos.get(term)) { + this.props.history.push(`/metainfo/${term}`) + } + } + render() { - const { metainfos } = this.state + const { classes, loading } = this.props + const { metainfos, selectedPackage, allMetainfos, loadedPackage } = this.state - if (!metainfos) { + if (!metainfos || !allMetainfos) { return <div /> } const metainfoName = this.props.metainfo || 'section_run' const metainfo = metainfos.resolve(metainfos.createProxy(metainfoName)) - return <Viewer rootElement={metainfo} - packages={metainfos.contents} - visiblePackages={metainfos.contents.map(pkg => pkg.name)} /> + return <div> + <div className={classes.forms}> + <Help cookie="uploadList">{` + The nomad *metainfo* is the schema that nomad uses to define the quantities that + comprise all nomad data. The nomad metainfo is consist of definitions for: + + - **sections**: A section are nested groups of quantities that allow a hierarchical data structure + - **values**: Actual quantities that contain data + - **references**: References that allow to connect related sections. + + All definitions have a name that you can search for. Furthermore, all definitions + are organized in packages. There is a *common* package with definitions that are + used by all codes and there are packages for each code with code specific definitions. + `}</Help> + <form style={{ display: 'flex' }}> + <FormControl disabled={loading > 0}> + <InputLabel htmlFor="select-multiple-checkbox">Package</InputLabel> + <Select + className={classes.packageSelect} + value={selectedPackage} + onChange={this.handleSelectedPackageChanged} + input={<Input id="select-multiple-checkbox" />} + MenuProps={MenuProps} + > + {allMetainfos.contents + .map(pkg => pkg.name) + .map(name => { + return <MenuItem key={name} value={name}> + <ListItemText primary={name.substring(0, name.length - 19)} /> + </MenuItem> + }) + } + </Select> + </FormControl> + <MetainfoSearch classes={{root: classes.search}} + suggestions={Object.values(metainfos.names)} + onChange={this.handleSearch} + /> + </form> + </div> + <Viewer key={loadedPackage} rootElement={metainfo} packages={metainfos.contents} /> + </div> } } -export default withApi(false)(MetaInfoBrowser) +export default compose(withRouter, withApi(false), withStyles(MetaInfoBrowser.styles))(MetaInfoBrowser) diff --git a/gui/src/components/metaInfoBrowser/MetainfoSearch.js b/gui/src/components/metaInfoBrowser/MetainfoSearch.js new file mode 100644 index 0000000000000000000000000000000000000000..d81ea1b988898c0e21664d9c3c70e2592eda3dd1 --- /dev/null +++ b/gui/src/components/metaInfoBrowser/MetainfoSearch.js @@ -0,0 +1,179 @@ +import React from 'react' +import PropTypes from 'prop-types' +import deburr from 'lodash/deburr' +import Autosuggest from 'react-autosuggest' +import match from 'autosuggest-highlight/match' +import parse from 'autosuggest-highlight/parse' +import TextField from '@material-ui/core/TextField' +import Paper from '@material-ui/core/Paper' +import MenuItem from '@material-ui/core/MenuItem' +import { withStyles } from '@material-ui/core/styles' + +function renderInputComponent(inputProps) { + const { classes, inputRef = () => {}, ref, ...other } = inputProps + + return ( + <TextField + fullWidth + InputProps={{ + inputRef: node => { + ref(node) + inputRef(node) + }, + classes: { + input: classes.input + } + }} + {...other} + /> + ) +} + +function renderSuggestion(suggestion, { query, isHighlighted }) { + const matches = match(suggestion.name, query) + const parts = parse(suggestion.name, matches) + + return ( + <MenuItem selected={isHighlighted} component="div"> + <div> + {parts.map((part, index) => + part.highlight ? ( + <span key={String(index)} style={{ fontWeight: 500 }}> + {part.text} + </span> + ) : ( + <strong key={String(index)} style={{ fontWeight: 300 }}> + {part.text} + </strong> + ) + )} + </div> + </MenuItem> + ) +} + +function getSuggestionValue(suggestion) { + return suggestion.name +} + +const styles = theme => ({ + root: { + height: 250, + flexGrow: 1 + }, + container: { + position: 'relative' + }, + suggestionsContainerOpen: { + position: 'absolute', + zIndex: 1000, + marginTop: theme.spacing.unit, + left: 0, + right: 0 + }, + suggestion: { + display: 'block' + }, + suggestionsList: { + margin: 0, + padding: 0, + listStyleType: 'none' + }, + divider: { + height: theme.spacing.unit * 2 + } +}) + +class MetainfoSearch extends React.Component { + state = { + single: '', + popper: '', + suggestions: [] + }; + + getSuggestions(value) { + const inputValue = deburr(value.trim()).toLowerCase() + const inputLength = inputValue.length + let count = 0 + + return inputLength === 0 + ? [] + : this.props.suggestions.filter(suggestion => { + const keep = + count < 5 && suggestion.name.slice(0, inputLength).toLowerCase() === inputValue + + if (keep) { + count += 1 + } + + return keep + }) + } + + handleSuggestionsFetchRequested = ({ value }) => { + this.setState({ + suggestions: this.getSuggestions(value) + }) + } + + handleSuggestionsClearRequested = () => { + this.setState({ + suggestions: [] + }) + } + + handleChange = name => (event, { newValue }) => { + this.setState({ + [name]: newValue + }) + this.props.onChange(newValue) + } + + render() { + const { classes } = this.props + + const autosuggestProps = { + renderInputComponent, + suggestions: this.state.suggestions, + onSuggestionsFetchRequested: this.handleSuggestionsFetchRequested, + onSuggestionsClearRequested: this.handleSuggestionsClearRequested, + getSuggestionValue, + renderSuggestion + } + + return ( + <Autosuggest + {...autosuggestProps} + inputProps={{ + classes: classes, + label: 'Definition', + placeholder: 'Enter definition name', + value: this.state.single, + onChange: this.handleChange('single'), + InputLabelProps: { + shrink: true + } + }} + theme={{ + container: classes.container, + suggestionsContainerOpen: classes.suggestionsContainerOpen, + suggestionsList: classes.suggestionsList, + suggestion: classes.suggestion + }} + renderSuggestionsContainer={options => ( + <Paper {...options.containerProps} square> + {options.children} + </Paper> + )} + /> + ) + } +} + +MetainfoSearch.propTypes = { + classes: PropTypes.object.isRequired, + suggestions: PropTypes.array.isRequired, + onChange: PropTypes.func.isRequired +} + +export default withStyles(styles)(MetainfoSearch) diff --git a/gui/src/components/metaInfoBrowser/SectionFeature.js b/gui/src/components/metaInfoBrowser/SectionFeature.js index 1a38392c601a4ac307cab5919f9de22a804d0d1d..7b980ecccd764dd2682b2151e49389ccbba06544 100644 --- a/gui/src/components/metaInfoBrowser/SectionFeature.js +++ b/gui/src/components/metaInfoBrowser/SectionFeature.js @@ -14,7 +14,7 @@ class SectionFeature extends React.Component { }, visible: { extends: 'root', - backgroundColor: grey[200] + backgroundColor: grey[300] }, gutters: { paddingLeft: theme.spacing.unit, diff --git a/gui/src/components/metaInfoBrowser/Viewer.js b/gui/src/components/metaInfoBrowser/Viewer.js index 4aff1b888de4a739c950d56c76359ebb15a49fd7..37f9ceefafc2363b9ef2023a03ca1ebfd1cd1841 100644 --- a/gui/src/components/metaInfoBrowser/Viewer.js +++ b/gui/src/components/metaInfoBrowser/Viewer.js @@ -2,7 +2,6 @@ import React from 'react' import PropTypes from 'prop-types' import { withStyles } from '@material-ui/core/styles' import grey from '@material-ui/core/colors/grey' -import blue from '@material-ui/core/colors/blue' import { AfterRender } from './util/after-render' import { Definitions } from './Definition' import { updateList } from './util/data' @@ -12,17 +11,11 @@ class ViewerUnstyled extends React.Component { static propTypes = { classes: PropTypes.object.isRequired, rootElement: PropTypes.object.isRequired, - packages: PropTypes.arrayOf(PropTypes.object).isRequired, - visiblePackages: PropTypes.arrayOf(PropTypes.string).isRequired + packages: PropTypes.arrayOf(PropTypes.object).isRequired } static styles = (theme) => ({ - root: { - - }, - packages: { - padding: theme.spacing.unit - }, + root: {}, container: { position: 'relative' }, @@ -32,7 +25,7 @@ class ViewerUnstyled extends React.Component { flexWrap: 'nowrap', justifyContent: 'flex-start', alignItems: 'center', - padding: theme.spacing.unit, + padding: theme.spacing.unit * 2, zIndex: 1 }, sankey: { @@ -43,11 +36,11 @@ class ViewerUnstyled extends React.Component { zIndex: 0 }, references: { - fill: blue[200], + fill: theme.palette.primary[200], fillOpacity: 0.8 }, containments: { - fill: grey[200], + fill: grey[300], fillOpacity: 0.8 } }) @@ -60,10 +53,6 @@ class ViewerUnstyled extends React.Component { this.isVisible = this.isVisible.bind(this) this.state = { - packages: this.props.packages.map(pkg => ({ - name: pkg.name, - visible: this.props.visiblePackages.find(name => pkg.name === name) !== undefined - })), definitions: [{ definition: this.props.rootElement, state: [] @@ -219,11 +208,6 @@ class ViewerUnstyled extends React.Component { const {classes} = this.props return ( <div className={classes.root}> - {/* <div className={classes.packages}> - {this.state.packages.map(({name, visible}, index) => ( - <CheckChip key={index} label={name} checked={visible}/> - ))} - </div> */} <div ref={this.canvasRef} className={classes.container}> <AfterRender classes={{content: classes.canvas, afterRender: classes.sankey}} afterRender={this.renderSankey}> <Definitions current={this.props.rootElement} diff --git a/gui/src/components/metaInfoBrowser/util/after-render.js b/gui/src/components/metaInfoBrowser/util/after-render.js index 496496154e61e053b2c8ebf77313647af1473b38..4ab79c94d9cb58b2caa5238f5bd28988e2eb1240 100644 --- a/gui/src/components/metaInfoBrowser/util/after-render.js +++ b/gui/src/components/metaInfoBrowser/util/after-render.js @@ -221,13 +221,11 @@ class AfterRenderTestUnstyled extends React.Component { render() { const {classes} = this.props return ( - <div style={{padding: 10}}> - <AfterRender afterRender={this.afterRender} classes={{afterRender: classes.afterRender}}> - <AfterRenderMeasure measureId={1}>Hello</AfterRenderMeasure> - <AfterRenderMeasure measureId={2}>Hello World</AfterRenderMeasure> - <Button>click</Button> - </AfterRender> - </div> + <AfterRender afterRender={this.afterRender} classes={{afterRender: classes.afterRender}}> + <AfterRenderMeasure measureId={1}>Hello</AfterRenderMeasure> + <AfterRenderMeasure measureId={2}>Hello World</AfterRenderMeasure> + <Button>click</Button> + </AfterRender> ) } } diff --git a/nomad/api/archive.py b/nomad/api/archive.py index 9c7a51ded1f45859b4494a659fe730dc907fb183..b76e7189fd2e6253ed69680fd72800edf91aca8a 100644 --- a/nomad/api/archive.py +++ b/nomad/api/archive.py @@ -17,13 +17,13 @@ The archive API of the nomad@FAIRDI APIs. This API is about serving processed (parsed and normalized) calculation data in nomad's *meta-info* format. """ +from typing import Dict, Any import os.path - from flask import send_file from flask_restplus import abort, Resource +import json import nomad_meta_info -from nomadcore.local_meta_info import load_metainfo from nomad.files import UploadFiles, Restricted @@ -111,21 +111,64 @@ class ArchiveCalcResource(Resource): abort(404, message='Calculation %s does not exist.' % archive_id) -@ns.route('/metainfo/<string:metainfo_path>') -@api.doc(params=dict(nomad_metainfo_path='A path or metainfo definition file name.')) +@ns.route('/metainfo/<string:metainfo_package_name>') +@api.doc(params=dict(metainfo_package_name='The name of the metainfo package.')) class MetainfoResource(Resource): @api.doc('get_metainfo') @api.response(404, 'The metainfo does not exist') @api.response(200, 'Metainfo data send') - def get(self, metainfo_path): + def get(self, metainfo_package_name): """ Get a metainfo definition file. """ try: - file_dir = os.path.dirname(os.path.abspath(nomad_meta_info.__file__)) - metainfo_file = os.path.normpath(os.path.join(file_dir, metainfo_path.strip())) - - meta_info, _ = load_metainfo(metainfo_file) - return meta_info.toJsonList(False), 200 + return load_metainfo(metainfo_package_name), 200 except FileNotFoundError: - abort(404, message='The metainfo %s does not exist.' % metainfo_path) + abort(404, message='The metainfo %s does not exist.' % metainfo_package_name) + + +metainfo_main_path = os.path.dirname(os.path.abspath(nomad_meta_info.__file__)) + + +def load_metainfo(package_name: str, is_path: bool = False, loaded_packages: Dict[str, Any] = None) -> Dict[str, Any]: + """ + Loads the given metainfo package and all its dependencies. Returns a dict with + all loaded package_names and respective packages. + + Arguments: + package_name: The name of the package, or a path to .nomadmetainfo.json file. + is_path: True will interpret package_name as (relative) path. + loaded_packages: Give a dict and the function will added freshly loaded packages + to it and return it. + """ + if loaded_packages is None: + loaded_packages = {} + + if is_path: + metainfo_path = package_name + else: + metainfo_path = os.path.join(metainfo_main_path, package_name) + + package_name = os.path.basename(package_name) + + if package_name in loaded_packages: + return loaded_packages + + with open(metainfo_path, 'rt') as f: + metainfo_json = json.load(f) + + loaded_packages[package_name] = metainfo_json + + for dependency in metainfo_json.get('dependencies', []): + if 'relativePath' in dependency: + dependency_path = os.path.join( + os.path.dirname(metainfo_path), dependency['relativePath']) + elif 'metainfoPath' in dependency: + dependency_path = os.path.join(metainfo_main_path, dependency['metainfoPath']) + else: + raise Exception( + 'Invalid dependency type in metainfo package %s' % metainfo_path) + + load_metainfo(dependency_path, is_path=True, loaded_packages=loaded_packages) + + return loaded_packages diff --git a/tests/test_api.py b/tests/test_api.py index ec496c46bb61b4388963e005077bc335a04f3739..ad1b20a044860076afc5a07b26450d3c6fe09fb9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -546,11 +546,6 @@ class TestArchive(UploadFilesBasedTests): rv = client.get('/archive/metainfo/all.nomadmetainfo.json') assert rv.status_code == 200 metainfo = json.loads((rv.data)) - names = {} - for item in metainfo: - name = item['name'] - assert name not in names - names[name] = item assert len(metainfo) > 0