From f1e29ac9eabe494659a09f2ed1500c993c805f61 Mon Sep 17 00:00:00 2001 From: Lauri Himanen <lauri.himanen@gmail.com> Date: Tue, 14 Sep 2021 15:34:13 +0300 Subject: [PATCH] Removed dead code related to the old search interface, removed the 'New'-prefix from the search components. --- gui/src/components/DatasetPage.js | 6 +- gui/src/components/UserdataPage.js | 6 +- gui/src/components/dft/DFTVisualizations.js | 189 --- gui/src/components/domainData.js | 34 - gui/src/components/ems/EMSVisualizations.js | 42 - gui/src/components/search/FilterChip.js | 4 + gui/src/components/search/FilterContext.js | 1143 -------------- gui/src/components/search/FilterSummary.js | 6 +- gui/src/components/search/NewSearch.js | 145 -- gui/src/components/search/NewSearchBar.js | 431 ------ .../components/search/QuantityHistogram.js | 145 -- gui/src/components/search/Search.js | 733 ++------- gui/src/components/search/SearchBar.js | 674 +++++---- gui/src/components/search/SearchContext.js | 1335 +++++++++++++---- gui/src/components/search/SearchPage.js | 79 - .../components/search/SearchPageEntries.js | 6 +- .../components/search/SearchPageMaterials.js | 6 +- gui/src/components/search/UploadsHistogram.js | 300 ---- .../components/search/input/InputCheckbox.js | 2 +- .../search/input/InputCheckboxes.js | 2 +- .../components/search/input/InputDateRange.js | 2 +- .../search/input/InputPeriodicTable.js | 4 +- gui/src/components/search/input/InputRadio.js | 2 +- .../components/search/input/InputSelect.js | 2 +- .../components/search/input/InputSlider.js | 2 +- gui/src/components/search/input/InputText.js | 2 +- .../components/search/input/PeriodicTable.js | 230 --- .../components/search/menus/FilterMainMenu.js | 2 +- gui/src/components/search/menus/FilterMenu.js | 2 +- .../search/menus/FilterSubMenuElements.js | 2 +- .../components/search/results/GroupList.js | 243 --- .../search/results/MaterialsList.js | 141 -- .../search/results/SearchResults.js | 3 +- .../search/results/UploadersList.js | 50 - ...eriodicTableData.json => elementData.json} | 0 35 files changed, 1584 insertions(+), 4391 deletions(-) delete mode 100644 gui/src/components/dft/DFTVisualizations.js delete mode 100644 gui/src/components/ems/EMSVisualizations.js delete mode 100644 gui/src/components/search/FilterContext.js delete mode 100644 gui/src/components/search/NewSearch.js delete mode 100644 gui/src/components/search/NewSearchBar.js delete mode 100644 gui/src/components/search/QuantityHistogram.js delete mode 100644 gui/src/components/search/SearchPage.js delete mode 100644 gui/src/components/search/UploadsHistogram.js delete mode 100644 gui/src/components/search/input/PeriodicTable.js delete mode 100644 gui/src/components/search/results/GroupList.js delete mode 100644 gui/src/components/search/results/MaterialsList.js delete mode 100644 gui/src/components/search/results/UploadersList.js rename gui/src/{components/search/input/PeriodicTableData.json => elementData.json} (100%) diff --git a/gui/src/components/DatasetPage.js b/gui/src/components/DatasetPage.js index 6d30b2c041..143a63a1e4 100644 --- a/gui/src/components/DatasetPage.js +++ b/gui/src/components/DatasetPage.js @@ -20,8 +20,8 @@ import PropTypes from 'prop-types' import { Typography, makeStyles } from '@material-ui/core' import { errorContext } from './errors' import { useApi } from './apiV1' -import NewSearch from './search/NewSearch' -import { SearchContext } from './search/FilterContext' +import Search from './search/Search' +import { SearchContext } from './search/SearchContext' import { DOI } from './search/results/DatasetList' export const help = ` @@ -60,7 +60,7 @@ const UserdataPage = React.memo(({match}) => { resource="entries" filtersLocked={datasetFilter} > - <NewSearch header={ + <Search header={ <div className={styles.header}> <Typography variant="h4"> {dataset.name || (dataset.isEmpty && 'Empty or non existing dataset') || 'loading ...'} diff --git a/gui/src/components/UserdataPage.js b/gui/src/components/UserdataPage.js index f8570f6e7c..46df92171d 100644 --- a/gui/src/components/UserdataPage.js +++ b/gui/src/components/UserdataPage.js @@ -17,8 +17,8 @@ */ import React from 'react' import { withLoginRequired } from './apiV1' -import { SearchContext } from './search/FilterContext' -import NewSearch from './search/NewSearch' +import { SearchContext } from './search/SearchContext' +import Search from './search/Search' export const help = ` This page allows you to **inspect** and **manage** you own data. It is similar to the @@ -78,7 +78,7 @@ const UserdataPage = React.memo(() => { resource="entries" filtersLocked={filtersLocked} > - <NewSearch/> + <Search/> </SearchContext> }) diff --git a/gui/src/components/dft/DFTVisualizations.js b/gui/src/components/dft/DFTVisualizations.js deleted file mode 100644 index dfeb5e7a07..0000000000 --- a/gui/src/components/dft/DFTVisualizations.js +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Copyright The NOMAD Authors. - * - * This file is part of NOMAD. See https://nomad-lab.eu for further info. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, { useContext, useEffect } from 'react' -import PropTypes from 'prop-types' -import { Grid } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import QuantityHistogram from '../search/QuantityHistogram' -import { searchContext } from '../search/SearchContext' -import { resolveRef } from '../archive/metainfo' -import { nomadTheme } from '../../config' -import Markdown from '../Markdown' - -export function DFTMethodVisualizations(props) { - const {info} = props - const {response: {statistics, metric}, setStatistics} = useContext(searchContext) - useEffect(() => { - setStatistics(['dft.code_name', 'dft.basis_set', 'dft.xc_functional']) - // eslint-disable-next-line - }, []) - - if (statistics.code_name && info) { - // filter based on known codes, since elastic search might return 0 aggregations on - // obsolete code names - const filteredCodeNames = {} - const defaultValue = { - code_runs: 0 - } - defaultValue[metric] = 0 - info.codes.forEach(key => { - filteredCodeNames[key] = statistics.code_name[key] || defaultValue - }) - statistics.code_name = filteredCodeNames - } - - return ( - <Grid container spacing={2}> - <Grid item xs={8}> - <QuantityHistogram quantity="dft.code_name" title="Code" initialScale={0.25} columns={2} /> - </Grid> - <Grid item xs={4}> - <QuantityHistogram quantity="dft.basis_set" title="Basis set" initialScale={0.25} /> - <QuantityHistogram quantity="dft.xc_functional" title="XC functionals" initialScale={0.5} /> - </Grid> - </Grid> - ) -} - -DFTMethodVisualizations.propTypes = { - info: PropTypes.object -} - -export function DFTSystemVisualizations(props) { - const {info} = props - const {response: {statistics, metric}, setStatistics} = useContext(searchContext) - useEffect(() => { - setStatistics(['dft.labels_springer_compound_class', 'dft.system', 'dft.crystal_system', 'dft.compound_type']) - // eslint-disable-next-line - }, []) - - if (statistics.code_name && info) { - // filter based on known codes, since elastic search might return 0 aggregations on - // obsolete code names - const filteredCodeNames = {} - const defaultValue = { - code_runs: 0 - } - defaultValue[metric] = 0 - info.codes.forEach(key => { - filteredCodeNames[key] = statistics.code_name[key] || defaultValue - }) - statistics.code_name = filteredCodeNames - } - - return ( - <Grid container spacing={2}> - <Grid item xs={4}> - <QuantityHistogram quantity="dft.system" title="System type" initialScale={0.25} /> - <QuantityHistogram quantity="dft.crystal_system" title="Crystal system" /> - </Grid> - <Grid item xs={4}> - <QuantityHistogram quantity="dft.compound_type" title="Compound type" initialScale={0.25} /> - </Grid> - <Grid item xs={4}> - <QuantityHistogram quantity="dft.labels_springer_compound_class" title="Compound classification" /> - </Grid> - </Grid> - ) -} - -DFTSystemVisualizations.propTypes = { - info: PropTypes.object -} - -const useMetainfoTooltipStyles = makeStyles(theme => ({ - root: { - display: 'flex', - flexDirection: 'column', - padding: 2 - }, - tooltipMarkdown: { - fontSize: nomadTheme.overrides.MuiTooltip.tooltip.fontSize, - color: 'white', - '& a': { - color: theme.palette.primary.light - } - } -})) - -function MetaInfoTooltip({def, path}) { - const classes = useMetainfoTooltipStyles() - let description = def.description - if (!description && def.sub_section) { - description = resolveRef(def.sub_section)?.description - } - return <div className={classes.root} > - <Markdown - classes={{root: classes.tooltipMarkdown}} - >{`${description?.slice(0, description.indexOf('.') || undefined)}. Click [here](/metainfo/${path}) for full the definition.`}</Markdown> - </div> -} - -MetaInfoTooltip.propTypes = { - def: PropTypes.object, - path: PropTypes.string -} - -const workflowTypeLabels = { - 'geometry_optimization': 'geometry optimization', - 'phonon': 'phonons', - 'elastic': 'elastic constants', - 'molecular_dynamics': 'molecular dynamics' -} - -export function DFTPropertyVisualizations(props) { - const {info} = props - const {response: {statistics, metric}, setStatistics} = useContext(searchContext) - useEffect(() => { - setStatistics([ - 'dft.searchable_quantities', - 'dft.labels_springer_classification', - 'dft.workflow.type' - ]) - // eslint-disable-next-line - }, []) - - if (statistics.code_name && info) { - // filter based on known codes, since elastic search might return 0 aggregations on - // obsolete code names - const filteredCodeNames = {} - const defaultValue = { - code_runs: 0 - } - defaultValue[metric] = 0 - info.codes.forEach(key => { - filteredCodeNames[key] = statistics.code_name[key] || defaultValue - }) - statistics.code_name = filteredCodeNames - } - - return ( - <Grid container spacing={2}> - <Grid item xs={12}> - <QuantityHistogram quantity="dft.labels_springer_classification" title="Functional classification" initialScale={1} multiple/> - </Grid> - <Grid item xs={12}> - <QuantityHistogram quantity="dft.workflow.type" title="Workflows" valueLabels={workflowTypeLabels} initialScale={0.25} /> - </Grid> - </Grid> - ) -} - -DFTPropertyVisualizations.propTypes = { - info: PropTypes.object -} diff --git a/gui/src/components/domainData.js b/gui/src/components/domainData.js index 7de1385b33..012e7783a3 100644 --- a/gui/src/components/domainData.js +++ b/gui/src/components/domainData.js @@ -16,10 +16,6 @@ * limitations under the License. */ import React from 'react' -import { - DFTSystemVisualizations, DFTPropertyVisualizations, DFTMethodVisualizations -} from './dft/DFTVisualizations' -import EMSVisualizations from './ems/EMSVisualizations' import { Link, Typography } from '@material-ui/core' import { amber } from '@material-ui/core/colors' @@ -42,27 +38,6 @@ export const domainData = ({ ? data.dft.code_name.charAt(0).toUpperCase() + data.dft.code_name.slice(1) + ' run' : 'Code run', searchPlaceholder: 'enter atoms, codes, functionals, or other quantity values', - /** - * A set of components and metadata that is used to present tabs of search visualizations - * in addition to the globally available elements and users view. - */ - searchVisualizations: { - 'system': { - component: DFTSystemVisualizations, - label: 'System', - description: 'Shows histograms on system metadata' - }, - 'method': { - component: DFTMethodVisualizations, - label: 'Method', - description: 'Shows histograms on method metadata' - }, - 'properties': { - component: DFTPropertyVisualizations, - label: 'Properties', - description: 'Shows histograms on the availability of key properties' - } - }, searchMetrics: { code_runs: { label: 'Entries', @@ -178,13 +153,6 @@ export const domainData = ({ entryLabelPlural: 'entries', entryTitle: () => 'Experiment', searchPlaceholder: 'enter atoms, experimental methods, or other quantity values', - searchVisualizations: { - 'metadata': { - component: EMSVisualizations, - label: 'Metadata', - description: 'Shows histograms on system metadata' - } - }, /** * Metrics are used to show values for aggregations. Each metric has a key (used * for API calls), a label (used in the select form), and result string (to show @@ -250,8 +218,6 @@ export const domainData = ({ entryLabelPlural: 'calculations', entryTitle: () => 'Quantum-computer calculation', searchPlaceholder: 'enter atoms', - searchVisualizations: { - }, /** * Metrics are used to show values for aggregations. Each metric has a key (used * for API calls), a label (used in the select form), and result string (to show diff --git a/gui/src/components/ems/EMSVisualizations.js b/gui/src/components/ems/EMSVisualizations.js deleted file mode 100644 index 25606dc12b..0000000000 --- a/gui/src/components/ems/EMSVisualizations.js +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright The NOMAD Authors. - * - * This file is part of NOMAD. See https://nomad-lab.eu for further info. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, { useContext, useEffect } from 'react' -import { Grid } from '@material-ui/core' -import QuantityHistogram from '../search/QuantityHistogram' -import { searchContext } from '../search/SearchContext' - -export default function EMSVisualizations(props) { - const {setStatistics} = useContext(searchContext) - useEffect(() => { - setStatistics(['ems.method', 'ems.probing_method', 'ems.sample_microstructure', 'ems.sample_constituents']) - // eslint-disable-next-line - }, []) - - return ( - <Grid container spacing={2}> - <Grid item xs={6}> - <QuantityHistogram quantity="ems.method" title="Method" /> - <QuantityHistogram quantity="ems.probing_method" title="Probing" /> - </Grid> - <Grid item xs={6}> - <QuantityHistogram quantity="ems.sample_microstructure" title="Sample structure" /> - <QuantityHistogram quantity="ems.sample_constituents" title="Sample constituents" /> - </Grid> - </Grid> - ) -} diff --git a/gui/src/components/search/FilterChip.js b/gui/src/components/search/FilterChip.js index 2588c771ab..c8fa1ab4e1 100644 --- a/gui/src/components/search/FilterChip.js +++ b/gui/src/components/search/FilterChip.js @@ -22,6 +22,10 @@ import LockIcon from '@material-ui/icons/Lock' import { Chip } from '@material-ui/core' import PropTypes from 'prop-types' +/** + * Thin wrapper for MUI Chip that is used for displaying (and possibly removing) + * filter values. + */ const useStyles = makeStyles(theme => ({ root: { padding: theme.spacing(0.5) diff --git a/gui/src/components/search/FilterContext.js b/gui/src/components/search/FilterContext.js deleted file mode 100644 index af4c4fe119..0000000000 --- a/gui/src/components/search/FilterContext.js +++ /dev/null @@ -1,1143 +0,0 @@ -/* - * Copyright The NOMAD Authors. - * - * This file is part of NOMAD. See https://nomad-lab.eu for further info. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, { useCallback, useEffect, useState, useRef, useMemo, useContext } from 'react' -import { - atom, - atomFamily, - selector, - useSetRecoilState, - useRecoilValue, - useRecoilState, - useRecoilCallback -} from 'recoil' -import { - debounce, - isEmpty, - isArray, - isPlainObject, - isNil, - isString -} from 'lodash' -import qs from 'qs' -import PropTypes from 'prop-types' -import { useHistory } from 'react-router-dom' -import { useApi } from '../apiV1' -import { setToArray, formatMeta, parseMeta } from '../../utils' -import searchQuantities from '../../searchQuantities' -import { Quantity } from '../../units' - -export const filters = new Set() // Contains the full names of all the available filters -export const filterGroups = [] // Mapping from filter full name -> group -export const filterAbbreviations = [] // Mapping of filter full name -> abbreviation -export const filterFullnames = [] // Mapping of filter abbreviation -> full name -export const filterData = {} // Stores data for each registered filter -export const labelMaterial = 'Material' -export const labelElements = 'Elements / Formula' -export const labelSymmetry = 'Symmetry' -export const labelMethod = 'Method' -export const labelSimulation = 'Simulation' -export const labelDFT = 'DFT' -export const labelGW = 'GW' -export const labelProperties = 'Properties' -export const labelElectronic = 'Electronic' -export const labelVibrational = 'Vibrational' -export const labelAuthor = 'Author / Origin' -export const labelAccess = 'Access' -export const labelDataset = 'Dataset' -export const labelIDs = 'IDs' - -/** - * This function is used to register a new filter within the FilterContext. - * Filters are entities that can be searched throuh the filter panel and the - * search bar, and can be encoded in the URL. Notice that a filter in this - * context does not have to correspond to a quantity in the metainfo. - * - * Only registered filters may be searched for. The registration must happen - * before any components use the filters. This is because: - * - The initial aggregation results must be fetched before any components - * using the filter values are rendered. - * - Several components need to know the list of available filters (e.g. the - * search bar and the search panel). If filters are only registered during - * component initialization, it may already be too late to update other - * components. - * - * @param {string} name Name of the filter. - * @param {string} group The group into which the filter belongs to. Groups - * are used to e.g. in showing FilterSummaries about a group of filters. - * @param {string|object} agg Custom setter/getter for the aggregation value. As a - * shortcut you can provide an ES aggregation type as a string, - * @param {object} value Custom setter/getter for the filter value. - * @param {bool} multiple Whether this filter supports several values: - * controls whether setting the value appends or overwrites. - */ -function registerFilter(name, group, agg, value, multiple = true) { - filters.add(name) - if (group) { - filterGroups[group] - ? filterGroups[group].add(name) - : filterGroups[group] = new Set([name]) - } - - // Register mappings from full name to abbreviation and vice versa - const abbreviation = name.split('.').pop() - const oldName = filterAbbreviations[abbreviation] - if (!oldName) { - filterAbbreviations[name] = abbreviation - filterFullnames[abbreviation] = name - } else { - delete filterFullnames[abbreviation] - filterAbbreviations[name] = name - filterAbbreviations[oldName] = oldName - } - - const data = filterData[name] || {} - if (agg) { - let aggSet, aggGet - if (isString(agg)) { - aggSet = {[name]: agg} - aggGet = (aggs) => (aggs[name][agg].data) - } else { - aggSet = agg.set - aggGet = agg.get - } - data.aggSet = aggSet - data.aggGet = aggGet - } - if (value) { - data.valueSet = value.set - } - data.multiple = multiple - filterData[name] = data -} - -// Filters that directly correspond to a metainfo value -registerFilter('results.material.structural_type', labelMaterial, 'terms') -registerFilter('results.material.functional_type', labelMaterial, 'terms') -registerFilter('results.material.compound_type', labelMaterial, 'terms') -registerFilter('results.material.material_name', labelMaterial) -registerFilter('results.material.chemical_formula_hill', labelElements) -registerFilter('results.material.chemical_formula_anonymous', labelElements) -registerFilter('results.material.n_elements', labelElements, 'min_max', undefined, false) -registerFilter('results.material.symmetry.bravais_lattice', labelSymmetry, 'terms') -registerFilter('results.material.symmetry.crystal_system', labelSymmetry, 'terms') -registerFilter('results.material.symmetry.structure_name', labelSymmetry, 'terms') -registerFilter('results.material.symmetry.strukturbericht_designation', labelSymmetry, 'terms') -registerFilter('results.material.symmetry.space_group_symbol', labelSymmetry) -registerFilter('results.material.symmetry.point_group', labelSymmetry) -registerFilter('results.material.symmetry.hall_symbol', labelSymmetry) -registerFilter('results.material.symmetry.prototype_aflow_id', labelSymmetry) -registerFilter('results.method.method_name', labelMethod, 'terms') -registerFilter('results.method.simulation.program_name', labelMethod, 'terms') -registerFilter('results.method.simulation.program_version', labelMethod) -registerFilter('results.method.simulation.dft.basis_set_type', labelDFT, 'terms') -registerFilter('results.method.simulation.dft.core_electron_treatment', labelDFT, 'terms') -registerFilter('results.method.simulation.dft.xc_functional_type', labelDFT, 'terms') -registerFilter('results.method.simulation.dft.relativity_method', labelDFT, 'terms') -registerFilter('results.method.simulation.gw.gw_type', labelGW, 'terms') -registerFilter('results.properties.electronic.band_structure_electronic.channel_info.band_gap_type', labelElectronic, 'terms') -registerFilter('results.properties.electronic.band_structure_electronic.channel_info.band_gap', labelElectronic, 'min_max', undefined, false) -registerFilter('external_db', labelAuthor, 'terms') -registerFilter('authors.name', labelAuthor) -registerFilter('upload_time', labelAuthor, 'min_max', undefined, false) -registerFilter('datasets.name', labelDataset) -registerFilter('datasets.doi', labelDataset) -registerFilter('entry_id', labelIDs) -registerFilter('upload_id', labelIDs) -registerFilter('results.material.material_id', labelIDs) -registerFilter('datasets.dataset_id', labelIDs) - -// In exclusive element query the elements names are sorted and concatenated -// into a single string. -registerFilter( - 'results.material.elements', - labelElements, - 'terms', - { - set: (newQuery, oldQuery, value) => { - if (oldQuery.exclusive) { - if (value.size !== 0) { - newQuery['results.material.elements_exclusive'] = setToArray(value).sort().join(' ') - } - } else { - newQuery['results.material.elements'] = value - } - } - } -) -// Electronic properties: subset of results.properties.available_properties -registerFilter( - 'electronic_properties', - labelElectronic, - { - set: {'results.properties.available_properties': 'terms'}, - get: (aggs) => (aggs['results.properties.available_properties'].terms.data) - }, - { - set: (newQuery, oldQuery, value) => { - const data = newQuery['results.properties.available_properties'] || new Set() - value.forEach((item) => { data.add(item) }) - newQuery['results.properties.available_properties'] = data - }, - get: (data) => (data.results.properties.available_properties) - } -) -// Vibrational properties: subset of results.properties.available_properties -registerFilter( - 'vibrational_properties', - labelVibrational, - { - set: {'results.properties.available_properties': 'terms'}, - get: (aggs) => (aggs['results.properties.available_properties'].terms.data) - }, - { - set: (newQuery, oldQuery, value) => { - const data = newQuery['results.properties.available_properties'] || new Set() - value.forEach((item) => { data.add(item) }) - newQuery['results.properties.available_properties'] = data - }, - get: (data) => (data.results.properties.available_properties) - } -) -// Visibility: controls the 'owner'-parameter in the API query, not part of the -// query itself. -registerFilter( - 'visibility', - labelAccess, - undefined, - {set: () => {}}, - false -) -// Restricted: controls whether materials search is done in a restricted mode. -registerFilter( - 'restricted', - undefined, - undefined, - {set: () => {}}, - false -) -// Exclusive: controls the way elements search is done. -registerFilter( - 'exclusive', - undefined, - undefined, - {set: () => {}}, - false -) - -// Material and entry queries target slightly different fields. Here we prebuild -// the mapping. -const materialNames = {} // Mapping of field name from entry -> material -const entryNames = {} // Mapping of field name from material -> entry -for (const name of Object.keys(searchQuantities)) { - const prefix = 'results.material.' - let materialName - if (name.startsWith(prefix)) { - materialName = name.substring(prefix.length) - } else { - materialName = `entries.${name}` - } - materialNames[name] = materialName - entryNames[materialName] = name -} - -export const searchContext = React.createContext() -export const SearchContext = React.memo(({ - resource, - filtersLocked, - children -}) => { - const setQuery = useSetRecoilState(queryState) - const setLocked = useSetRecoilState(lockedState) - const {api} = useApi() - const setInitialAggs = useSetRecoilState(initialAggsState) - - // Reset the query/locks when entering the search context for the first time - const reset = useRecoilCallback(({reset}) => () => { - for (let filter of filters) { - reset(queryFamily(filter)) - reset(lockedFamily(filter)) - } - }, []) - - useEffect(() => { - reset() - }, [reset]) - - // Read the initial query from the URL - const query = useMemo(() => { - const location = window.location.href - const split = location.split('?') - let qs, query - if (split.length === 1) { - query = {} - } else { - qs = split.pop() - query = qsToQuery(qs) - } - return query - }, []) - - // Save the initial query and locked filters. Cannot be done inside useMemo - // due to bad setState. - useEffect(() => { - setQuery(query) - // Transform the locked values into a GUI-suitable format and store them - if (filtersLocked) { - const filtersLockedGUI = {} - for (const [key, value] of Object.entries(filtersLocked)) { - filtersLockedGUI[key] = toGUIFilter(key, value) - } - setLocked(filtersLockedGUI) - } - }, [setLocked, setQuery, query, filtersLocked]) - - // Fetch initial aggregation data. - useEffect(() => { - const aggRequest = {} - const aggNames = [...filters].filter(name => filterData[name].aggGet) - for (const filter of aggNames) { - toAPIAgg(aggRequest, filter, resource) - } - - const search = { - owner: 'visible', - query: {}, - aggregations: aggRequest, - pagination: {page_size: 0} - } - - api.query(resource, search, false) - .then(data => { - data = toGUIAgg(data.aggregations, aggNames, resource) - setInitialAggs(data) - }) - }, [api, setInitialAggs, resource]) - - const values = useMemo(() => ({ - resource: resource - }), [resource]) - - return <searchContext.Provider value={values}> - {children} - </searchContext.Provider> -}) -SearchContext.propTypes = { - resource: PropTypes.string, - filtersLocked: PropTypes.object, - children: PropTypes.node -} - -export function useSearchContext() { - return useContext(searchContext) -} - -/** - * Each search filter is here mapped into a separate Recoil.js Atom. This - * allows components to hook into individual search parameters (both for setting - * and reading their value). This performs much better than having one large - * Atom for the entire query, as this would cause all of the hooked components - * to render even if they are not affected by some other search filter. - * Re-renders became problematic with large and complex components (e.g. the - * periodic table), for which the re-rendering takes significant time. Another - * approach would have been to try and Memoize each sufficiently complex - * component, but this quickly becomes a hard manual task. - */ -export const queryFamily = atomFamily({ - key: 'queryFamily', - default: undefined -}) -export const lockedFamily = atomFamily({ - key: 'lockedFamily', - default: false -}) - -// Menu open state -export const menuOpen = atom({ - key: 'isMenuOpen', - default: false -}) -export function useMenuOpenState() { - return useRecoilState(menuOpen) -} -export function useSetMenuOpen() { - return useSetRecoilState(menuOpen) -} - -// Current menu path -export const menuPath = atom({ - key: 'menuPath', - default: 'Filters' -}) -export function useMenuPathState() { - return useRecoilState(menuPath) -} -export function useMenuPath() { - return useRecoilValue(menuPath) -} -export function useSetMenuPath() { - return useSetRecoilState(menuPath) -} - -// Whether the search is initialized. -export const initializedState = atom({ - key: 'initialized', - default: false -}) - -/** - * Returns a function that can be called to reset all current filters. - * - * @returns Function for resetting all filters. - */ -export function useResetFilters() { - const locked = useRecoilValue(lockedState) - const reset = useRecoilCallback(({reset}) => () => { - for (let filter of filters) { - if (!locked[filter]) { - reset(queryFamily(filter)) - } - } - }, [locked]) - return reset -} - -/** - * This hook will expose a function for reading if the given filter is locked. - * - * @param {string} name Name of the filter. - * @returns Whether the filter is locked or not. - */ -export function useFilterLocked(name) { - return useRecoilValue(lockedFamily(name)) -} - -/** - * This hook will expose a function for reading the locked status of all - * filters. - * - * @returns An object containing a mapping from filter name to a boolean - * indicating whether it is locked or not. - */ -export function useFiltersLocked() { - return useRecoilValue(lockedState) -} - -/** - * This hook will expose a function for reading if the given set of filters are - * locked. - * - * @param {string} names Names of the filters. - * @returns Array containing the filter values in a map and a setter function. - */ -let indexLocked = 0 -export function useFiltersLockedState(names) { - // We dynamically create a Recoil.js selector that is subscribed to the - // filters specified in the input. This way only the specified filters will - // cause a render. Recoil.js requires that each selector/atom has an unique - // id. Because this hook can be called dynamically, we simply generate the ID - // sequentially. - const filterState = useMemo(() => { - const id = `locked_selector${indexLocked}` - indexLocked += 1 - return selector({ - key: id, - get: ({get}) => { - const query = {} - for (let key of names) { - const filter = get(lockedFamily(key)) - query[key] = filter - } - return query - } - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - return useRecoilValue(filterState) -} - -// Used to set the locked state of several filters at once -const lockedState = selector({ - key: 'lockedState', - get: ({get}) => { - const locks = {} - for (let key of filters) { - const filter = get(lockedFamily(key)) - locks[key] = filter - } - return locks - }, - set: ({ get, set, reset }, data) => { - if (data) { - for (const [key, value] of Object.entries(data)) { - set(queryFamily(key), value) - set(lockedFamily(key), true) - } - } - } -}) - -/** - * This hook will expose a function for reading filter values. Use this hook if - * you intend to only view the filter values and are not interested in setting - * the filter. - * - * @param {string} name Name of the filter. - * @returns currently set filter value. - */ -export function useFilterValue(name) { - return useRecoilValue(queryFamily(name)) -} - -/** - * This hook will expose a function for setting a filter value. Use this hook if - * you intend to only set the filter value and are not interested in the query - * results. - * - * @param {string} name Name of the quantity to set. - * @returns function for setting the value for the given quantity - */ -export function useSetFilter(name) { - return useSetRecoilState(queryFamily(name)) -} - -/** - * This hook will expose a function for getting and setting filter values. Use - * this hook if you intend to both read and write the filter value. - * - * @param {string} name Name of the filter. - * @returns Array containing the filter value and setter function for it. - */ -export function useFilterState(name) { - return useRecoilState(queryFamily(name)) -} - -/** - * This hook will expose a function for setting the values of all filters. - * - * @returns An object containing a mapping from filter name to a boolean - * indicating whether it is locked or not. - */ -export function useSetFilters() { - return useSetRecoilState(filtersState) -} - -// Used to get/set the locked state of all filters at once -const filtersState = selector({ - key: 'filtersState', - get: ({get}) => { - const query = {} - for (let key of filters) { - const filter = get(queryFamily(key)) - query[key] = filter - } - return query - }, - set: ({set}, [key, value]) => { - set(queryFamily(key), value) - } -}) - -/** - * This hook will expose a function for getting and setting filter values for - * the specified list of filters. Use this hook if you intend to both read and - * write the filter values. - * - * @param {string} names Names of the filters. - * @returns Array containing the filter values in a map and a setter function. - */ -let indexFilters = 0 -export function useFiltersState(names) { - // We dynamically create a Recoil.js selector that is subscribed to the - // filters specified in the input. This way only the specified filters will - // cause a render. Recoil.js requires that each selector/atom has an unique - // id. Because this hook can be called dynamically, we simply generate the ID - // sequentially. - const filterState = useMemo(() => { - const id = `dynamic_selector${indexFilters}` - indexFilters += 1 - return selector({ - key: id, - get: ({get}) => { - const query = {} - for (let key of names) { - const filter = get(queryFamily(key)) - query[key] = filter - } - return query - }, - set: ({set}, [key, value]) => { - set(queryFamily(key), value) - } - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - return useRecoilState(filterState) -} - -/** - * This Recoil.js selector aggregates all the currently set filters into a - * single query object used by the API. - */ -const queryState = selector({ - key: 'query', - get: ({get}) => { - if (!get(initializedState)) { - return undefined - } - let query = {} - for (let key of filters) { - const filter = get(queryFamily(key)) - if (filter !== undefined) { - query[key] = filter - } - } - return query - }, - set: ({ get, set, reset }, data) => { - for (let filter of filters) { - reset(queryFamily(filter)) - } - if (data) { - for (const [key, value] of Object.entries(data)) { - set(queryFamily(key), value) - } - set(initializedState, true) - } else { - set(initializedState, false) - } - } -}) - -export function useQuery() { - return useRecoilValue(queryState) -} - -/** - * Hook for writing a query object to the query string. - * - * @returns {object} Object containing the search object. - */ -export function useUpdateQueryString() { - const history = useHistory() - - const updateQueryString = useCallback((query, locked) => { - const queryString = queryToQs(query, locked) - history.replace(history.location.pathname + '?' + queryString) - }, [history]) - - return updateQueryString -} - -/** - * Converts a query string into a valid query object. - * - * @param {string} queryString URL querystring, encoded or not. - * @returns Returns an object containing the filters. Values are converted into - * datatypes that are directly compatible with the filter components. - */ -function qsToQuery(queryString) { - const query = qs.parse(queryString, {comma: true}) - const newQuery = {} - for (let [key, value] of Object.entries(query)) { - const split = key.split(':') - key = split[0] - let newKey = filterFullnames[key] || key - const valueGUI = toGUIFilter(newKey, value) - if (split.length !== 1) { - const op = split[1] - const oldValue = newQuery[newKey] - if (!oldValue) { - newQuery[newKey] = {[op]: valueGUI} - } else { - newQuery[newKey][op] = valueGUI - } - } else { - newQuery[newKey] = valueGUI - } - } - return newQuery -} - -/** - * Converts a query into a valid query string. - * @param {object} query A query object representing the currently active - * filters. - * @returns URL querystring, not encoded if possible to improve readability. - */ -function queryToQs(query, locked) { - const newQuery = {} - for (const [key, value] of Object.entries(query)) { - if (locked[key]) { - continue - } - const {formatter} = formatMeta(key, false) - let newValue - const newKey = filterAbbreviations[key] - if (isPlainObject(value)) { - if (!isNil(value.gte)) { - newQuery[`${newKey}:gte`] = formatter(value.gte) - } - if (!isNil(value.lte)) { - newQuery[`${newKey}:lte`] = formatter(value.lte) - } - } else { - if (isArray(value)) { - newValue = value.map(formatter) - } else if (value instanceof Set) { - newValue = [...value].map(formatter) - } else { - newValue = formatter(value) - } - newQuery[newKey] = newValue - } - } - return qs.stringify(newQuery, {indices: false, encode: false}) -} - -export const initialAggsState = atom({ - key: 'initialAggs', - default: undefined -}) - -/** - * Hook for returning an initial aggregation value for a filter. - * - * @returns {array} Array containing the aggregation data. - */ -export function useInitialAgg(name) { - const aggs = useRecoilValue(initialAggsState) - return aggs?.[name] -} - -/** - * Hook for retrieving the most up-to-date aggregation results for a specific - * filter, taking into account the current search context. - * - * @param {string} name The filter name - * @param {bool} restrict If true, the ES query targeting this particular filter - * will be removed. This makes it possible to return all possible values for - * dropdowns etc. - * @param {bool} update Whether the hook needs to react to changes in the - * current query context. E.g. if the component showing the data is not visible, - * this can be set to false. - * - * @returns {array} The data-array returned by the API. - */ -export function useAgg(name, restrict = false, update = true, delay = 500) { - const {api} = useApi() - const { resource } = useSearchContext() - const [results, setResults] = useState(undefined) - const initialAggs = useRecoilValue(initialAggsState) - const query = useQuery() - const firstLoad = useRef(true) - - // Pretty much all of the required pre-processing etc. should be done in this - // function, as it is the final one that gets called after the debounce - // interval. - const apiCall = useCallback((query) => { - // If the restrict option is enabled, the filters targeting the specified - // quantity will be removed. This way all possible options pre-selection can - // be returned. - let queryCleaned = {...query} - if (restrict && query && name in query) { - delete queryCleaned[name] - } - queryCleaned = toAPIQuery(queryCleaned, resource, query.restricted) - const aggRequest = {} - toAPIAgg(aggRequest, name, resource) - const search = { - owner: query.visibility || 'visible', - query: queryCleaned, - aggregations: aggRequest, - pagination: {page_size: 0}, - required: { include: [] } - } - - api.query(resource, search, false) - .then(data => { - data = toGUIAgg(data.aggregations, [name], resource) - firstLoad.current = false - setResults(data[name]) - }) - }, [api, name, restrict, resource]) - - // This is a debounced version of apiCall. - const debounced = useCallback(debounce(apiCall, delay), []) - - // The API call is made immediately on first render. On subsequent renders it - // will be debounced. - useEffect(() => { - if (!update || query === undefined) { - return - } - if (firstLoad.current) { - // Fetch the initial aggregation values if no query - // is specified. - if (isEmpty(query)) { - setResults(initialAggs[name]) - // Make an immediate request for the aggregation values if query has been - // specified. - } else { - apiCall(query) - } - } else { - debounced(query) - } - }, [apiCall, name, debounced, query, update, initialAggs]) - - return results -} - -/** - * Hook for returning a set of results based on the currently set query together - * with a function for retrieving a new set of results. - * - * @param {int} pageSize The number of results to return with one scroll. - * @param {string} orderBy The field used for sorting. - * @param {string} order Ascending or descending order. - * @param {number} delay The debounce delay in milliseconds. - * - * @returns {object} Object containing the search results and a function for - * scrolling to next set of results. - */ -export function useScrollResults(pageSize, orderBy, order, delay = 500) { - const {api} = useApi() - const {resource} = useSearchContext() - const firstRender = useRef(true) - const [results, setResults] = useState() - const pageNumber = useRef(1) - const query = useQuery(true) - const locked = useRecoilValue(lockedState) - const updateQueryString = useUpdateQueryString() - const pageAfterValue = useRef() - const searchRef = useRef() - const loading = useRef(false) - const total = useRef(0) - - // The results are fetched as a side effect in order to not block the - // rendering. This causes two renders: first one without the data, the second - // one with the data. - const apiCall = useCallback((query, locked, pageSize, orderBy, order) => { - pageAfterValue.current = undefined - const restricted = query.restricted - const cleanedQuery = toAPIQuery(query, resource, restricted) - const search = { - owner: query.visibility || 'visible', - query: cleanedQuery, - pagination: { - page_size: pageSize, - order_by: orderBy, - order: order, - page_after_value: pageAfterValue.current - } - } - searchRef.current = search - - loading.current = true - api.query(resource, search) - .then(data => { - pageAfterValue.current = data.pagination.next_page_after_value - total.current = data.pagination.total - setResults(data) - loading.current = false - }) - - // We only update the query string after the API call is finished. Updating - // the query string causes quite an intensive render (not sure why), so it - // is better to debounce this value as well to keep the user interaction - // smoother. - updateQueryString(query, locked) - }, [resource, api, updateQueryString]) - - // This is a debounced version of apiCall. - const debounced = useCallback(debounce(apiCall, delay), []) - - // Used to load the next bath of results - const next = useCallback(() => { - if (loading.current) { - return - } - pageNumber.current += 1 - searchRef.current.pagination.page_after_value = pageAfterValue.current - loading.current = true - api.query(resource, searchRef.current) - .then(data => { - pageAfterValue.current = data.pagination.next_page_after_value - total.current = data.pagination.total - setResults(old => { - data.data = old.data.concat(data.data) - return data - }) - loading.current = false - }) - }, [api, resource]) - - // Whenever the query changes, we make a new query that resets pagination and - // shows the first batch of results. - useEffect(() => { - // If the initial query is not yet ready, do nothing - if (query === undefined) { - return - } - if (firstRender.current) { - apiCall(query, locked, pageSize, orderBy, order) - firstRender.current = false - } else { - debounced(query, locked, pageSize, orderBy, order) - } - }, [apiCall, debounced, query, locked, pageSize, order, orderBy]) - - // Whenever the ordering changes, we perform a single API call that fetches - // results in the new order. The amount of fetched results is based on the - // already loaded amount. - // TODO - return { - results: results, - next: next, - page: pageNumber.current, - total: total.current - } -} - -/** - * Converts the contents a query into a format that is suitable for the API. - * - * Should only be called when making the final API call, as during the - * construction of the query it is much more convenient to store filters within - * e.g. Sets. - * - * @param {number} query The query object - * @param {bool} exclusive The chemical element search mode. - * - * @returns {object} A copy of the object with certain items cleaned into a - * format that is supported by the API. - */ -export function toAPIQuery(query, resource, restricted) { - // Perform custom transformations - let queryCustomized = {} - for (let [k, v] of Object.entries(query)) { - const setter = filterData[k]?.valueSet - if (setter) { - setter(queryCustomized, query, v) - } else { - queryCustomized[k] = v - } - } - - let queryNormalized = {} - for (const [k, v] of Object.entries(queryCustomized)) { - // Transform sets into lists and Quantities into SI values and modify keys - // according to target resource (entries/materials). - let newValue - if (isPlainObject(v)) { - newValue = {} - if (!isNil(v.lte)) { - newValue.lte = toAPIQueryValue(v.lte) - } - if (!isNil(v.gte)) { - newValue.gte = toAPIQueryValue(v.gte) - } - } else { - newValue = toAPIQueryValue(v) - } - - // The postfixes are added here. By default query items with array values - // get the 'any'-postfix. - let postfix - if (isArray(newValue)) { - const fieldPostfixMap = { - 'results.properties.available_properties': 'all', - 'results.material.elements': 'all' - } - postfix = fieldPostfixMap[k] || 'any' - } - - // For material query the keys are remapped. - let newKey = resource === 'materials' ? materialNames[k] : k - newKey = postfix ? `${newKey}:${postfix}` : newKey - queryNormalized[newKey] = newValue - } - - if (resource === 'materials') { - // In restricted search we simply move all method/properties filters - // inside a single entries-subsection. - if (restricted) { - const entrySearch = {} - for (const [k, v] of Object.entries(queryNormalized)) { - if (k.startsWith('entries.')) { - const name = k.split('entries.').pop() - entrySearch[name] = v - delete queryNormalized[k] - } - } - if (!isEmpty(entrySearch)) { - queryNormalized.entries = entrySearch - } - // In unrestricted search we have to split each filter and each filter value - // into it's own separate entries query. These queries are then joined with - // 'and'. - } else { - const entrySearch = [] - for (const [k, v] of Object.entries(queryNormalized)) { - if (k.startsWith('entries.')) { - const newKey = k.split(':')[0] - if (isArray(v)) { - for (const item of v) { - entrySearch.push({[newKey]: item}) - } - } else { - entrySearch.push({[newKey]: v}) - } - delete queryNormalized[k] - } - } - if (entrySearch.length > 0) { - queryNormalized.and = entrySearch - } - } - } - - return queryNormalized -} - -/** - * Cleans a filter value into a form that is supported by the API. This includes: - * - Sets are transformed into Arrays - * - Quantities are converted to SI values. - * - * @returns {any} The filter value in a format that is suitable for the API. - */ -function toAPIQueryValue(value) { - let newValue - if (value instanceof Set) { - newValue = setToArray(value) - if (newValue.length === 0) { - newValue = undefined - } else { - newValue = newValue.map((item) => item instanceof Quantity ? item.toSI() : item) - } - } else if (value instanceof Quantity) { - newValue = value.toSI() - } else if (isArray(value)) { - if (value.length === 0) { - newValue = undefined - } else { - newValue = value.map((item) => item instanceof Quantity ? item.toSI() : item) - } - } else { - newValue = value - } - return newValue -} - -/** - * Cleans a filter value into a form that is supported by the GUI. This includes: - * - Arrays are are transformed into Sets - * - If multiple values are supported, scalar values are stored inside sets. - * - Numerical values with units are transformed into Quantities. - * - * @returns {any} The filter value in a format that is suitable for the GUI. - */ -export function toGUIFilter(name, value, units = undefined) { - let multiple = filterData[name].multiple - let newValue - const {parser} = parseMeta(name) - if (isArray(value)) { - newValue = new Set(value.map((v) => parser(v, units))) - } else if (isPlainObject(value)) { - newValue = {} - if (!isNil(value.gte)) { - newValue.gte = parser(value.gte, units) - } - if (!isNil(value.lte)) { - newValue.lte = parser(value.lte, units) - } - } else { - newValue = parser(value, units) - if (multiple) { - newValue = new Set([newValue]) - } - } - return newValue -} - -/** - * Used to transform a GUI aggregation query into a form that is usable by the - * API. - * - * @param {object} aggs The aggregation data in which the modifications are - * made. - * @param {string} filter The filter name - * @param {string} resource The resource we are looking at: entries or materials. - */ -function toAPIAgg(aggs, filter, resource) { - const aggSet = filterData[filter].aggSet - if (aggSet) { - for (const [key, type] of Object.entries(aggSet)) { - const name = resource === 'materials' ? materialNames[key.split(':')[0]] : key - const agg = aggs[name] || {} - agg[type] = { - quantity: name, - size: 500 - } - aggs[name] = agg - } - } -} - -/** - * Used to transform an API aggregation query into a form that is usable by the - * GUI. - * - * @param {object} aggs The aggregation data as returned by the API. - * @param {array} filters The filters to take into account. - * @param {string} resource The resource we are looking at: entries or materials. - * - * @returns {object} Aggregation data that is usable by the GUI. - */ -function toGUIAgg(aggs, filters, resource) { - if (isEmpty(aggs)) { - return aggs - } - // Modify keys according to target resource (entries/materials). - let aggsNormalized - if (resource === 'materials') { - aggsNormalized = {} - for (const key of Object.keys(aggs)) { - const name = resource === 'materials' ? entryNames[key] : key - aggs[key].quantity = name - aggsNormalized[name] = aggs[key] - } - } else { - aggsNormalized = aggs - } - - // Perform custom transformations - const aggsCustomized = {} - for (const name of filters) { - const aggGet = filterData[name].aggGet - if (aggGet) { - let agg - agg = aggGet(aggsNormalized) - aggsCustomized[name] = agg - } - } - return aggsCustomized -} diff --git a/gui/src/components/search/FilterSummary.js b/gui/src/components/search/FilterSummary.js index 5152cbd269..b77b5d6f5f 100644 --- a/gui/src/components/search/FilterSummary.js +++ b/gui/src/components/search/FilterSummary.js @@ -22,13 +22,13 @@ import PropTypes from 'prop-types' import clsx from 'clsx' import { isNil, isPlainObject } from 'lodash' import FilterChip from './FilterChip' -import { useFiltersState, useFiltersLockedState } from './FilterContext' +import { useFiltersState, useFiltersLockedState } from './SearchContext' import { formatMeta } from '../../utils' import { useUnits } from '../../units' /** - * Displays an interactable summary for a given subset of filters - * (=searchQuantities). + * Displays a summary for the given subset of filters. Each filter value is + * displayed as a chip. */ const useStyles = makeStyles(theme => { const padding = theme.spacing(2) diff --git a/gui/src/components/search/NewSearch.js b/gui/src/components/search/NewSearch.js deleted file mode 100644 index f80bd7a0ad..0000000000 --- a/gui/src/components/search/NewSearch.js +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright The NOMAD Authors. - * - * This file is part of NOMAD. See https://nomad-lab.eu for further info. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, { useState } from 'react' -import clsx from 'clsx' -import PropTypes from 'prop-types' -import { makeStyles } from '@material-ui/core/styles' -import FilterMainMenu from './menus/FilterMainMenu' -import NewSearchBar from './NewSearchBar' -import SearchResults from './results/SearchResults' -import { - useMenuOpenState -} from './FilterContext' - -const useStyles = makeStyles(theme => { - return { - root: { - display: 'flex', - height: '100%', - width: '100%', - overflow: 'hidden' - }, - leftColumn: { - flexShrink: 0, - flexGrow: 0, - height: '100%', - zIndex: 2 - }, - leftColumnCollapsed: { - maxWidth: '4rem' - }, - center: { - flex: `1 1 100%`, - display: 'flex', - flexDirection: 'column', - zIndex: 1, - paddingBottom: theme.spacing(2.5), - paddingLeft: theme.spacing(3), - paddingRight: theme.spacing(3), - paddingTop: theme.spacing(0.25) - }, - container: { - }, - resultList: { - flexGrow: 1, - minHeight: 0 // This makes sure that the flex item is not bigger than the parent. - }, - spacer: { - flexGrow: 1 - }, - header: { - marginTop: theme.spacing(2) - }, - searchBar: { - marginTop: theme.spacing(2), - display: 'flex', - flexGrow: 0, - zIndex: 1, - marginBottom: theme.spacing(2.0) - }, - spacerBar: { - flex: `0 0 ${theme.spacing(3)}px` - }, - nonInteractive: { - pointerEvents: 'none', - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - top: 0, - height: '100%', - width: '100%' - }, - shadow: { - backgroundColor: 'black', - transition: 'opacity 200ms', - willChange: 'opacity', - zIndex: 1 - }, - hidden: { - display: 'none' - }, - shadowHidden: { - opacity: 0 - }, - shadowVisible: { - opacity: 0.1 - }, - placeholderVisible: { - display: 'block' - } - } -}) - -const NewSearch = React.memo(({ - collapsed, - header -}) => { - const styles = useStyles() - const [isMenuOpen, setIsMenuOpen] = useMenuOpenState(false) - const [isCollapsed, setIsCollapsed] = useState(collapsed) - - return <div className={styles.root}> - <div className={clsx(styles.leftColumn, isCollapsed && styles.leftColumnCollapsed)}> - <FilterMainMenu - open={isMenuOpen} - onOpenChange={setIsMenuOpen} - collapsed={isCollapsed} - onCollapsedChange={setIsCollapsed} - /> - </div> - <div className={styles.center} onClick={() => setIsMenuOpen(false)}> - <div className={styles.header}> - {header} - </div> - <NewSearchBar - className={styles.searchBar} - /> - <SearchResults - className={styles.resultList} - /> - <div className={clsx(styles.nonInteractive, styles.shadow, styles.shadowHidden, isMenuOpen && styles.shadowVisible)}></div> - </div> - </div> -}) -NewSearch.propTypes = { - collapsed: PropTypes.bool, - header: PropTypes.node -} - -export default NewSearch diff --git a/gui/src/components/search/NewSearchBar.js b/gui/src/components/search/NewSearchBar.js deleted file mode 100644 index 93dbd613ef..0000000000 --- a/gui/src/components/search/NewSearchBar.js +++ /dev/null @@ -1,431 +0,0 @@ -/* - * Copyright The NOMAD Authors. - * - * This file is part of NOMAD. See https://nomad-lab.eu for further info. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, { useCallback, useState, useMemo } from 'react' -import PropTypes from 'prop-types' -import clsx from 'clsx' -import { debounce, isNil } from 'lodash' -import Autocomplete from '@material-ui/lab/Autocomplete' -import { makeStyles } from '@material-ui/core/styles' -import SearchIcon from '@material-ui/icons/Search' -import CloseIcon from '@material-ui/icons/Close' -import { - TextField, - CircularProgress, - Paper, - Divider, - Tooltip, - Typography -} from '@material-ui/core' -import IconButton from '@material-ui/core/IconButton' -import { useApi } from '../apiV1' -import { useUnits } from '../../units' -import { isMetaNumber, isMetaTimestamp } from '../../utils' -import { - useSetFilters, - useFiltersLocked, - filterFullnames, - filterAbbreviations, - toGUIFilter, - filterData, - filters -} from './FilterContext' -import searchQuantities from '../../searchQuantities' - -const opMap = { - '<=': 'lte', - '>=': 'gte', - '>': 'gt', - '<': 'lt' -} -const opMapReverse = { - '<=': 'gte', - '>=': 'lte', - '>': 'lt', - '<': 'gt' -} - -// Decides which options are shown -const filterOptions = (options, {inputValue}) => { - const trimmed = inputValue.trim().toLowerCase() - return options.filter(option => { - // ES results do not need to be filtered at all - const category = option.category - if (category !== 'quantity name') { - return true - } - // Underscore can be replaced by a whitespace - const optionClean = option.value.trim().toLowerCase() - const matchUnderscore = optionClean.includes(trimmed) - const matchNoUnderscore = optionClean.replaceAll('_', ' ').includes(trimmed) - return matchUnderscore || matchNoUnderscore - }) -} - -// Customized paper component for the autocompletion options -const CustomPaper = (props) => { - return <Paper elevation={3} {...props} /> -} - -const useStyles = makeStyles(theme => ({ - root: { - display: 'flex', - alignItems: 'center', - position: 'relative' - }, - notchedOutline: { - borderColor: 'rgba(0, 0, 0, 0.0)' - }, - iconButton: { - padding: 10 - }, - divider: { - height: '2rem' - }, - endAdornment: { - position: 'static' - }, - examples: { - position: 'absolute', - left: 0, - right: 0, - top: 'calc(100% + 4px)', - padding: theme.spacing(2), - fontStyle: 'italic' - } -})) - -/** - * This component shows a searchbar with autocomplete functionality. It does its - * on API calls to provide autocomplete suggestion options. - */ -const NewSearchBar = React.memo(({ - className -}) => { - const styles = useStyles() - const units = useUnits() - const [suggestions, setSuggestions] = useState([]) - const [loading, setLoading] = useState(false) - const [inputValue, setInputValue] = useState('') - const [highlighted, setHighlighted] = useState({value: ''}) - const [open, setOpen] = useState(false) - const [error, setError] = useState(false) - const [showExamples, setShowExamples] = useState(false) - const {api} = useApi() - const filtersLocked = useFiltersLocked() - const setFilter = useSetFilters() - const quantitySet = filters - const quantitySuggestions = useMemo(() => { - const suggestions = [] - for (let q of filters) { - suggestions.push({ - value: filterAbbreviations[q] || q, - category: 'quantity name' - }) - } - return suggestions - }, []) - - // Triggered when a value is submitted by pressing enter or clicking the - // search icon. - const handleSubmit = useCallback(() => { - if (inputValue.trim().length === 0) { - return - } - const reString = '[^\\s=<>](?:[^=<>]*[^\\s=<>])?' - const op = '(?:<|>)=?' - let valid = false - let quantityFullname - let queryValue - - // Equality query - const equals = inputValue.match(new RegExp(`^\\s*(${reString})\\s*=\\s*(${reString})\\s*$`)) - if (equals) { - const quantityName = equals[1] - quantityFullname = filterFullnames[quantityName] || quantityName - if (!quantitySet.has(quantityFullname)) { - setError(`Unknown quantity name`) - return - } - try { - queryValue = toGUIFilter(quantityFullname, equals[2], units) - } catch (error) { - setError(`Invalid value for this metainfo. Please check your syntax.`) - return - } - valid = true - } - - // Simple LTE/GTE query - if (!valid) { - const ltegte = inputValue.match(new RegExp(`^\\s*(${reString})\\s*(${op})\\s*(${reString})\\s*$`)) - if (ltegte) { - const a = ltegte[1] - const op = ltegte[2] - const b = ltegte[3] - const aFullname = filterFullnames[a] - const bFullname = filterFullnames[b] - const isAQuantity = quantitySet.has(aFullname) - const isBQuantity = quantitySet.has(bFullname) - if (!isAQuantity && !isBQuantity) { - setError(`Unknown quantity name`) - return - } - quantityFullname = isAQuantity ? aFullname : bFullname - if (!isMetaNumber(quantityFullname) && !isMetaTimestamp(quantityFullname)) { - setError(`Cannot perform range query for a non-numeric quantity.`) - return - } - let quantityValue - try { - quantityValue = toGUIFilter(quantityFullname, isAQuantity ? b : a, units) - } catch (error) { - setError(`Invalid value for this metainfo. Please check your syntax.`) - return - } - queryValue = {} - queryValue[opMap[op]] = quantityValue - valid = true - } - } - - // Sandwiched LTE/GTE query - if (!valid) { - const ltegteSandwich = inputValue.match(new RegExp(`^\\s*(${reString})\\s*(${op})\\s*(${reString})\\s*(${op})\\s*(${reString})\\s*$`)) - if (ltegteSandwich) { - const a = ltegteSandwich[1] - const op1 = ltegteSandwich[2] - const b = ltegteSandwich[3] - const op2 = ltegteSandwich[4] - const c = ltegteSandwich[5] - quantityFullname = filterFullnames[b] - if (!isMetaNumber(quantityFullname) && !isMetaTimestamp(quantityFullname)) { - setError(`Cannot perform range query for a non-numeric quantity.`) - return - } - const isBQuantity = quantitySet.has(quantityFullname) - if (!isBQuantity) { - setError(`Unknown quantity name`) - return - } - - queryValue = {} - try { - queryValue[opMapReverse[op1]] = toGUIFilter(quantityFullname, a, units) - queryValue[opMap[op2]] = toGUIFilter(quantityFullname, c, units) - } catch (error) { - setError(`Invalid value for this metainfo. Please check your syntax.`) - return - } - valid = true - } - } - - // Check if filter is locked - if (filtersLocked[quantityFullname]) { - setError(`Cannot change the filter as it is locked in the current search context.`) - return - } - - if (valid) { - // Submit to search context on successful validation. - setFilter([quantityFullname, old => { - const multiple = filterData[quantityFullname].multiple - return (isNil(old) || !multiple) ? queryValue : new Set([...old, ...queryValue]) - }]) - setInputValue('') - setOpen(false) - } else { - setError(`Invalid query`) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [inputValue, quantitySet]) - - // Handle clear button - const handleClose = useCallback(() => { - setInputValue('') - setSuggestions([]) - setOpen(false) - setShowExamples(true) - }, []) - - const handleHighlight = useCallback((event, value, reason) => { - setHighlighted(value) - }, []) - - // When enter is pressed, select currently highlighted value and close menu, - // or if menu is not open submit the value. - const handleEnter = useCallback((event) => { - if (event.key === 'Enter') { - if (open && highlighted?.value) { - setInputValue(highlighted.value) - setOpen(false) - } else { - handleSubmit() - } - event.stopPropagation() - event.preventDefault() - } - }, [open, highlighted, handleSubmit]) - - const suggestionCall = useCallback((quantityList, value) => { - setLoading(true) - // If some input is given, and the quantity supports suggestions, we use - // input suggester to suggest values - const filteredList = quantityList.filter(q => searchQuantities[q]?.suggestion) - api.suggestions(filteredList, value) - .then(data => { - let res = [] - for (let q of filteredList) { - const name = filterAbbreviations[q] || q - const esSuggestions = data[q] - if (esSuggestions) { - res = res.concat(esSuggestions.map(suggestion => ({ - value: `${name}=${suggestion.value}`, - category: name - }))) - } - } - setSuggestions(res) - }) - .finally(() => setLoading(false)) - }, [api]) - const suggestionDebounced = useCallback(debounce(suggestionCall, 150), []) - - // Handle typing events. After a debounce time has expired, a list of - // suggestion will be retrieved if they are available for this metainfo and - // the input is deemed meaningful. - const handleInputChange = useCallback((event, value, reason) => { - setError(error => error ? undefined : null) - setInputValue(value) - value = value?.trim() - setShowExamples(!value) - if (!value) { - setSuggestions([]) - setOpen(false) - setShowExamples(true) - return - } else { - setOpen(true) - setShowExamples(false) - } - if (reason !== 'input') { - setSuggestions([]) - setOpen(false) - } - // If the input is prefixed with a proper quantity name and an equals-sign, - // we extract the quantity name and the typed input - const split = value.split('=', 2) - let quantityList = [...filters] - if (split.length === 2) { - const quantityName = split[0].trim() - const quantityFullname = filterFullnames[quantityName] - if (quantitySet.has(quantityName)) { - quantityList = [quantityName] - value = split[1].trim() - } else if (quantitySet.has(quantityFullname)) { - quantityList = [quantityFullname] - value = split[1].trim() - } - } - - setLoading(true) - // If some input is given, and the quantity supports suggestions, we use - // input suggester to suggest values - if (value.length > 0) { - suggestionDebounced(quantityList, value) - // If no input is given, we suggest Enum values, or for non-enum quantities - // use terms aggregation. - } else { - } - }, [quantitySet, suggestionDebounced]) - - // This determines the order: notice that items should be sorted by group - // first in order for the grouping to work correctly. - const options = useMemo(() => { - return suggestions.concat(quantitySuggestions) - }, [quantitySuggestions, suggestions]) - - return <Paper className={clsx(className, styles.root)}> - <Autocomplete - className={styles.input} - freeSolo - clearOnBlur={false} - inputValue={inputValue} - value={null} - open={open} - onFocus={() => setShowExamples(true)} - onBlur={() => setShowExamples(false)} - onOpen={() => { if (inputValue.trim() !== '') { setOpen(true) } }} - onClose={() => setOpen(false)} - fullWidth - disableClearable - PaperComponent={CustomPaper} - classes={{endAdornment: styles.endAdornment}} - groupBy={(option) => option.category} - filterOptions={filterOptions} - options={options} - onInputChange={handleInputChange} - onHighlightChange={handleHighlight} - getOptionLabel={option => option.value} - getOptionSelected={(option, value) => false} - renderInput={(params) => ( - <TextField - {...params} - className={styles.textField} - variant="outlined" - placeholder="" - label={error || undefined} - error={!!error} - onKeyDown={handleEnter} - InputLabelProps={{ shrink: true }} - InputProps={{ - ...params.InputProps, - classes: { - notchedOutline: styles.notchedOutline - }, - endAdornment: (<> - {loading ? <CircularProgress color="inherit" size={20} /> : null} - {(inputValue?.length || null) && <> - <Tooltip title="Clear"> - <IconButton onClick={handleClose} className={styles.iconButton} aria-label="clear"> - <CloseIcon /> - </IconButton> - </Tooltip> - <Divider className={styles.divider} orientation="vertical"/> - </>} - <Tooltip title="Add filter"> - <IconButton onClick={handleSubmit} className={styles.iconButton} aria-label="search"> - <SearchIcon /> - </IconButton> - </Tooltip> - </>) - }} - /> - )} - /> - {showExamples && <CustomPaper className={styles.examples}> - <Typography>{'Start typing a query or a keyword to get relevant suggestions.'}</Typography> - </CustomPaper>} - </Paper> -}) - -NewSearchBar.propTypes = { - className: PropTypes.string -} - -export default NewSearchBar diff --git a/gui/src/components/search/QuantityHistogram.js b/gui/src/components/search/QuantityHistogram.js deleted file mode 100644 index e47b99af57..0000000000 --- a/gui/src/components/search/QuantityHistogram.js +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright The NOMAD Authors. - * - * This file is part of NOMAD. See https://nomad-lab.eu for further info. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, { useContext, useMemo, useCallback } from 'react' -import PropTypes from 'prop-types' -import { searchContext } from './SearchContext.js' -import searchQuantities from '../../searchQuantities' -import Histogram from '../Histogram.js' - -const unprocessedLabel = 'not processed' -const unavailableLabel = 'unavailable' - -export default function QuantityHistogram({ - quantity, valueLabels = {}, title, values, numberOfValues, multiple, tooltips = {}, - ...props -}) { - title = title || quantity - values = values || (searchQuantities[quantity] && searchQuantities[quantity].statistic_values) - numberOfValues = numberOfValues || (values && values.length) || (searchQuantities[quantity] && searchQuantities[quantity].statistic_size) - const {response: {statistics, metric}, query, setQuery} = useContext(searchContext) - const statisticsData = statistics[quantity] - - const handleItemClicked = useCallback(item => { - if (multiple) { - // Add or remove item from query - let newQuery = query[quantity] - if (newQuery === undefined) { - newQuery = [item.key] - } else { - if (!Array.isArray(newQuery)) { - newQuery = [newQuery] - } - newQuery = new Set(newQuery) - if (newQuery.has(item.key)) { - newQuery.delete(item.key) - } else { - newQuery.add([item.key]) - } - newQuery = Array.from(newQuery.values()) - } - setQuery({[quantity]: newQuery}) - } else { - setQuery({[quantity]: (query[quantity] === item.key) ? null : item.key}) - } - }, [query, setQuery, multiple, quantity]) - - const data = useMemo(() => { - let data - if (!statistics[quantity]) { - data = [] - } else if (values) { - data = values.map(value => ({ - key: value, - name: valueLabels[value] || value, - value: statisticsData[value] ? statisticsData[value][metric] : 0, - tooltip: tooltips[value] - })) - } else { - data = Object.keys(statisticsData) - .map(value => ({ - key: value, - name: valueLabels[value] || value, - value: statisticsData[value][metric] - })) - // keep the data sorting, but put unavailable and not processed to the end - const unavailableIndex = data.findIndex(d => d.name === unavailableLabel) - const unprocessedIndex = data.findIndex(d => d.name === unprocessedLabel) - if (unavailableIndex !== -1) { - data.push(data.splice(unavailableIndex, 1)[0]) - } - if (unprocessedIndex !== -1) { - data.push(data.splice(unprocessedIndex, 1)[0]) - } - } - return data - }, [metric, quantity, statistics, statisticsData, valueLabels, values, tooltips]) - - return <Histogram - card data={data} - numberOfValues={numberOfValues} - title={title} - onClick={handleItemClicked} - selected={query[quantity]} - multiple={multiple} - tooltips={!!tooltips} - {...props} - /> -} -QuantityHistogram.propTypes = { - /** - * The name of the search quantity that is displayed in the histogram. This has to - * match the provided statistics data. - */ - quantity: PropTypes.string.isRequired, - /** - * An optional title for the chart. If no title is given, the quantity is used. - */ - title: PropTypes.string, - /** - * The data. Usually the statistics data send by NOMAD's API. - */ - data: PropTypes.object, - /** - * Optional list of possible values. This is used to sort the data and fill the data - * with 0-values to keep a persistent appearance, even if no data for that value exists. - * Otherwise, the values are not sorted. - */ - values: PropTypes.arrayOf(PropTypes.string), - /** - * The maximum number of values. This is used to fix the histograms size. Otherwise, - * the size is determined by the required space to render the existing values. - */ - numberOfValues: PropTypes.number, - /** - * An optional mapping between values and labels that should be used to render the - * values. - */ - valueLabels: PropTypes.object, - /** - * An optional mapping between values and their tooltip content. - */ - tooltips: PropTypes.object, - /** - * Whether multiple values can be appended to the same query key. - */ - multiple: PropTypes.bool -} - -QuantityHistogram.defaultProps = { - multiple: false -} diff --git a/gui/src/components/search/Search.js b/gui/src/components/search/Search.js index e0627733a5..bf3859f1a4 100644 --- a/gui/src/components/search/Search.js +++ b/gui/src/components/search/Search.js @@ -15,629 +15,136 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState, useContext, useEffect } from 'react' +import React, { useState } from 'react' +import clsx from 'clsx' import PropTypes from 'prop-types' import { makeStyles } from '@material-ui/core/styles' -import { Card, Button, Tooltip, Tabs, Tab, Paper, FormControl, - FormGroup, Checkbox, FormControlLabel, CardContent, IconButton, Select, MenuItem, Box } from '@material-ui/core' -import { useQueryParam, useQueryParams, StringParam, NumberParam } from 'use-query-params' +import FilterMainMenu from './menus/FilterMainMenu' import SearchBar from './SearchBar' -import { DisableOnLoading } from '../api' -import { domainData } from '../domainData' -import PeriodicTable from './input/PeriodicTable' -import ReloadIcon from '@material-ui/icons/Cached' -import GroupList from './results/GroupList' -import ApiDialogButton from '../ApiDialogButton' -import UploadsHistogram from './UploadsHistogram' -import QuantityHistogram from './QuantityHistogram' -import SearchContext, { searchContext, useUrlQuery } from './SearchContext' -import {objectFilter} from '../../utils' -import MaterialsList from './results/MaterialsList' -import UploadList from './results/UploadsList' -import DatasetList from './results/DatasetList' -import EntryList from './EntryList' - -const resultTabs = { - 'entries': { - label: 'Entries', - groups: {}, - component: SearchEntryList - }, - 'materials': { - label: 'Materials', - groups: {'encyclopedia.material.materials_grouped': true}, - component: SearchMaterialsList - }, - 'groups': { - label: 'Grouped entries', - groups: {'dft.groups_grouped': true}, - component: SearchGroupList - }, - 'uploads': { - label: 'Uploads', - groups: {'uploads_grouped': true}, - component: SearchUploadList - }, - 'datasets': { - label: 'Datasets', - groups: {'datasets_grouped': true}, - component: SearchDatasetList - } -} - -const useSearchStyles = makeStyles(theme => ({ - root: { - padding: theme.spacing(3) - } -})) +import SearchResults from './results/SearchResults' +import { + useMenuOpenState +} from './SearchContext' /** - * This component shows the full search interface including result lists. + * The primary search interface that is reused throughout the application in + * different contexts. Displays a menu of filters, a search bar, a list of + * results and optionally a customizable header above the search bar. */ -export default function Search(props) { - const { - initialVisualizationTab, - initialOwner, - ownerTypes, - initialDomain, - initialMetric, - initialResultTab, - availableResultTabs, - query, - initialQuery, - resultListProps, - initialRequest, - showDisclaimer, - ...rest} = props - const classes = useSearchStyles() - return <DisableOnLoading> - <SearchContext query={query} initialQuery={initialQuery}> - <div className={classes.root} {...rest}> - <SearchEntry - initialTab={initialVisualizationTab} - initialOwner={initialOwner} - ownerTypes={ownerTypes} - initialDomain={initialDomain} - initialMetric={initialMetric} - initialRequest={initialRequest} - showDisclaimer={showDisclaimer} - /> - <SearchResults - initialTab={initialResultTab} - availableTabs={availableResultTabs} - resultListProps={resultListProps} - /> - </div> - </SearchContext> - </DisableOnLoading> -} -Search.propTypes = { - initialResultTab: PropTypes.string, - initialVisualizationTab: PropTypes.string, - availableResultTabs: PropTypes.arrayOf(PropTypes.string), - initialOwner: PropTypes.string, - ownerTypes: PropTypes.arrayOf(PropTypes.string), - initialDomain: PropTypes.string, - initialMetric: PropTypes.string, - initialRequest: PropTypes.object, - resultListProps: PropTypes.object, - /** - * Additional search parameters that will be added to all searches that are send to - * the API. The idea is that this can be used to lock some aspects of the search for - * special contexts, like the dataset page for example. - */ - query: PropTypes.object, - /** - * Similar to query, but these parameters can be changes by the user interacting with - * the component. - */ - initialQuery: PropTypes.object, - showDisclaimer: PropTypes.bool -} - -const useSearchEntryStyles = makeStyles(theme => ({ - search: { - marginTop: theme.spacing(2), - marginBottom: theme.spacing(2), - maxWidth: 1024, - margin: 'auto', - width: '100%' - }, - - domainButton: { - margin: theme.spacing(1) - }, - metricButton: { - margin: theme.spacing(1), - marginRight: 0 - }, - searchBar: { - marginTop: theme.spacing(1), - marginBottom: theme.spacing(1) - }, - selectButton: { - margin: theme.spacing(1) - }, - visualizations: { - display: 'block', - maxWidth: 900, - margin: 'auto', - marginTop: theme.spacing(2), - marginBottom: theme.spacing(2) - } -})) -function SearchEntry({initialTab, initialOwner, ownerTypes, initialDomain, initialMetric, showDisclaimer}) { - const classes = useSearchEntryStyles() - const [openVisualizationParam, setOpenVisualizationParam] = useQueryParam('visualization', StringParam) - const {domain} = useContext(searchContext) - - const visualizations = {} - visualizations.elements = { - component: ElementsVisualization, - label: 'Elements', - description: 'Shows data as a heatmap over the periodic table' - } - Object.assign(visualizations, domain.searchVisualizations) - visualizations.users = { - component: UsersVisualization, - label: 'Uploads', - description: 'Show statistics about when and by whom data was uploaded' - } - - const openVisualizationKey = openVisualizationParam || initialTab - const openVisualizationTab = visualizations[openVisualizationKey] - - const VisualizationComponent = openVisualizationTab ? openVisualizationTab.component : React.Fragment - - const handleVisualizationChange = value => { - if (value === openVisualizationKey) { - setOpenVisualizationParam('none') - } else { - setOpenVisualizationParam(value) +const useStyles = makeStyles(theme => { + return { + root: { + display: 'flex', + height: '100%', + width: '100%', + overflow: 'hidden' + }, + leftColumn: { + flexShrink: 0, + flexGrow: 0, + height: '100%', + zIndex: 2 + }, + leftColumnCollapsed: { + maxWidth: '4rem' + }, + center: { + flex: `1 1 100%`, + display: 'flex', + flexDirection: 'column', + zIndex: 1, + paddingBottom: theme.spacing(2.5), + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(3), + paddingTop: theme.spacing(0.25) + }, + container: { + }, + resultList: { + flexGrow: 1, + minHeight: 0 // This makes sure that the flex item is not bigger than the parent. + }, + spacer: { + flexGrow: 1 + }, + header: { + marginTop: theme.spacing(2) + }, + searchBar: { + marginTop: theme.spacing(2), + display: 'flex', + flexGrow: 0, + zIndex: 1, + marginBottom: theme.spacing(2.0) + }, + spacerBar: { + flex: `0 0 ${theme.spacing(3)}px` + }, + nonInteractive: { + pointerEvents: 'none', + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + top: 0, + height: '100%', + width: '100%' + }, + shadow: { + backgroundColor: 'black', + transition: 'opacity 200ms', + willChange: 'opacity', + zIndex: 1 + }, + hidden: { + display: 'none' + }, + shadowHidden: { + opacity: 0 + }, + shadowVisible: { + opacity: 0.1 + }, + placeholderVisible: { + display: 'block' } } - - return <div> - <div className={classes.search}> - {domain.disclaimer && showDisclaimer && <Box marginBottom={2} fontStyle="italic"> - {domain.disclaimer} - </Box>} - <FormGroup row style={{alignItems: 'center'}}> - <Box marginRight={2}> - <DomainSelect classes={{root: classes.domainButton}} initialDomain={initialDomain} /> - </Box> - <div style={{flexGrow: 1}} /> - <OwnerSelect ownerTypes={ownerTypes} initialOwner={initialOwner}/> - <div style={{flexGrow: 1}} /> - <VisualizationSelect - classes={{button: classes.selectButton}} - value={openVisualizationKey} - onChange={handleVisualizationChange} - visualizations={visualizations} - /> - <Box marginLeft={2}> - <MetricSelect classes={{root: classes.metricButton}} initialMetric={initialMetric}/> - </Box> - </FormGroup> - - {/* <SearchBar classes={{autosuggestRoot: classes.searchBar}} /> */} - <div className={classes.searchBar}> - <SearchBar /> - </div> - </div> - - <div className={classes.visualizations}> - <VisualizationComponent/> +}) + +const Search = React.memo(({ + collapsed, + header +}) => { + const styles = useStyles() + const [isMenuOpen, setIsMenuOpen] = useMenuOpenState(false) + const [isCollapsed, setIsCollapsed] = useState(collapsed) + + return <div className={styles.root}> + <div className={clsx(styles.leftColumn, isCollapsed && styles.leftColumnCollapsed)}> + <FilterMainMenu + open={isMenuOpen} + onOpenChange={setIsMenuOpen} + collapsed={isCollapsed} + onCollapsedChange={setIsCollapsed} + /> </div> - </div> -} -SearchEntry.propTypes = { - initialTab: PropTypes.string, - initialOwner: PropTypes.string, - initialDomain: PropTypes.string, - initialMetric: PropTypes.string, - ownerTypes: PropTypes.arrayOf(PropTypes.string), - showDisclaimer: PropTypes.bool -} - -const originLabels = { - 'Stefano Curtarolo': 'AFLOW', - 'Chris Wolverton': 'OQMD', - 'Patrick Huck': 'Materials Project', - 'Markus Scheidgen': 'NOMAD Laboratory' -} - -function UsersVisualization() { - const {setStatistics} = useContext(searchContext) - useEffect(() => { - setStatistics(['origin']) - // eslint-disable-next-line - }, []) - return <div> - <UploadsHistogram tooltips initialScale={0.5} /> - <QuantityHistogram quantity="origin" title="Uploader/origin" valueLabels={originLabels}/> - </div> -} - -function ElementsVisualization(props) { - const [exclusive, setExclusive] = useState(false) - const {response: {statistics, metric}, query, setQuery, setStatistics} = useContext(searchContext) - useEffect(() => { - setStatistics(['atoms']) - // eslint-disable-next-line - }, []) - - const handleExclusiveChanged = () => { - if (!exclusive) { - setQuery({only_atoms: query.atoms, atoms: []}) - } else { - setQuery({atoms: query.only_atoms, only_atoms: []}) - } - setExclusive(!exclusive) - } - const handleAtomsChanged = atoms => { - if (exclusive) { - setExclusive(false) - } - setQuery({atoms: atoms, only_atoms: []}) - } - - return <Card> - <CardContent> - <PeriodicTable - aggregations={statistics.atoms} - metric={metric} - exclusive={exclusive} - values={[...(query.atoms || []), ...(query.only_atoms || [])]} - onChanged={handleAtomsChanged} - onExclusiveChanged={handleExclusiveChanged} + <div className={styles.center} onClick={() => setIsMenuOpen(false)}> + <div className={styles.header}> + {header} + </div> + <SearchBar + className={styles.searchBar} /> - </CardContent> - </Card> -} - -const useMetricSelectStyles = makeStyles(theme => ({ - root: { - minWidth: 100 - } -})) -function MetricSelect({initialMetric}) { - const {domain, setMetric} = useContext(searchContext) - const [metricParam, setMetricParam] = useQueryParam('metric', StringParam) - const metric = metricParam || initialMetric || domain.defaultSearchMetric - - useEffect(() => setMetric(metric), [metric, setMetric]) - - const metricsDefinitions = domain.searchMetrics - const classes = useMetricSelectStyles() - const [tooltipOpen, setTooltipOpen] = useState(false) - const handleTooltip = bool => setTooltipOpen(bool) - return <FormControl className={classes.root}> - <Tooltip title="Select the metric used to represent data" open={tooltipOpen}> - <Select - MenuProps={{ - getContentAnchorEl: null, - anchorOrigin: { - vertical: 'bottom', - horizontal: 'left' - } - }} - renderValue={key => { - const metric = metricsDefinitions[key] - return metric.shortLabel || metric.label - }} - value={metric} - onChange={event => setMetricParam(event.target.value)} - onMouseEnter={() => handleTooltip(true)} - onMouseLeave={() => handleTooltip(false)} - onOpen={() => handleTooltip(false)} - > - {Object.keys(metricsDefinitions).map(metricKey => { - const {label, tooltip} = metricsDefinitions[metricKey] - return ( - <MenuItem value={metricKey} key={metricKey}> - <Tooltip title={tooltip || ''}> - <div>{label}</div> - </Tooltip> - </MenuItem> - ) - })} - </Select> - </Tooltip> - </FormControl> -} -MetricSelect.propTypes = { - initialMetric: PropTypes.string -} - -function VisualizationSelect({classes, value, onChange, visualizations}) { - return <React.Fragment> - {Object.keys(visualizations).map(key => { - const visualization = visualizations[key] - return <Tooltip key={key} title={visualization.description}> - <Button - variant="outlined" - size="small" className={classes.button} - color={value === key ? 'primary' : 'default'} - onClick={() => onChange(key)} - > - {visualization.label} - </Button> - </Tooltip> - })} - </React.Fragment> -} -VisualizationSelect.propTypes = { - classes: PropTypes.object.isRequired, - value: PropTypes.string, - visualizations: PropTypes.object.isRequired, - onChange: PropTypes.func.isRequired -} - -const useDomainSelectStyles = makeStyles(theme => ({ - root: { - minWidth: 60 - } -})) -function DomainSelect({initialDomain}) { - const {setDomain} = useContext(searchContext) - const [domainParam, setDomainParam] = useQueryParam('domain', StringParam) - const domain = domainParam || initialDomain || domainData.dft.key - - useEffect(() => setDomain(domain), [domain, setDomain]) - - const classes = useDomainSelectStyles() - const [tooltipOpen, setTooltipOpen] = useState(false) - const handleTooltip = bool => setTooltipOpen(bool) - return <FormControl className={classes.root}> - <Tooltip - title="Select the data domain to search. Different domains contain different type of data." - open={tooltipOpen} - > - <Select - MenuProps={{ - getContentAnchorEl: null, - anchorOrigin: { - vertical: 'bottom', - horizontal: 'left' - } - }} - renderValue={key => domainData[key].name} - value={domain} - onChange={event => setDomainParam(event.target.value)} - onMouseEnter={() => handleTooltip(true)} - onMouseLeave={() => handleTooltip(false)} - onOpen={() => handleTooltip(false)} - > - {Object.keys(domainData).map(domainKey => { - const domain = domainData[domainKey] - return ( - <MenuItem value={domain.key} key={domain.key}> - <Tooltip title={domain.about}> - <div>{domain.label}</div> - </Tooltip> - </MenuItem> - ) - })} - </Select> - </Tooltip> - </FormControl> -} -DomainSelect.propTypes = { - initialDomain: PropTypes.string -} - -const ownerLabel = { - all: 'All', - visible: 'Include private', - public: 'Only public', - user: 'Only yours', - shared: 'Shared', - staging: 'Unpublished' -} - -const ownerTooltips = { - all: 'This will show all entries in the database.', - visible: 'Do also show entries that are only visible to you.', - public: 'Do not show entries with embargo.', - user: 'Do only show entries visible to you.', - shared: 'Also include data that is shared with you', - staging: 'Will only show entries that you uploaded, but not yet published.' -} - -function OwnerSelect(props) { - const {ownerTypes, initialOwner} = props - const {setOwner} = useContext(searchContext) - - const ownerTypesToRender = ownerTypes.length === 1 ? [] : ownerTypes.slice(1) - - const [{owner}, setQueryParam] = useUrlQuery() - const ownerValue = owner || initialOwner || 'all' - - useEffect(() => { - setOwner(ownerValue) - }, [ownerValue, setOwner]) - - const handleChange = (event) => { - if (owner !== event.target.value) { - setQueryParam({owner: event.target.value}) - } else { - setQueryParam({owner: initialOwner}) - } - } - - if (ownerTypes.length === 1) { - return <React.Fragment/> - } - - return <FormControl> - <FormGroup row> - {ownerTypesToRender.map(ownerToRender => ( - <Tooltip key={ownerToRender} title={ownerTooltips[ownerToRender]}> - <FormControlLabel - control={<Checkbox - checked={ownerValue === ownerToRender} - onChange={handleChange} value={ownerToRender} - />} - label={ownerLabel[ownerToRender]} - /> - </Tooltip> - ))} - </FormGroup> - </FormControl> -} -OwnerSelect.propTypes = { - ownerTypes: PropTypes.arrayOf(PropTypes.string).isRequired, - initialOwner: PropTypes.string -} - -const useSearchResultStyles = makeStyles(theme => ({ - root: { - marginTop: theme.spacing(4) - } -})) -function SearchResults({availableTabs = ['entries'], initialTab = 'entries', resultListProps = {}}) { - const classes = useSearchResultStyles() - const {domain, setGroups} = useContext(searchContext) - let [openTab, setOpenTab] = useQueryParam('results', StringParam) - openTab = openTab || initialTab - const ResultList = resultTabs[openTab].component - const handleTabChange = tab => { - setOpenTab(tab) - setGroups(resultTabs[tab].groups) - } - - useEffect(() => { - if (openTab !== 'entries') { - handleTabChange(openTab) - } - // eslint-disable-next-line - }, []) - - return <div className={classes.root}> - <Paper> - <Tabs - value={openTab} - indicatorColor="primary" - textColor="primary" - onChange={(event, value) => handleTabChange(value)} - > - {availableTabs.filter(tab => domain.searchTabs.includes(tab)).map(key => { - const tab = resultTabs[key] - return <Tab key={key} label={tab.label} value={key} /> - })} - </Tabs> - - <ResultList domain={domain} {...resultListProps} /> - </Paper> + <SearchResults + className={styles.resultList} + /> + <div className={clsx(styles.nonInteractive, styles.shadow, styles.shadowHidden, isMenuOpen && styles.shadowVisible)}></div> + </div> </div> -} -SearchResults.propTypes = { - 'availableTabs': PropTypes.arrayOf(PropTypes.string), - 'initialTab': PropTypes.string, - 'resultListProps': PropTypes.object -} - -function ReRunSearchButton() { - const {update} = useContext(searchContext) - return <Tooltip title="Re-execute the search"> - <IconButton onClick={update}> - <ReloadIcon /> - </IconButton> - </Tooltip> -} - -const usePagination = () => { - const {setRequestParameters} = useContext(searchContext) - let [requestQueryParameters, setRequestQueryParameters] = useQueryParams({ - order: NumberParam, order_by: StringParam, per_page: NumberParam, page: NumberParam - }) - requestQueryParameters = objectFilter(requestQueryParameters, key => requestQueryParameters[key]) - requestQueryParameters.page = requestQueryParameters.page || 1 - useEffect( - () => setRequestParameters(requestQueryParameters), - [requestQueryParameters, setRequestParameters] - ) - return setRequestQueryParameters -} - -const useScroll = (apiGroupName, afterParameterName) => { - afterParameterName = afterParameterName || `${apiGroupName}_after` - const apiAfterParameterName = `${apiGroupName}_grouped_after` - - const {response, setRequestParameters} = useContext(searchContext) - const [queryAfterParameter, setQueryAfterParameter] = useQueryParam(afterParameterName, StringParam) - useEffect( - () => { - const requestParameters = {} - requestParameters[apiAfterParameterName] = queryAfterParameter || null - setRequestParameters(requestParameters) - }, [queryAfterParameter, setRequestParameters, apiAfterParameterName] - ) - - const responseGroup = response[`${apiGroupName}_grouped`] - const after = responseGroup && responseGroup.after - const result = { - total: response.statistics.total.all[apiGroupName], - onChange: requestParameters => setQueryAfterParameter(requestParameters[apiAfterParameterName]) - } - result[afterParameterName] = after - return result -} - -function SearchEntryList(props) { - const {response, requestParameters, apiQuery, update} = useContext(searchContext) - const setRequestParameters = usePagination() - return <EntryList - query={apiQuery} - editable={apiQuery.owner === 'staging' || apiQuery.owner === 'user'} - data={response} - onChange={setRequestParameters} - onEdit={update} - actions={ - <React.Fragment> - <ReRunSearchButton/> - <ApiDialogButton data={response} /> - </React.Fragment> - } - {...requestParameters} - {...props} - /> -} - -function SearchDatasetList(props) { - const {response, update} = useContext(searchContext) - return <DatasetList - data={response} - onEdit={update} - actions={<ReRunSearchButton/>} - {...response} {...props} {...useScroll('datasets')} - /> -} - -function SearchGroupList(props) { - const {response} = useContext(searchContext) - return <GroupList - data={response} - actions={<ReRunSearchButton/>} - {...response} {...props} {...useScroll('dft.groups', 'groups_after')} - /> -} - -function SearchUploadList(props) { - const {response, update} = useContext(searchContext) - return <UploadList data={response} - onEdit={update} - actions={<ReRunSearchButton/>} - {...response} {...props} {...useScroll('uploads')} - /> +}) +Search.propTypes = { + collapsed: PropTypes.bool, + header: PropTypes.node } -function SearchMaterialsList(props) { - const {response} = useContext(searchContext) - return <MaterialsList - data={response} - actions={<ReRunSearchButton/>} - {...response} {...props} {...useScroll('encyclopedia.material.materials', 'materials_after')} - /> -} +export default Search diff --git a/gui/src/components/search/SearchBar.js b/gui/src/components/search/SearchBar.js index 6aa164c1bc..72f6a221db 100644 --- a/gui/src/components/search/SearchBar.js +++ b/gui/src/components/search/SearchBar.js @@ -15,379 +15,417 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useRef, useState, useContext, useCallback, useMemo } from 'react' -import {searchContext} from './SearchContext' +import React, { useCallback, useState, useMemo } from 'react' +import PropTypes from 'prop-types' +import clsx from 'clsx' +import { debounce, isNil } from 'lodash' import Autocomplete from '@material-ui/lab/Autocomplete' -import TextField from '@material-ui/core/TextField' -import { CircularProgress, InputAdornment, Button, Tooltip } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import SearchIcon from '@material-ui/icons/Search' +import CloseIcon from '@material-ui/icons/Close' +import { + TextField, + CircularProgress, + Paper, + Divider, + Tooltip, + Typography +} from '@material-ui/core' +import IconButton from '@material-ui/core/IconButton' +import { useApi } from '../apiV1' +import { useUnits } from '../../units' +import { isMetaNumber, isMetaTimestamp } from '../../utils' +import { + useSetFilters, + useFiltersLocked, + filterFullnames, + filterAbbreviations, + toGUIFilter, + filterData, + filters +} from './SearchContext' import searchQuantities from '../../searchQuantities' -import { apiContext } from '../api' -import { defsByName as metainfoDefs } from '../archive/metainfo' -import { domainData } from '../domainData' -const metainfoOptions = [] +const opMap = { + '<=': 'lte', + '>=': 'gte', + '>': 'gt', + '<': 'lt' +} +const opMapReverse = { + '<=': 'gte', + '>=': 'lte', + '>': 'lt', + '<': 'gt' +} -const quantitiesWithAlternativeOptions = { - calc_id: () => [], - upload_id: () => [], - calc_hash: () => [], - 'dft.quantities': () => { - if (metainfoOptions.length === 0) { - metainfoOptions.push(...Object.keys(metainfoDefs) - .filter(name => !name.startsWith('x_')) - .map(name => ({ - domain: 'dft', - quantity: 'dft.quantities', - value: name - }))) +// Decides which options are shown +const filterOptions = (options, {inputValue}) => { + const trimmed = inputValue.trim().toLowerCase() + return options.filter(option => { + // ES results do not need to be filtered at all + const category = option.category + if (category !== 'quantity name') { + return true } - return metainfoOptions - } + // Underscore can be replaced by a whitespace + const optionClean = option.value.trim().toLowerCase() + const matchUnderscore = optionClean.includes(trimmed) + const matchNoUnderscore = optionClean.replaceAll('_', ' ').includes(trimmed) + return matchUnderscore || matchNoUnderscore + }) } -// We need to treat dft. and encyclopedia. special. Usually all dft domain pieces -// are prefixed dft., but the encycloepdia is top-level and also a dft. specific -// quantity. These to functions remove and add the dft./encyclopedia. prefixes accordingly. -function getDomainOfQuantity(quantity) { - if (!quantity.includes('.')) { - return null - } - const firstSegment = quantity.split('.')[0] - if (firstSegment === 'encyclopedia') { - return 'dft' - } - return firstSegment +// Customized paper component for the autocompletion options +const CustomPaper = (props) => { + return <Paper elevation={3} {...props} /> } -function addDomainToQuantity(shortenedQuantityName, domainKey) { - if (!searchQuantities[shortenedQuantityName]) { - shortenedQuantityName = domainKey + '.' + shortenedQuantityName - if (!searchQuantities[shortenedQuantityName]) { - shortenedQuantityName = 'encyclopedia.' + shortenedQuantityName.slice(4) - } +const useStyles = makeStyles(theme => ({ + root: { + display: 'flex', + alignItems: 'center', + position: 'relative' + }, + notchedOutline: { + borderColor: 'rgba(0, 0, 0, 0.0)' + }, + iconButton: { + padding: 10 + }, + divider: { + height: '2rem' + }, + endAdornment: { + position: 'static' + }, + examples: { + position: 'absolute', + left: 0, + right: 0, + top: 'calc(100% + 4px)', + padding: theme.spacing(2), + fontStyle: 'italic' } - return shortenedQuantityName -} +})) /** - * This searchbar component shows a searchbar with autocomplete functionality. The - * searchbar also includes a status line about the current results. It uses the - * search context to manipulate the current query and display results. It does its on - * API calls to provide autocomplete suggestion options. + * This component shows a searchbar with autocomplete functionality. It does its + * on API calls to provide autocomplete suggestion options. */ -export default function SearchBar() { - const currentLoadOptionsConfigRef = useRef({ - timer: null, - latestOption: null, - requestedOption: null - }) - const {response: {statistics, pagination, error}, domain, query, apiQuery, setQuery} = useContext(searchContext) - const defaultOptions = useMemo(() => { - return Object.keys(searchQuantities) - .map(quantity => ({ - quantity: quantity, - domain: getDomainOfQuantity(quantity) - })) - .filter(option => !option.domain || option.domain === domain.key) - }, [domain.key]) - - const [open, setOpen] = useState(false) - const [options, setOptions] = useState(defaultOptions) +const SearchBar = React.memo(({ + className +}) => { + const styles = useStyles() + const units = useUnits() + const [suggestions, setSuggestions] = useState([]) const [loading, setLoading] = useState(false) const [inputValue, setInputValue] = useState('') - const [searchType, setSearchType] = useState('nomad') - - const {api} = useContext(apiContext) - - const autocompleteValue = Object.keys(query).map(quantity => ({ - quantity: quantity, - domain: quantity.includes('.') ? quantity.split('.')[0] : null, - value: query[quantity] - })) - - const handleSearchTypeClicked = useCallback(() => { - if (searchType === 'nomad') { - setSearchType('optimade') - } else { - setSearchType('nomad') - } - }, [searchType, setSearchType]) - - const handleOptimadeEntered = useCallback(query => { - setQuery({'dft.optimade': query}) - }, [setQuery]) - - let helperText = '' - if (error) { - helperText = '' + (error.apiMessage || error) - } else if (pagination && statistics) { - if (pagination.total === 0) { - helperText = <span>There are no more entries matching your criteria.</span> - } else { - helperText = <span> - There {pagination.total === 1 ? 'is' : 'are'} { - Object.keys(domain.searchMetrics).filter(key => statistics.total.all[key]).map(key => { - return <span key={key}> - {domain.searchMetrics[key].renderResultString(statistics.total.all[key])} - </span> - }) - }{Object.keys(query).length ? ' left' : ''}. - </span> + const [highlighted, setHighlighted] = useState({value: ''}) + const [open, setOpen] = useState(false) + const [error, setError] = useState(false) + const [showExamples, setShowExamples] = useState(false) + const {api} = useApi() + const filtersLocked = useFiltersLocked() + const setFilter = useSetFilters() + const quantitySet = filters + const quantitySuggestions = useMemo(() => { + const suggestions = [] + for (let q of filters) { + suggestions.push({ + value: filterAbbreviations[q] || q, + category: 'quantity name' + }) } - } - - const loadOptions = useCallback(option => { - const config = currentLoadOptionsConfigRef.current - config.latestOption = option + return suggestions + }, []) - if (config.timer !== null) { - clearTimeout(config.timer) - } - if (loading) { + // Triggered when a value is submitted by pressing enter or clicking the + // search icon. + const handleSubmit = useCallback(() => { + if (inputValue.trim().length === 0) { return } - config.timer = setTimeout(() => { - config.requestedOption = option - - const alternativeOptions = quantitiesWithAlternativeOptions[option.quantity] - if (alternativeOptions) { - setOptions(alternativeOptions()) + const reString = '[^\\s=<>](?:[^=<>]*[^\\s=<>])?' + const op = '(?:<|>)=?' + let valid = false + let quantityFullname + let queryValue + + // Equality query + const equals = inputValue.match(new RegExp(`^\\s*(${reString})\\s*=\\s*(${reString})\\s*$`)) + if (equals) { + const quantityName = equals[1] + quantityFullname = filterFullnames[quantityName] || quantityName + if (!quantitySet.has(quantityFullname)) { + setError(`Unknown quantity name`) return } - - const size = searchQuantities[option.quantity].statistic_size - setLoading(true) - api.suggestions_search(option.quantity, apiQuery, size ? null : option.value, size || 20, true) - .then(response => { - setLoading(false) - if (!config.latestOption || config.requestedOption.quantity !== config.latestOption.quantity) { - // don't do anything if quantity has changed in the meantime - return - } - const options = response.suggestions.map(value => ({ - quantity: option.quantity, - domain: option.domain, - value: value - })) - setOptions(options) - setOpen(true) - }) - .catch(() => { - setLoading(false) - }) - }, 200) - }, [api, currentLoadOptionsConfigRef, apiQuery, loading, setLoading]) - - const getOptionLabel = useCallback(option => { - if (option.quantity === 'from_time' || option.quantity === 'until_time') { - if (option.value) { - return `${option.quantity.replace('_time', '')}=${option.value.substring(0, 10)}` + try { + queryValue = toGUIFilter(quantityFullname, equals[2], units) + } catch (error) { + setError(`Invalid value for this metainfo. Please check your syntax.`) + return } + valid = true } - let label = option.quantity + '=' - if (option.value) { - if (Array.isArray(option.value)) { - label += option.value.join(',') - } else { - label += option.value + // Simple LTE/GTE query + if (!valid) { + const ltegte = inputValue.match(new RegExp(`^\\s*(${reString})\\s*(${op})\\s*(${reString})\\s*$`)) + if (ltegte) { + const a = ltegte[1] + const op = ltegte[2] + const b = ltegte[3] + const aFullname = filterFullnames[a] + const bFullname = filterFullnames[b] + const isAQuantity = quantitySet.has(aFullname) + const isBQuantity = quantitySet.has(bFullname) + if (!isAQuantity && !isBQuantity) { + setError(`Unknown quantity name`) + return + } + quantityFullname = isAQuantity ? aFullname : bFullname + if (!isMetaNumber(quantityFullname) && !isMetaTimestamp(quantityFullname)) { + setError(`Cannot perform range query for a non-numeric quantity.`) + return + } + let quantityValue + try { + quantityValue = toGUIFilter(quantityFullname, isAQuantity ? b : a, units) + } catch (error) { + setError(`Invalid value for this metainfo. Please check your syntax.`) + return + } + queryValue = {} + queryValue[opMap[op]] = quantityValue + valid = true } } - return label.substring(label.indexOf('.') + 1) - }, []) - - const parseOption = useCallback(input => { - const [inputQuantity, inputValue] = input.split('=') - - const quantity = addDomainToQuantity(inputQuantity, domain.key) - let value = inputValue - if (value && searchQuantities[quantity] && searchQuantities[quantity].many) { - value = value.split(',').map(item => item.trim()) - } - return { - inputQuantity: inputQuantity, - inputValue: inputValue, - domain: inputQuantity.includes('.') ? inputQuantity.split('.')[0] : null, - quantity: searchQuantities[quantity] ? quantity : null, - value: value - } - }, [domain.key]) - const filterOptions = useCallback((options, params) => { - const inputOption = parseOption(params.inputValue) - const filteredOptions = options.filter(option => { - if (!inputOption.quantity) { - return option.quantity.includes( - inputOption.inputQuantity) && (option.domain === domain.key || !option.domain) - } - if (option.quantity !== inputOption.quantity) { - return false - } - - if (!inputOption.value) { - return true - } + // Sandwiched LTE/GTE query + if (!valid) { + const ltegteSandwich = inputValue.match(new RegExp(`^\\s*(${reString})\\s*(${op})\\s*(${reString})\\s*(${op})\\s*(${reString})\\s*$`)) + if (ltegteSandwich) { + const a = ltegteSandwich[1] + const op1 = ltegteSandwich[2] + const b = ltegteSandwich[3] + const op2 = ltegteSandwich[4] + const c = ltegteSandwich[5] + quantityFullname = filterFullnames[b] + if (!isMetaNumber(quantityFullname) && !isMetaTimestamp(quantityFullname)) { + setError(`Cannot perform range query for a non-numeric quantity.`) + return + } + const isBQuantity = quantitySet.has(quantityFullname) + if (!isBQuantity) { + setError(`Unknown quantity name`) + return + } - const matches = option.value && - inputOption.inputValue && - option.value.toLowerCase().includes(inputOption.inputValue.toLowerCase()) - if (matches) { - if (option.value === inputOption.inputValue) { - inputOption.exists |= true + queryValue = {} + try { + queryValue[opMapReverse[op1]] = toGUIFilter(quantityFullname, a, units) + queryValue[opMap[op2]] = toGUIFilter(quantityFullname, c, units) + } catch (error) { + setError(`Invalid value for this metainfo. Please check your syntax.`) + return } - return true + valid = true } - - return false - }) - - // Add the value as option, even if it does not exist to allow search for missing, - // faulty, or not yet loaded options - if (inputOption.quantity && !inputOption.exists) { - filteredOptions.push(inputOption) } - return filteredOptions - }, [domain.key, parseOption]) + // Check if filter is locked + if (filtersLocked[quantityFullname]) { + setError(`Cannot change the filter as it is locked in the current search context.`) + return + } - const handleInputChange = useCallback((event, value, reason) => { - if (reason === 'input') { - setInputValue(value) - const inputOption = parseOption(value) - if (inputOption.quantity) { - loadOptions(inputOption) - } else { - setOptions(defaultOptions) - } + if (valid) { + // Submit to search context on successful validation. + setFilter([quantityFullname, old => { + const multiple = filterData[quantityFullname].multiple + return (isNil(old) || !multiple) ? queryValue : new Set([...old, ...queryValue]) + }]) + setInputValue('') + setOpen(false) + } else { + setError(`Invalid query`) } - }, [loadOptions, defaultOptions, parseOption]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputValue, quantitySet]) + + // Handle clear button + const handleClose = useCallback(() => { + setInputValue('') + setSuggestions([]) + setOpen(false) + setShowExamples(true) + }, []) - const handleChange = (event, entries) => { - currentLoadOptionsConfigRef.current.latestOption = null + const handleHighlight = useCallback((event, value, reason) => { + setHighlighted(value) + }, []) - entries = entries.map(entry => { - if (typeof entry === 'string') { - return parseOption(entry) + // When enter is pressed, select currently highlighted value and close menu, + // or if menu is not open submit the value. + const handleEnter = useCallback((event) => { + if (event.key === 'Enter') { + if (open && highlighted?.value) { + setInputValue(highlighted.value) + setOpen(false) } else { - return entry + handleSubmit() } - }) - - const newQuery = entries.reduce((query, entry) => { - if (entry) { - if (query[entry.quantity]) { - if (searchQuantities[entry.quantity].many) { - if (Array.isArray(query[entry.quantity])) { - query[entry.quantity].push(entry.value) - } else { - query[entry.quantity] = [query[entry.quantity], entry.value] - } - } else { - query[entry.quantity] = entry.value + event.stopPropagation() + event.preventDefault() + } + }, [open, highlighted, handleSubmit]) + + const suggestionCall = useCallback((quantityList, value) => { + setLoading(true) + // If some input is given, and the quantity supports suggestions, we use + // input suggester to suggest values + const filteredList = quantityList.filter(q => searchQuantities[q]?.suggestion) + api.suggestions(filteredList, value) + .then(data => { + let res = [] + for (let q of filteredList) { + const name = filterAbbreviations[q] || q + const esSuggestions = data[q] + if (esSuggestions) { + res = res.concat(esSuggestions.map(suggestion => ({ + value: `${name}=${suggestion.value}`, + category: name + }))) } - } else { - query[entry.quantity] = entry.value } - } - return query - }, {}) - setQuery(newQuery, true) - - if (entries.length !== 0) { - const entry = entries[entries.length - 1] - if (entry.value) { - setInputValue('') - } else { - setInputValue(getOptionLabel(entry)) - loadOptions(entry) + setSuggestions(res) + }) + .finally(() => setLoading(false)) + }, [api]) + const suggestionDebounced = useCallback(debounce(suggestionCall, 150), []) + + // Handle typing events. After a debounce time has expired, a list of + // suggestion will be retrieved if they are available for this metainfo and + // the input is deemed meaningful. + const handleInputChange = useCallback((event, value, reason) => { + setError(error => error ? undefined : null) + setInputValue(value) + value = value?.trim() + setShowExamples(!value) + if (!value) { + setSuggestions([]) + setOpen(false) + setShowExamples(true) + return + } else { + setOpen(true) + setShowExamples(false) + } + if (reason !== 'input') { + setSuggestions([]) + setOpen(false) + } + // If the input is prefixed with a proper quantity name and an equals-sign, + // we extract the quantity name and the typed input + const split = value.split('=', 2) + let quantityList = [...filters] + if (split.length === 2) { + const quantityName = split[0].trim() + const quantityFullname = filterFullnames[quantityName] + if (quantitySet.has(quantityName)) { + quantityList = [quantityName] + value = split[1].trim() + } else if (quantitySet.has(quantityFullname)) { + quantityList = [quantityFullname] + value = split[1].trim() } } - } - React.useEffect(() => { - if (!open) { - setOptions(defaultOptions) + setLoading(true) + // If some input is given, and the quantity supports suggestions, we use + // input suggester to suggest values + if (value.length > 0) { + suggestionDebounced(quantityList, value) + // If no input is given, we suggest Enum values, or for non-enum quantities + // use terms aggregation. + } else { } - }, [open, defaultOptions]) + }, [quantitySet, suggestionDebounced]) - const commonTextFieldProps = params => ({ - error: !!error, - helperText: helperText, - variant: 'outlined', - fullWidth: true, - ...params - }) - - const commonInputProps = (params) => ({ - ...params, - startAdornment: ( - <React.Fragment> - {domain === domainData.dft && - <InputAdornment position="start"> - <Tooltip title="Switch between NOMAD's quantity=value search and the Optimade filter language."> - <Button onClick={handleSearchTypeClicked}size="small">{searchType}</Button> - </Tooltip> - </InputAdornment>} - {params.startAdornment} - </React.Fragment> - ) - }) + // This determines the order: notice that items should be sorted by group + // first in order for the grouping to work correctly. + const options = useMemo(() => { + return suggestions.concat(quantitySuggestions) + }, [quantitySuggestions, suggestions]) - if (searchType === 'nomad') { - return <Autocomplete - multiple + return <Paper className={clsx(className, styles.root)}> + <Autocomplete + className={styles.input} freeSolo + clearOnBlur={false} inputValue={inputValue} - value={autocompleteValue} - limitTags={4} - id='search-bar' + value={null} open={open} - onOpen={() => { - setOpen(true) - }} - onClose={() => { - setOpen(false) - }} - onChange={handleChange} - onInputChange={handleInputChange} - getOptionSelected={(option, inputOption) => { - return inputOption.quantity === option.quantity && inputOption.value === option.value - }} - getOptionLabel={getOptionLabel} - options={options} - loading={loading} + onFocus={() => setShowExamples(true)} + onBlur={() => setShowExamples(false)} + onOpen={() => { if (inputValue.trim() !== '') { setOpen(true) } }} + onClose={() => setOpen(false)} + fullWidth + disableClearable + PaperComponent={CustomPaper} + classes={{endAdornment: styles.endAdornment}} + groupBy={(option) => option.category} filterOptions={filterOptions} - // handleHomeEndKeys + options={options} + onInputChange={handleInputChange} + onHighlightChange={handleHighlight} + getOptionLabel={option => option.value} + getOptionSelected={(option, value) => false} renderInput={(params) => ( <TextField - {...commonTextFieldProps(params)} - label={searchType === 'nomad' ? 'Search with quantity=value' : 'Search with Optimade filter language'} + {...params} + className={styles.textField} + variant="outlined" + placeholder="" + label={error || undefined} + error={!!error} + onKeyDown={handleEnter} + InputLabelProps={{ shrink: true }} InputProps={{ - ...commonInputProps(params.InputProps), - endAdornment: ( - <React.Fragment> - {loading ? <CircularProgress color='inherit' size={20} /> : null} - {params.InputProps.endAdornment} - </React.Fragment> - ) + ...params.InputProps, + classes: { + notchedOutline: styles.notchedOutline + }, + endAdornment: (<> + {loading ? <CircularProgress color="inherit" size={20} /> : null} + {(inputValue?.length || null) && <> + <Tooltip title="Clear"> + <IconButton onClick={handleClose} className={styles.iconButton} aria-label="clear"> + <CloseIcon /> + </IconButton> + </Tooltip> + <Divider className={styles.divider} orientation="vertical"/> + </>} + <Tooltip title="Add filter"> + <IconButton onClick={handleSubmit} className={styles.iconButton} aria-label="search"> + <SearchIcon /> + </IconButton> + </Tooltip> + </>) }} /> )} /> - } else { - return <TextField - {...commonTextFieldProps({})} - label={searchType === 'nomad' ? 'Search with quantity=value' : 'Search with Optimade filter language'} - InputProps={{ - ...commonInputProps({}) - }} - defaultValue={query['dft.optimade'] || ''} - onKeyPress={(ev) => { - if (ev.key === 'Enter') { - handleOptimadeEntered(ev.target.value) - ev.preventDefault() - } - }} - /> - } + {showExamples && <CustomPaper className={styles.examples}> + <Typography>{'Start typing a query or a keyword to get relevant suggestions.'}</Typography> + </CustomPaper>} + </Paper> +}) + +SearchBar.propTypes = { + className: PropTypes.string } + +export default SearchBar diff --git a/gui/src/components/search/SearchContext.js b/gui/src/components/search/SearchContext.js index 67b1d90ebd..af4c4fe119 100644 --- a/gui/src/components/search/SearchContext.js +++ b/gui/src/components/search/SearchContext.js @@ -15,312 +15,1129 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState, useContext, useEffect, useRef, useCallback, useMemo } from 'react' -import PropTypes from 'prop-types' -import hash from 'object-hash' -import { errorContext } from '../errors' -import { onlyUnique, objectFilter } from '../../utils' -import { domainData } from '../domainData' -import { apiContext } from '../api' -import { useLocation, useHistory } from 'react-router-dom' +import React, { useCallback, useEffect, useState, useRef, useMemo, useContext } from 'react' +import { + atom, + atomFamily, + selector, + useSetRecoilState, + useRecoilValue, + useRecoilState, + useRecoilCallback +} from 'recoil' +import { + debounce, + isEmpty, + isArray, + isPlainObject, + isNil, + isString +} from 'lodash' import qs from 'qs' +import PropTypes from 'prop-types' +import { useHistory } from 'react-router-dom' +import { useApi } from '../apiV1' +import { setToArray, formatMeta, parseMeta } from '../../utils' import searchQuantities from '../../searchQuantities' +import { Quantity } from '../../units' + +export const filters = new Set() // Contains the full names of all the available filters +export const filterGroups = [] // Mapping from filter full name -> group +export const filterAbbreviations = [] // Mapping of filter full name -> abbreviation +export const filterFullnames = [] // Mapping of filter abbreviation -> full name +export const filterData = {} // Stores data for each registered filter +export const labelMaterial = 'Material' +export const labelElements = 'Elements / Formula' +export const labelSymmetry = 'Symmetry' +export const labelMethod = 'Method' +export const labelSimulation = 'Simulation' +export const labelDFT = 'DFT' +export const labelGW = 'GW' +export const labelProperties = 'Properties' +export const labelElectronic = 'Electronic' +export const labelVibrational = 'Vibrational' +export const labelAuthor = 'Author / Origin' +export const labelAccess = 'Access' +export const labelDataset = 'Dataset' +export const labelIDs = 'IDs' + +/** + * This function is used to register a new filter within the FilterContext. + * Filters are entities that can be searched throuh the filter panel and the + * search bar, and can be encoded in the URL. Notice that a filter in this + * context does not have to correspond to a quantity in the metainfo. + * + * Only registered filters may be searched for. The registration must happen + * before any components use the filters. This is because: + * - The initial aggregation results must be fetched before any components + * using the filter values are rendered. + * - Several components need to know the list of available filters (e.g. the + * search bar and the search panel). If filters are only registered during + * component initialization, it may already be too late to update other + * components. + * + * @param {string} name Name of the filter. + * @param {string} group The group into which the filter belongs to. Groups + * are used to e.g. in showing FilterSummaries about a group of filters. + * @param {string|object} agg Custom setter/getter for the aggregation value. As a + * shortcut you can provide an ES aggregation type as a string, + * @param {object} value Custom setter/getter for the filter value. + * @param {bool} multiple Whether this filter supports several values: + * controls whether setting the value appends or overwrites. + */ +function registerFilter(name, group, agg, value, multiple = true) { + filters.add(name) + if (group) { + filterGroups[group] + ? filterGroups[group].add(name) + : filterGroups[group] = new Set([name]) + } + + // Register mappings from full name to abbreviation and vice versa + const abbreviation = name.split('.').pop() + const oldName = filterAbbreviations[abbreviation] + if (!oldName) { + filterAbbreviations[name] = abbreviation + filterFullnames[abbreviation] = name + } else { + delete filterFullnames[abbreviation] + filterAbbreviations[name] = name + filterAbbreviations[oldName] = oldName + } + + const data = filterData[name] || {} + if (agg) { + let aggSet, aggGet + if (isString(agg)) { + aggSet = {[name]: agg} + aggGet = (aggs) => (aggs[name][agg].data) + } else { + aggSet = agg.set + aggGet = agg.get + } + data.aggSet = aggSet + data.aggGet = aggGet + } + if (value) { + data.valueSet = value.set + } + data.multiple = multiple + filterData[name] = data +} -const padDateNumber = number => String('00' + number).slice(-2) +// Filters that directly correspond to a metainfo value +registerFilter('results.material.structural_type', labelMaterial, 'terms') +registerFilter('results.material.functional_type', labelMaterial, 'terms') +registerFilter('results.material.compound_type', labelMaterial, 'terms') +registerFilter('results.material.material_name', labelMaterial) +registerFilter('results.material.chemical_formula_hill', labelElements) +registerFilter('results.material.chemical_formula_anonymous', labelElements) +registerFilter('results.material.n_elements', labelElements, 'min_max', undefined, false) +registerFilter('results.material.symmetry.bravais_lattice', labelSymmetry, 'terms') +registerFilter('results.material.symmetry.crystal_system', labelSymmetry, 'terms') +registerFilter('results.material.symmetry.structure_name', labelSymmetry, 'terms') +registerFilter('results.material.symmetry.strukturbericht_designation', labelSymmetry, 'terms') +registerFilter('results.material.symmetry.space_group_symbol', labelSymmetry) +registerFilter('results.material.symmetry.point_group', labelSymmetry) +registerFilter('results.material.symmetry.hall_symbol', labelSymmetry) +registerFilter('results.material.symmetry.prototype_aflow_id', labelSymmetry) +registerFilter('results.method.method_name', labelMethod, 'terms') +registerFilter('results.method.simulation.program_name', labelMethod, 'terms') +registerFilter('results.method.simulation.program_version', labelMethod) +registerFilter('results.method.simulation.dft.basis_set_type', labelDFT, 'terms') +registerFilter('results.method.simulation.dft.core_electron_treatment', labelDFT, 'terms') +registerFilter('results.method.simulation.dft.xc_functional_type', labelDFT, 'terms') +registerFilter('results.method.simulation.dft.relativity_method', labelDFT, 'terms') +registerFilter('results.method.simulation.gw.gw_type', labelGW, 'terms') +registerFilter('results.properties.electronic.band_structure_electronic.channel_info.band_gap_type', labelElectronic, 'terms') +registerFilter('results.properties.electronic.band_structure_electronic.channel_info.band_gap', labelElectronic, 'min_max', undefined, false) +registerFilter('external_db', labelAuthor, 'terms') +registerFilter('authors.name', labelAuthor) +registerFilter('upload_time', labelAuthor, 'min_max', undefined, false) +registerFilter('datasets.name', labelDataset) +registerFilter('datasets.doi', labelDataset) +registerFilter('entry_id', labelIDs) +registerFilter('upload_id', labelIDs) +registerFilter('results.material.material_id', labelIDs) +registerFilter('datasets.dataset_id', labelIDs) -export const Dates = { - dateHistogramStartDate: '2014-12-15', - APIDate: date => date.toISOString(), - JSDate: date => new Date(date), - FormDate: date => { - date = new Date(date) - return `${date.getFullYear()}-${padDateNumber(date.getMonth())}-${padDateNumber(date.getDate())}` +// In exclusive element query the elements names are sorted and concatenated +// into a single string. +registerFilter( + 'results.material.elements', + labelElements, + 'terms', + { + set: (newQuery, oldQuery, value) => { + if (oldQuery.exclusive) { + if (value.size !== 0) { + newQuery['results.material.elements_exclusive'] = setToArray(value).sort().join(' ') + } + } else { + newQuery['results.material.elements'] = value + } + } + } +) +// Electronic properties: subset of results.properties.available_properties +registerFilter( + 'electronic_properties', + labelElectronic, + { + set: {'results.properties.available_properties': 'terms'}, + get: (aggs) => (aggs['results.properties.available_properties'].terms.data) }, - addSeconds: (date, interval) => new Date(date.getTime() + interval * 1000), - deltaSeconds: (from, end) => Math.round((new Date(end).getTime() - new Date(from).getTime()) / 1000), - intervalSeconds: (from, end, buckets) => Math.round((new Date(end).getTime() - new Date(from).getTime()) / (1000 * buckets)), - buckets: 50 + { + set: (newQuery, oldQuery, value) => { + const data = newQuery['results.properties.available_properties'] || new Set() + value.forEach((item) => { data.add(item) }) + newQuery['results.properties.available_properties'] = data + }, + get: (data) => (data.results.properties.available_properties) + } +) +// Vibrational properties: subset of results.properties.available_properties +registerFilter( + 'vibrational_properties', + labelVibrational, + { + set: {'results.properties.available_properties': 'terms'}, + get: (aggs) => (aggs['results.properties.available_properties'].terms.data) + }, + { + set: (newQuery, oldQuery, value) => { + const data = newQuery['results.properties.available_properties'] || new Set() + value.forEach((item) => { data.add(item) }) + newQuery['results.properties.available_properties'] = data + }, + get: (data) => (data.results.properties.available_properties) + } +) +// Visibility: controls the 'owner'-parameter in the API query, not part of the +// query itself. +registerFilter( + 'visibility', + labelAccess, + undefined, + {set: () => {}}, + false +) +// Restricted: controls whether materials search is done in a restricted mode. +registerFilter( + 'restricted', + undefined, + undefined, + {set: () => {}}, + false +) +// Exclusive: controls the way elements search is done. +registerFilter( + 'exclusive', + undefined, + undefined, + {set: () => {}}, + false +) + +// Material and entry queries target slightly different fields. Here we prebuild +// the mapping. +const materialNames = {} // Mapping of field name from entry -> material +const entryNames = {} // Mapping of field name from material -> entry +for (const name of Object.keys(searchQuantities)) { + const prefix = 'results.material.' + let materialName + if (name.startsWith(prefix)) { + materialName = name.substring(prefix.length) + } else { + materialName = `entries.${name}` + } + materialNames[name] = materialName + entryNames[materialName] = name } -searchQuantities['from_time'] = {name: 'from_time'} -searchQuantities['until_time'] = {name: 'until_time'} -searchQuantities['dft.optimade'] = {name: 'dft.optimade'} +export const searchContext = React.createContext() +export const SearchContext = React.memo(({ + resource, + filtersLocked, + children +}) => { + const setQuery = useSetRecoilState(queryState) + const setLocked = useSetRecoilState(lockedState) + const {api} = useApi() + const setInitialAggs = useSetRecoilState(initialAggsState) + + // Reset the query/locks when entering the search context for the first time + const reset = useRecoilCallback(({reset}) => () => { + for (let filter of filters) { + reset(queryFamily(filter)) + reset(lockedFamily(filter)) + } + }, []) + + useEffect(() => { + reset() + }, [reset]) + + // Read the initial query from the URL + const query = useMemo(() => { + const location = window.location.href + const split = location.split('?') + let qs, query + if (split.length === 1) { + query = {} + } else { + qs = split.pop() + query = qsToQuery(qs) + } + return query + }, []) + + // Save the initial query and locked filters. Cannot be done inside useMemo + // due to bad setState. + useEffect(() => { + setQuery(query) + // Transform the locked values into a GUI-suitable format and store them + if (filtersLocked) { + const filtersLockedGUI = {} + for (const [key, value] of Object.entries(filtersLocked)) { + filtersLockedGUI[key] = toGUIFilter(key, value) + } + setLocked(filtersLockedGUI) + } + }, [setLocked, setQuery, query, filtersLocked]) + + // Fetch initial aggregation data. + useEffect(() => { + const aggRequest = {} + const aggNames = [...filters].filter(name => filterData[name].aggGet) + for (const filter of aggNames) { + toAPIAgg(aggRequest, filter, resource) + } + + const search = { + owner: 'visible', + query: {}, + aggregations: aggRequest, + pagination: {page_size: 0} + } + + api.query(resource, search, false) + .then(data => { + data = toGUIAgg(data.aggregations, aggNames, resource) + setInitialAggs(data) + }) + }, [api, setInitialAggs, resource]) + + const values = useMemo(() => ({ + resource: resource + }), [resource]) + + return <searchContext.Provider value={values}> + {children} + </searchContext.Provider> +}) +SearchContext.propTypes = { + resource: PropTypes.string, + filtersLocked: PropTypes.object, + children: PropTypes.node +} + +export function useSearchContext() { + return useContext(searchContext) +} -const filterPaginationParams = query => objectFilter(query, key => key !== 'page' && !key.endsWith('_after')) /** - * A custom hook to read, update, and set the query URL part. + * Each search filter is here mapped into a separate Recoil.js Atom. This + * allows components to hook into individual search parameters (both for setting + * and reading their value). This performs much better than having one large + * Atom for the entire query, as this would cause all of the hooked components + * to render even if they are not affected by some other search filter. + * Re-renders became problematic with large and complex components (e.g. the + * periodic table), for which the re-rendering takes significant time. Another + * approach would have been to try and Memoize each sufficiently complex + * component, but this quickly becomes a hard manual task. */ -export const useUrlQuery = () => { - const location = useLocation() - const history = useHistory() - const urlQuery = location.search ? { - ...qs.parse(location.search.substring(1)) - } : {} +export const queryFamily = atomFamily({ + key: 'queryFamily', + default: undefined +}) +export const lockedFamily = atomFamily({ + key: 'lockedFamily', + default: false +}) - const setUrlQuery = urlQuery => { - history.push(location.pathname + '?' + qs.stringify(urlQuery, {indices: false})) - } +// Menu open state +export const menuOpen = atom({ + key: 'isMenuOpen', + default: false +}) +export function useMenuOpenState() { + return useRecoilState(menuOpen) +} +export function useSetMenuOpen() { + return useSetRecoilState(menuOpen) +} - const updateUrlQuery = changes => { - const oldQuery = (changes.owner || changes.domain) ? filterPaginationParams(urlQuery) : urlQuery - setUrlQuery({...oldQuery, ...changes}) +// Current menu path +export const menuPath = atom({ + key: 'menuPath', + default: 'Filters' +}) +export function useMenuPathState() { + return useRecoilState(menuPath) +} +export function useMenuPath() { + return useRecoilValue(menuPath) +} +export function useSetMenuPath() { + return useSetRecoilState(menuPath) +} + +// Whether the search is initialized. +export const initializedState = atom({ + key: 'initialized', + default: false +}) + +/** + * Returns a function that can be called to reset all current filters. + * + * @returns Function for resetting all filters. + */ +export function useResetFilters() { + const locked = useRecoilValue(lockedState) + const reset = useRecoilCallback(({reset}) => () => { + for (let filter of filters) { + if (!locked[filter]) { + reset(queryFamily(filter)) + } + } + }, [locked]) + return reset +} + +/** + * This hook will expose a function for reading if the given filter is locked. + * + * @param {string} name Name of the filter. + * @returns Whether the filter is locked or not. + */ +export function useFilterLocked(name) { + return useRecoilValue(lockedFamily(name)) +} + +/** + * This hook will expose a function for reading the locked status of all + * filters. + * + * @returns An object containing a mapping from filter name to a boolean + * indicating whether it is locked or not. + */ +export function useFiltersLocked() { + return useRecoilValue(lockedState) +} + +/** + * This hook will expose a function for reading if the given set of filters are + * locked. + * + * @param {string} names Names of the filters. + * @returns Array containing the filter values in a map and a setter function. + */ +let indexLocked = 0 +export function useFiltersLockedState(names) { + // We dynamically create a Recoil.js selector that is subscribed to the + // filters specified in the input. This way only the specified filters will + // cause a render. Recoil.js requires that each selector/atom has an unique + // id. Because this hook can be called dynamically, we simply generate the ID + // sequentially. + const filterState = useMemo(() => { + const id = `locked_selector${indexLocked}` + indexLocked += 1 + return selector({ + key: id, + get: ({get}) => { + const query = {} + for (let key of names) { + const filter = get(lockedFamily(key)) + query[key] = filter + } + return query + } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return useRecoilValue(filterState) +} + +// Used to set the locked state of several filters at once +const lockedState = selector({ + key: 'lockedState', + get: ({get}) => { + const locks = {} + for (let key of filters) { + const filter = get(lockedFamily(key)) + locks[key] = filter + } + return locks + }, + set: ({ get, set, reset }, data) => { + if (data) { + for (const [key, value] of Object.entries(data)) { + set(queryFamily(key), value) + set(lockedFamily(key), true) + } + } } +}) + +/** + * This hook will expose a function for reading filter values. Use this hook if + * you intend to only view the filter values and are not interested in setting + * the filter. + * + * @param {string} name Name of the filter. + * @returns currently set filter value. + */ +export function useFilterValue(name) { + return useRecoilValue(queryFamily(name)) +} + +/** + * This hook will expose a function for setting a filter value. Use this hook if + * you intend to only set the filter value and are not interested in the query + * results. + * + * @param {string} name Name of the quantity to set. + * @returns function for setting the value for the given quantity + */ +export function useSetFilter(name) { + return useSetRecoilState(queryFamily(name)) +} - return [urlQuery, updateUrlQuery, setUrlQuery] +/** + * This hook will expose a function for getting and setting filter values. Use + * this hook if you intend to both read and write the filter value. + * + * @param {string} name Name of the filter. + * @returns Array containing the filter value and setter function for it. + */ +export function useFilterState(name) { + return useRecoilState(queryFamily(name)) } + /** - * A custom hook that reads and writes search parameters from the current URL. + * This hook will expose a function for setting the values of all filters. + * + * @returns An object containing a mapping from filter name to a boolean + * indicating whether it is locked or not. */ -const useSearchUrlQuery = () => { - // eslint-disable-next-line no-unused-vars - const [urlQuery, unused, setUrlQuery] = useUrlQuery() - const searchQuery = objectFilter(urlQuery, key => searchQuantities[key] && key !== 'domain') - const rest = objectFilter(urlQuery, key => !searchQuantities[key] || key === 'domain') - if (searchQuery.atoms && !Array.isArray(searchQuery.atoms)) { - searchQuery.atoms = [searchQuery.atoms] +export function useSetFilters() { + return useSetRecoilState(filtersState) +} + +// Used to get/set the locked state of all filters at once +const filtersState = selector({ + key: 'filtersState', + get: ({get}) => { + const query = {} + for (let key of filters) { + const filter = get(queryFamily(key)) + query[key] = filter + } + return query + }, + set: ({set}, [key, value]) => { + set(queryFamily(key), value) } - if (searchQuery.only_atoms && !Array.isArray(searchQuery.only_atoms)) { - searchQuery.only_atoms = [searchQuery.only_atoms] +}) + +/** + * This hook will expose a function for getting and setting filter values for + * the specified list of filters. Use this hook if you intend to both read and + * write the filter values. + * + * @param {string} names Names of the filters. + * @returns Array containing the filter values in a map and a setter function. + */ +let indexFilters = 0 +export function useFiltersState(names) { + // We dynamically create a Recoil.js selector that is subscribed to the + // filters specified in the input. This way only the specified filters will + // cause a render. Recoil.js requires that each selector/atom has an unique + // id. Because this hook can be called dynamically, we simply generate the ID + // sequentially. + const filterState = useMemo(() => { + const id = `dynamic_selector${indexFilters}` + indexFilters += 1 + return selector({ + key: id, + get: ({get}) => { + const query = {} + for (let key of names) { + const filter = get(queryFamily(key)) + query[key] = filter + } + return query + }, + set: ({set}, [key, value]) => { + set(queryFamily(key), value) + } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return useRecoilState(filterState) +} + +/** + * This Recoil.js selector aggregates all the currently set filters into a + * single query object used by the API. + */ +const queryState = selector({ + key: 'query', + get: ({get}) => { + if (!get(initializedState)) { + return undefined + } + let query = {} + for (let key of filters) { + const filter = get(queryFamily(key)) + if (filter !== undefined) { + query[key] = filter + } + } + return query + }, + set: ({ get, set, reset }, data) => { + for (let filter of filters) { + reset(queryFamily(filter)) + } + if (data) { + for (const [key, value] of Object.entries(data)) { + set(queryFamily(key), value) + } + set(initializedState, true) + } else { + set(initializedState, false) + } } - const setSearchUrlQuery = query => setUrlQuery({ - ...filterPaginationParams(rest), - ...objectFilter(query, key => query[key]) - }) - return [searchQuery, setSearchUrlQuery] +}) + +export function useQuery() { + return useRecoilValue(queryState) } /** - * The React context object. Can be accessed from functional components with useContext. + * Hook for writing a query object to the query string. + * + * @returns {object} Object containing the search object. */ -export const searchContext = React.createContext() +export function useUpdateQueryString() { + const history = useHistory() + + const updateQueryString = useCallback((query, locked) => { + const queryString = queryToQs(query, locked) + history.replace(history.location.pathname + '?' + queryString) + }, [history]) + + return updateQueryString +} /** - * Component that provides a searchContext. Can be used with useContext. The context - * objects provides access to the current search request and response as well as - * callbacks to manipulate the current search request. + * Converts a query string into a valid query object. * - * The search request is made from two objects: the request and the query. The former - * contains all parameters that do not effect the search results themselves. This includes - * pagination, statistics, order. The query object contains all parameters that - * constitute the actual search. This includes the domain and owner parameters. + * @param {string} queryString URL querystring, encoded or not. + * @returns Returns an object containing the filters. Values are converted into + * datatypes that are directly compatible with the filter components. */ -export default function SearchContext({initialRequest, initialQuery, query, children}) { - const defaultStatistics = [] // ['atoms', 'authors'] TODO - const emptyResponse = useMemo(() => ({ - statistics: { - total: { - all: {} +function qsToQuery(queryString) { + const query = qs.parse(queryString, {comma: true}) + const newQuery = {} + for (let [key, value] of Object.entries(query)) { + const split = key.split(':') + key = split[0] + let newKey = filterFullnames[key] || key + const valueGUI = toGUIFilter(newKey, value) + if (split.length !== 1) { + const op = split[1] + const oldValue = newQuery[newKey] + if (!oldValue) { + newQuery[newKey] = {[op]: valueGUI} + } else { + newQuery[newKey][op] = valueGUI } - }, - pagination: { - total: undefined, - per_page: 10, - page: 1, - order: -1, - order_by: 'upload_time' - }, - metric: domainData.dft.defaultSearchMetric - }), []) - - const {api} = useContext(apiContext) - const {raiseError} = useContext(errorContext) - - // React calls the children effects for the parent effect. But the parent effect is - // run with the state of the last render, which is the state before the children effects. - // If we would maintain the request in regular React state, we might execute unnecessary - // outdated requests. - // Therefore, we use two ref objects and one state object to manage the current state. - // The goal is to reduce the amounts of re-renders, only send requests to the api with - // the latest set parameters, and only send requests if necessary. - // The first ref keeps all information that will form the next - // search request that is send to the API. It therefore keeps the state of the current - // request provided by the various children of this context. It also helps us to - // lower the amount of state changes. - // The second ref keeps a hash over the last request that was send to the API. - // This is used to verify if a new request is actually necessary. - const requestRef = useRef({ - metric: domainData.dft.defaultSearchMetric, - statistics: [], - groups: {}, - domainKey: domainData.dft.key, - owner: 'all', - pagination: { - page: 1, - per_page: 10, - order: -1, - order_by: 'upload_time' - }, - statisticsToRefresh: [], - query: {}, - update: 0 - }) - const lastRequestHashRef = useRef(0) - - // We use proper React state to maintain the last response from the API. - const [response, setResponse] = useState(emptyResponse) - - // We use a custom hook to read/write search parameters from the current URL. - const [urlQuery, setUrlQuery] = useSearchUrlQuery() - // We set the current query. This will be used by an effect to potentially call the - // API after rendering. - requestRef.current.query = urlQuery - - // This is a callback that executes the current request in requestRef without any - // checks for necessity. It will update the response state, once the request has - // been answered by the api. - const runRequest = useCallback(() => { - let dateHistogramInterval = null - const {metric, domainKey, owner, dateHistogram} = requestRef.current - const domain = domainData[domainKey] - const apiRequest = { - ...initialRequest, - ...requestRef.current.pagination, - statistics: requestRef.current.statistics, - ...requestRef.current.groups, - metrics: (metric === domain.defaultSearchMetric) ? [] : [metric], - domain: domain.key - } - const apiQuery = { - ...apiRequest, - owner: owner, - ...initialQuery, - ...requestRef.current.query, - ...query - } - if (dateHistogram) { - dateHistogramInterval = Dates.intervalSeconds( - apiQuery.from_time || Dates.dateHistogramStartDate, - apiQuery.until_time || new Date(), Dates.buckets) - apiQuery['date_histogram'] = true - apiQuery['interval'] = `${dateHistogramInterval}s` - } - api.search(apiQuery) - .then(newResponse => { - setResponse({ - ...emptyResponse, - ...newResponse, - metric: metric, - dateHistogramInterval: dateHistogramInterval, - from_time: apiQuery.from_time, - until_time: apiQuery.until_time - }) - }).catch(error => { - setResponse({...emptyResponse, metric: metric, error: error}) - if (error.status !== 400) { - raiseError(error) - } + } else { + newQuery[newKey] = valueGUI + } + } + return newQuery +} + +/** + * Converts a query into a valid query string. + * @param {object} query A query object representing the currently active + * filters. + * @returns URL querystring, not encoded if possible to improve readability. + */ +function queryToQs(query, locked) { + const newQuery = {} + for (const [key, value] of Object.entries(query)) { + if (locked[key]) { + continue + } + const {formatter} = formatMeta(key, false) + let newValue + const newKey = filterAbbreviations[key] + if (isPlainObject(value)) { + if (!isNil(value.gte)) { + newQuery[`${newKey}:gte`] = formatter(value.gte) + } + if (!isNil(value.lte)) { + newQuery[`${newKey}:lte`] = formatter(value.lte) + } + } else { + if (isArray(value)) { + newValue = value.map(formatter) + } else if (value instanceof Set) { + newValue = [...value].map(formatter) + } else { + newValue = formatter(value) + } + newQuery[newKey] = newValue + } + } + return qs.stringify(newQuery, {indices: false, encode: false}) +} + +export const initialAggsState = atom({ + key: 'initialAggs', + default: undefined +}) + +/** + * Hook for returning an initial aggregation value for a filter. + * + * @returns {array} Array containing the aggregation data. + */ +export function useInitialAgg(name) { + const aggs = useRecoilValue(initialAggsState) + return aggs?.[name] +} + +/** + * Hook for retrieving the most up-to-date aggregation results for a specific + * filter, taking into account the current search context. + * + * @param {string} name The filter name + * @param {bool} restrict If true, the ES query targeting this particular filter + * will be removed. This makes it possible to return all possible values for + * dropdowns etc. + * @param {bool} update Whether the hook needs to react to changes in the + * current query context. E.g. if the component showing the data is not visible, + * this can be set to false. + * + * @returns {array} The data-array returned by the API. + */ +export function useAgg(name, restrict = false, update = true, delay = 500) { + const {api} = useApi() + const { resource } = useSearchContext() + const [results, setResults] = useState(undefined) + const initialAggs = useRecoilValue(initialAggsState) + const query = useQuery() + const firstLoad = useRef(true) + + // Pretty much all of the required pre-processing etc. should be done in this + // function, as it is the final one that gets called after the debounce + // interval. + const apiCall = useCallback((query) => { + // If the restrict option is enabled, the filters targeting the specified + // quantity will be removed. This way all possible options pre-selection can + // be returned. + let queryCleaned = {...query} + if (restrict && query && name in query) { + delete queryCleaned[name] + } + queryCleaned = toAPIQuery(queryCleaned, resource, query.restricted) + const aggRequest = {} + toAPIAgg(aggRequest, name, resource) + const search = { + owner: query.visibility || 'visible', + query: queryCleaned, + aggregations: aggRequest, + pagination: {page_size: 0}, + required: { include: [] } + } + + api.query(resource, search, false) + .then(data => { + data = toGUIAgg(data.aggregations, [name], resource) + firstLoad.current = false + setResults(data[name]) }) - }, [requestRef, setResponse, api, raiseError, emptyResponse, initialQuery, initialRequest, query]) - - // The following are various callbacks that can be used by children to update the - // request and implicitly trigger a search request to the API. The implicit triggering - // is realised that all changes to the request are accompanied by updates to the URL - // which is used to hold the whole request state. Each push to the history will rerender - // everything and therefore trigger effects. - const setRequestParameters = useCallback( - changes => { - requestRef.current.pagination = { - ...requestRef.current.pagination, - ...changes + }, [api, name, restrict, resource]) + + // This is a debounced version of apiCall. + const debounced = useCallback(debounce(apiCall, delay), []) + + // The API call is made immediately on first render. On subsequent renders it + // will be debounced. + useEffect(() => { + if (!update || query === undefined) { + return + } + if (firstLoad.current) { + // Fetch the initial aggregation values if no query + // is specified. + if (isEmpty(query)) { + setResults(initialAggs[name]) + // Make an immediate request for the aggregation values if query has been + // specified. + } else { + apiCall(query) + } + } else { + debounced(query) + } + }, [apiCall, name, debounced, query, update, initialAggs]) + + return results +} + +/** + * Hook for returning a set of results based on the currently set query together + * with a function for retrieving a new set of results. + * + * @param {int} pageSize The number of results to return with one scroll. + * @param {string} orderBy The field used for sorting. + * @param {string} order Ascending or descending order. + * @param {number} delay The debounce delay in milliseconds. + * + * @returns {object} Object containing the search results and a function for + * scrolling to next set of results. + */ +export function useScrollResults(pageSize, orderBy, order, delay = 500) { + const {api} = useApi() + const {resource} = useSearchContext() + const firstRender = useRef(true) + const [results, setResults] = useState() + const pageNumber = useRef(1) + const query = useQuery(true) + const locked = useRecoilValue(lockedState) + const updateQueryString = useUpdateQueryString() + const pageAfterValue = useRef() + const searchRef = useRef() + const loading = useRef(false) + const total = useRef(0) + + // The results are fetched as a side effect in order to not block the + // rendering. This causes two renders: first one without the data, the second + // one with the data. + const apiCall = useCallback((query, locked, pageSize, orderBy, order) => { + pageAfterValue.current = undefined + const restricted = query.restricted + const cleanedQuery = toAPIQuery(query, resource, restricted) + const search = { + owner: query.visibility || 'visible', + query: cleanedQuery, + pagination: { + page_size: pageSize, + order_by: orderBy, + order: order, + page_after_value: pageAfterValue.current } - }, [requestRef]) + } + searchRef.current = search + + loading.current = true + api.query(resource, search) + .then(data => { + pageAfterValue.current = data.pagination.next_page_after_value + total.current = data.pagination.total + setResults(data) + loading.current = false + }) - const setDomain = useCallback(domainKey => { - requestRef.current.domainKey = domainKey || domainData.dft.key - }, [requestRef]) + // We only update the query string after the API call is finished. Updating + // the query string causes quite an intensive render (not sure why), so it + // is better to debounce this value as well to keep the user interaction + // smoother. + updateQueryString(query, locked) + }, [resource, api, updateQueryString]) - const setOwner = useCallback(owner => { - requestRef.current.owner = owner - }, [requestRef]) + // This is a debounced version of apiCall. + const debounced = useCallback(debounce(apiCall, delay), []) - const setMetric = useCallback(metric => { - requestRef.current.metric = metric || domainData.dft.defaultSearchMetric - }, [requestRef]) + // Used to load the next bath of results + const next = useCallback(() => { + if (loading.current) { + return + } + pageNumber.current += 1 + searchRef.current.pagination.page_after_value = pageAfterValue.current + loading.current = true + api.query(resource, searchRef.current) + .then(data => { + pageAfterValue.current = data.pagination.next_page_after_value + total.current = data.pagination.total + setResults(old => { + data.data = old.data.concat(data.data) + return data + }) + loading.current = false + }) + }, [api, resource]) - const setStatistics = useCallback(statistics => { - requestRef.current.statistics = [...statistics, ...defaultStatistics].filter(onlyUnique) - // eslint-disable-next-line - }, [requestRef]) + // Whenever the query changes, we make a new query that resets pagination and + // shows the first batch of results. + useEffect(() => { + // If the initial query is not yet ready, do nothing + if (query === undefined) { + return + } + if (firstRender.current) { + apiCall(query, locked, pageSize, orderBy, order) + firstRender.current = false + } else { + debounced(query, locked, pageSize, orderBy, order) + } + }, [apiCall, debounced, query, locked, pageSize, order, orderBy]) - const setGroups = useCallback(groups => { - requestRef.current.groups = {...groups} - }, [requestRef]) + // Whenever the ordering changes, we perform a single API call that fetches + // results in the new order. The amount of fetched results is based on the + // already loaded amount. + // TODO + return { + results: results, + next: next, + page: pageNumber.current, + total: total.current + } +} - const setDateHistogram = useCallback(dateHistogram => { - requestRef.current.dateHistogram = dateHistogram - }, [requestRef]) +/** + * Converts the contents a query into a format that is suitable for the API. + * + * Should only be called when making the final API call, as during the + * construction of the query it is much more convenient to store filters within + * e.g. Sets. + * + * @param {number} query The query object + * @param {bool} exclusive The chemical element search mode. + * + * @returns {object} A copy of the object with certain items cleaned into a + * format that is supported by the API. + */ +export function toAPIQuery(query, resource, restricted) { + // Perform custom transformations + let queryCustomized = {} + for (let [k, v] of Object.entries(query)) { + const setter = filterData[k]?.valueSet + if (setter) { + setter(queryCustomized, query, v) + } else { + queryCustomized[k] = v + } + } - const handleQueryChange = (changes, replace) => { - if (changes.atoms && changes.atoms.length === 0) { - changes.atoms = undefined + let queryNormalized = {} + for (const [k, v] of Object.entries(queryCustomized)) { + // Transform sets into lists and Quantities into SI values and modify keys + // according to target resource (entries/materials). + let newValue + if (isPlainObject(v)) { + newValue = {} + if (!isNil(v.lte)) { + newValue.lte = toAPIQueryValue(v.lte) + } + if (!isNil(v.gte)) { + newValue.gte = toAPIQueryValue(v.gte) + } + } else { + newValue = toAPIQueryValue(v) } - if (changes.only_atoms && changes.only_atoms.length === 0) { - changes.only_atoms = undefined + + // The postfixes are added here. By default query items with array values + // get the 'any'-postfix. + let postfix + if (isArray(newValue)) { + const fieldPostfixMap = { + 'results.properties.available_properties': 'all', + 'results.material.elements': 'all' + } + postfix = fieldPostfixMap[k] || 'any' } - if (replace) { - setUrlQuery(changes) + // For material query the keys are remapped. + let newKey = resource === 'materials' ? materialNames[k] : k + newKey = postfix ? `${newKey}:${postfix}` : newKey + queryNormalized[newKey] = newValue + } + + if (resource === 'materials') { + // In restricted search we simply move all method/properties filters + // inside a single entries-subsection. + if (restricted) { + const entrySearch = {} + for (const [k, v] of Object.entries(queryNormalized)) { + if (k.startsWith('entries.')) { + const name = k.split('entries.').pop() + entrySearch[name] = v + delete queryNormalized[k] + } + } + if (!isEmpty(entrySearch)) { + queryNormalized.entries = entrySearch + } + // In unrestricted search we have to split each filter and each filter value + // into it's own separate entries query. These queries are then joined with + // 'and'. } else { - setUrlQuery({...urlQuery, ...changes}) + const entrySearch = [] + for (const [k, v] of Object.entries(queryNormalized)) { + if (k.startsWith('entries.')) { + const newKey = k.split(':')[0] + if (isArray(v)) { + for (const item of v) { + entrySearch.push({[newKey]: item}) + } + } else { + entrySearch.push({[newKey]: v}) + } + delete queryNormalized[k] + } + } + if (entrySearch.length > 0) { + queryNormalized.and = entrySearch + } } } - // We check and run (if necessary) the search request after each render - useEffect(() => { - if (lastRequestHashRef.current !== hash(requestRef.current)) { - runRequest() - lastRequestHashRef.current = hash(requestRef.current) + return queryNormalized +} + +/** + * Cleans a filter value into a form that is supported by the API. This includes: + * - Sets are transformed into Arrays + * - Quantities are converted to SI values. + * + * @returns {any} The filter value in a format that is suitable for the API. + */ +function toAPIQueryValue(value) { + let newValue + if (value instanceof Set) { + newValue = setToArray(value) + if (newValue.length === 0) { + newValue = undefined + } else { + newValue = newValue.map((item) => item instanceof Quantity ? item.toSI() : item) + } + } else if (value instanceof Quantity) { + newValue = value.toSI() + } else if (isArray(value)) { + if (value.length === 0) { + newValue = undefined + } else { + newValue = value.map((item) => item instanceof Quantity ? item.toSI() : item) } - }) + } else { + newValue = value + } + return newValue +} - const value = { - response: response, - query: { - ...requestRef.current.query - }, - apiQuery: { - domain: requestRef.current.domainKey, - owner: requestRef.current.owner, - ...requestRef.current.query, - ...query - }, - domain: domainData[requestRef.current.domainKey], - metric: requestRef.current.metric, - requestParameters: requestRef.current.pagination, - setRequestParameters: setRequestParameters, - setQuery: handleQueryChange, - setMetric: setMetric, - setGroups: setGroups, - setDomain: setDomain, - setOwner: setOwner, - setStatistics: setStatistics, - setDateHistogram: setDateHistogram, - update: runRequest +/** + * Cleans a filter value into a form that is supported by the GUI. This includes: + * - Arrays are are transformed into Sets + * - If multiple values are supported, scalar values are stored inside sets. + * - Numerical values with units are transformed into Quantities. + * + * @returns {any} The filter value in a format that is suitable for the GUI. + */ +export function toGUIFilter(name, value, units = undefined) { + let multiple = filterData[name].multiple + let newValue + const {parser} = parseMeta(name) + if (isArray(value)) { + newValue = new Set(value.map((v) => parser(v, units))) + } else if (isPlainObject(value)) { + newValue = {} + if (!isNil(value.gte)) { + newValue.gte = parser(value.gte, units) + } + if (!isNil(value.lte)) { + newValue.lte = parser(value.lte, units) + } + } else { + newValue = parser(value, units) + if (multiple) { + newValue = new Set([newValue]) + } } + return newValue +} - return <searchContext.Provider value={value} >{children}</searchContext.Provider> +/** + * Used to transform a GUI aggregation query into a form that is usable by the + * API. + * + * @param {object} aggs The aggregation data in which the modifications are + * made. + * @param {string} filter The filter name + * @param {string} resource The resource we are looking at: entries or materials. + */ +function toAPIAgg(aggs, filter, resource) { + const aggSet = filterData[filter].aggSet + if (aggSet) { + for (const [key, type] of Object.entries(aggSet)) { + const name = resource === 'materials' ? materialNames[key.split(':')[0]] : key + const agg = aggs[name] || {} + agg[type] = { + quantity: name, + size: 500 + } + aggs[name] = agg + } + } } -SearchContext.propTypes = { - /** - * An object with initial query parameters. These will be added to the search context - * and be used in all search requests. - */ - query: PropTypes.object, - /** - * An object with initial query parameters. These will be added to the search context - * and the first search request. Afterwards search parameters might be removed or - * overwritten by the search. - */ - initialQuery: PropTypes.object, - /** - * An object with initial request parameters. These will be added to the search context - * and the first search request. Afterwards request parameters might be removed or - * overwritten by children components. - */ - initialRequest: PropTypes.object, - /** - * The children prop. All components in the children can make use of this search - * context via useContext. - */ - children: PropTypes.any + +/** + * Used to transform an API aggregation query into a form that is usable by the + * GUI. + * + * @param {object} aggs The aggregation data as returned by the API. + * @param {array} filters The filters to take into account. + * @param {string} resource The resource we are looking at: entries or materials. + * + * @returns {object} Aggregation data that is usable by the GUI. + */ +function toGUIAgg(aggs, filters, resource) { + if (isEmpty(aggs)) { + return aggs + } + // Modify keys according to target resource (entries/materials). + let aggsNormalized + if (resource === 'materials') { + aggsNormalized = {} + for (const key of Object.keys(aggs)) { + const name = resource === 'materials' ? entryNames[key] : key + aggs[key].quantity = name + aggsNormalized[name] = aggs[key] + } + } else { + aggsNormalized = aggs + } + + // Perform custom transformations + const aggsCustomized = {} + for (const name of filters) { + const aggGet = filterData[name].aggGet + if (aggGet) { + let agg + agg = aggGet(aggsNormalized) + aggsCustomized[name] = agg + } + } + return aggsCustomized } diff --git a/gui/src/components/search/SearchPage.js b/gui/src/components/search/SearchPage.js deleted file mode 100644 index 8c037f2993..0000000000 --- a/gui/src/components/search/SearchPage.js +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright The NOMAD Authors. - * - * This file is part of NOMAD. See https://nomad-lab.eu for further info. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, { useContext } from 'react' -import { apiContext } from '../api' -import Search from './Search' -import { domainData } from '../domainData' -import { encyclopediaEnabled } from '../../config' - -const help = ` -This page allows you to **search** in NOMAD's data. The upper part of this page -gives you various options to enter and configure your search. The lower part -shows all data that fulfills your search criteria. - -NOMAD's *domain-aware* search allows you to screen data by filtering based on -desired properties. This is different from basic *text-search* that traditional -search engines offer. - -The search bar allows you to specify various quantity values that you want to -see in your results. This includes *authors*, *comments*, *atom labels*, *code name*, -*system type*, *crystal system*, *basis set types*, and *XC functionals*. - -Alternatively, you can click *elements* or *metadata* to get a visual representation of -NOMAD's data as a periodic table or metadata charts. You can click the various -visualization elements to filter for respective quantities. - -The visual representations show metrics for all data that fit your criteria. -You can display *entries* (i.e. code runs), *unique entries*, and *datasets*. -Other more specific metrics might be available. - -Some quantities have no autocompletion for their values. You can still search for them, -if you know exactly what you are looking for. To search for a particular entry by its id -for example, type \`calc_id=<the_id>\` and press entry (or select the respective item from the menu). -The usable *hidden* quantities are: ${Object.keys(domainData.dft.additionalSearchKeys).map(key => `\`${key}\``).join(', ')}. - -The results tabs gives you a quick overview of all entries and datasets that fit your search. -You can click entries to see more details, download data, see the archive, etc. The *entries* -tab displays individual entries (i.e. code runs), the *grouped entries* tab will group -entries with similar metadata (it will group entries for the same material from the - same user). The *dataset* tab, shows entry curated by user created datasets. You can - click on datasets for a search page that will only display entries from the respective - dataset. - -The table columns can be configured. The *entries* tab also supports sorting. Selected -entries (or all entries) can be downloaded. The download will contain all user provided -raw calculation input and output files. - -You can click entries to see more details about them. The details button will navigate -you to an entry's page. This entry page will show more metadata, raw files, the -entry's archive, and processing logs. -` -export {help} - -export default function SearchPage() { - const {user} = useContext(apiContext) - const withoutLogin = ['all', 'public'] - - return <Search - initialVisualizationTab="elements" - availableResultTabs={['entries', ...(encyclopediaEnabled ? ['materials'] : []), 'groups', 'datasets']} - initialOwner="public" - ownerTypes={['public', 'visible'].filter(key => user || withoutLogin.indexOf(key) !== -1)} - showDisclaimer - /> -} diff --git a/gui/src/components/search/SearchPageEntries.js b/gui/src/components/search/SearchPageEntries.js index 58644f1f2b..f681036ab1 100644 --- a/gui/src/components/search/SearchPageEntries.js +++ b/gui/src/components/search/SearchPageEntries.js @@ -16,8 +16,8 @@ * limitations under the License. */ import React from 'react' -import NewSearch from './NewSearch' -import { SearchContext } from './FilterContext' +import Search from './Search' +import { SearchContext } from './SearchContext' export const help = ` This page allows you to **search** in NOMAD's data. NOMAD's *domain-aware* @@ -53,7 +53,7 @@ will show more metadata, raw files, the entry's archive, and processing logs. const SearchPageEntries = React.memo(() => { return <SearchContext resource="entries"> - <NewSearch/> + <Search/> </SearchContext> }) diff --git a/gui/src/components/search/SearchPageMaterials.js b/gui/src/components/search/SearchPageMaterials.js index 2fe3df17a8..df5c46c7e3 100644 --- a/gui/src/components/search/SearchPageMaterials.js +++ b/gui/src/components/search/SearchPageMaterials.js @@ -16,8 +16,8 @@ * limitations under the License. */ import React from 'react' -import NewSearch from './NewSearch' -import { SearchContext } from './FilterContext' +import Search from './Search' +import { SearchContext } from './SearchContext' export const help = ` This page allows you to **search** in NOMAD's data. NOMAD's *domain-aware* @@ -53,7 +53,7 @@ will show more metadata, raw files, the entry's archive, and processing logs. const SearchPageMaterials = React.memo(() => { return <SearchContext resource="materials"> - <NewSearch/> + <Search/> </SearchContext> }) diff --git a/gui/src/components/search/UploadsHistogram.js b/gui/src/components/search/UploadsHistogram.js deleted file mode 100644 index a732636562..0000000000 --- a/gui/src/components/search/UploadsHistogram.js +++ /dev/null @@ -1,300 +0,0 @@ -/* - * Copyright The NOMAD Authors. - * - * This file is part of NOMAD. See https://nomad-lab.eu for further info. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, { useContext, useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react' -import PropTypes from 'prop-types' -import { Select, MenuItem, Card, CardHeader, CardContent, makeStyles } from '@material-ui/core' -import Grid from '@material-ui/core/Grid' -import TextField from '@material-ui/core/TextField' -import * as d3 from 'd3' -import { scaleTime, scalePow } from 'd3-scale' -import { nomadSecondaryColor, nomadTheme } from '../../config.js' -import { searchContext, Dates } from './SearchContext' - -const useStyles = makeStyles(theme => ({ - root: { - marginTop: theme.spacing(2) - }, - header: { - paddingBottom: 0 - }, - content: { - paddingTop: 0, - position: 'relative', - height: 250 - }, - tooltip: { - textAlign: 'center', - position: 'absolute', - pointerEvents: 'none', - opacity: 0 - }, - tooltipContent: { - // copy of the material ui popper style - display: 'inline-block', - color: '#fff', - padding: '4px 8px', - fontSize: nomadTheme.overrides.MuiTooltip.tooltip.fontSize, - fontWeight: nomadTheme.overrides.MuiTooltip.tooltip.fontWeight, - fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', - lineHeight: '1.4em', - borderRadius: '4px', - backgroundColor: '#616161' - } -})) - -export default function UploadsHistogram({title = 'Uploads over time', initialScale = 1, tooltips}) { - const classes = useStyles() - const containerRef = useRef() - const fromTimeFieldRef = useRef() - const untilTimeFieldRef = useRef() - const [scale, setScale] = useState(initialScale) - const {response, query, setQuery, domain, setDateHistogram} = useContext(searchContext) - - useEffect(() => { - setDateHistogram(true) - return () => { - setDateHistogram(false) - } - }, [setDateHistogram]) - - useLayoutEffect(() => { - fromTimeFieldRef.current.value = Dates.FormDate(query.from_time || Dates.dateHistogramStartDate) - untilTimeFieldRef.current.value = Dates.FormDate(query.until_time || new Date()) - }) - - useEffect(() => { - const {statistics, metric} = response - - let data = [] - if (!statistics.date_histogram) { - return - } else { - data = Object.keys(statistics.date_histogram).map(key => ({ - time: Dates.JSDate(parseInt(key)), - value: statistics.date_histogram[key][metric] - })).filter(d => d.value) - } - - const fromTime = Dates.JSDate(response.from_time || Dates.dateHistogramStartDate) - const untilTime = Dates.JSDate(response.until_time || new Date()) - const interval = response.dateHistogramInterval - const clickable = (interval * Dates.buckets) > 3600 - - const handleItemClicked = item => { - if (!clickable) { - return - } - const fromTime = item.time - const untilTime = Dates.addSeconds(fromTime, interval) - setQuery({ - from_time: Dates.APIDate(fromTime), - until_time: Dates.APIDate(untilTime) - }) - } - - const width = containerRef.current.offsetWidth - const height = 250 - const marginRight = 32 - const marginTop = 16 - const marginBottom = 17 // 16 misses a pixel in safari - - const y = scalePow().range([height - marginBottom, marginTop]).exponent(scale) - const max = d3.max(data, d => d.value) || 0 - y.domain([0, max]) - - const x = scaleTime() - .domain([Dates.addSeconds(fromTime, -interval), Dates.addSeconds(untilTime, interval)]) - .rangeRound([marginRight, width]) - - const container = d3.select(containerRef.current) - const tooltip = container.select('.' + classes.tooltip) - .style('opacity', 0) - const tooltipContent = container.select('.' + classes.tooltipContent) - const svg = container.select('svg') - .attr('width', width) - .attr('height', height) - - const xAxis = d3.axisBottom(x) - svg.select('.xaxis').remove() - svg.append('g') - .attr('transform', `translate(0,${height - marginBottom})`) - .attr('class', 'xaxis') - .call(xAxis) - - svg.select('.xlabel').remove() - svg.append('text') - .attr('class', 'xlabel') - .attr('x', width) - .attr('y', height - 4) - .attr('dy', '.35em') - .attr('font-size', '12px') - .style('text-anchor', 'end') - - const yAxis = d3.axisLeft(y).ticks(Math.min(max, 5), '.0s') - svg.select('.yaxis').remove() - svg.append('g') - .attr('transform', `translate(${marginRight}, 0)`) - .attr('class', 'yaxis') - .call(yAxis) - - const {label, shortLabel} = domain.searchMetrics[metric] - - let withData = svg - .selectAll('.bar').remove().exit() - .data(data) - - let item = withData.enter() - .append('g') - - item - .append('rect') - .attr('x', d => x(d.time) + 1) - .attr('y', y(max)) - .attr('width', d => x(Dates.addSeconds(d.time, interval)) - x(d.time) - 2) - .attr('class', 'background') - .style('opacity', 0) - .attr('height', y(0) - y(max)) - - item - .append('rect') - .attr('class', 'bar') - .attr('x', d => x(d.time) + 1) - .attr('y', d => y(d.value)) - .attr('width', d => x(Dates.addSeconds(d.time, interval)) - x(d.time) - 2) - .attr('height', d => y(0) - y(d.value)) - .style('fill', nomadSecondaryColor.light) - - if (clickable) { - item - .style('cursor', 'pointer') - .on('click', handleItemClicked) - } - - item - .on('mouseover', function(d) { - d3.select(this).select('.background') - .style('opacity', 0.08) - if (tooltips) { - tooltip.transition() - .duration(200) - .style('opacity', 1) - tooltip - .style('left', x(d.time) + 'px') - .style('bottom', '24px') - tooltipContent.html( - `${d.time.toLocaleDateString()}-${Dates.addSeconds(d.time, interval).toLocaleDateString()} with ${d.value.toLocaleString()} ${shortLabel || label}`) - } - }) - .on('mouseout', function(d) { - d3.select(this).select('.background') - .style('opacity', 0) - if (tooltips) { - tooltip.transition() - .duration(200) - .style('opacity', 0) - } - }) - }) - - const handleDatePickerChange = useCallback((event, key) => { - try { - const date = new Date(event.target.value).getTime() - if (date < Dates.JSDate(Dates.dateHistogramStartDate).getTime()) { - return - } - if (date > new Date().getTime()) { - return - } - const value = Dates.APIDate(new Date(event.target.value)) - setQuery({[key]: value}) - } catch (error) { - } - }, [setQuery]) - - return <Card classes={{root: classes.root}}> - <CardHeader - classes={{root: classes.header}} - title={title} - titleTypographyProps={{variant: 'body1'}} - action={( - <Grid container alignItems='flex-end' style={{flexWrap: 'nowrap'}} spacing={2}> - <Grid item> - <TextField - inputRef={fromTimeFieldRef} - label="from time" - type="date" - defaultValue={Dates.FormDate(query.from_time || Dates.dateHistogramStartDate)} - onChange={event => handleDatePickerChange(event, 'from_time')} - InputLabelProps={{ - shrink: true - }} - /> - </Grid> - <Grid item> - <TextField - inputRef={untilTimeFieldRef} - label="until time" - type="date" - defaultValue={Dates.FormDate(query.until_time || new Date())} - onChange={event => handleDatePickerChange(event, 'until_time')} - InputLabelProps={{ - shrink: true - }} - /> - </Grid> - <Grid item> - <Select - value={scale} - onChange={(event) => setScale(event.target.value)} - displayEmpty - name="scale power" - > - <MenuItem value={1}>linear</MenuItem> - <MenuItem value={0.5}>1/2</MenuItem> - <MenuItem value={0.25}>1/4</MenuItem> - <MenuItem value={0.125}>1/8</MenuItem> - </Select> - </Grid> - </Grid> - )} - /> - <CardContent classes={{root: classes.content}}> - <div ref={containerRef}> - <div className={classes.tooltip}> - <div className={classes.tooltipContent}></div> - </div> - <svg /> - </div> - </CardContent> - </Card> -} -UploadsHistogram.propTypes = { - /** - * An optional title for the chart. If no title is given, the quantity is used. - */ - title: PropTypes.string, - /** - * An optional scale power that is used as the initial scale before the user - * changes it. Default is 1 (linear scale). - */ - initialScale: PropTypes.number, - /** - * Set to true to enable tooltips for each value. - */ - tooltips: PropTypes.bool -} diff --git a/gui/src/components/search/input/InputCheckbox.js b/gui/src/components/search/input/InputCheckbox.js index 17eb287b5c..08d38ecd32 100644 --- a/gui/src/components/search/input/InputCheckbox.js +++ b/gui/src/components/search/input/InputCheckbox.js @@ -26,7 +26,7 @@ import { import PropTypes from 'prop-types' import clsx from 'clsx' import searchQuantities from '../../../searchQuantities' -import { useFilterState, useFilterLocked } from '../FilterContext' +import { useFilterState, useFilterLocked } from '../SearchContext' const useStyles = makeStyles(theme => ({ root: { diff --git a/gui/src/components/search/input/InputCheckboxes.js b/gui/src/components/search/input/InputCheckboxes.js index 7d66044ff5..1bde83bd5c 100644 --- a/gui/src/components/search/input/InputCheckboxes.js +++ b/gui/src/components/search/input/InputCheckboxes.js @@ -32,7 +32,7 @@ import { useAgg, useInitialAgg, useFilterLocked -} from '../FilterContext' +} from '../SearchContext' import { isArray } from 'lodash' const useStyles = makeStyles(theme => ({ diff --git a/gui/src/components/search/input/InputDateRange.js b/gui/src/components/search/input/InputDateRange.js index ecd09a5f3f..b8854b62b4 100644 --- a/gui/src/components/search/input/InputDateRange.js +++ b/gui/src/components/search/input/InputDateRange.js @@ -27,7 +27,7 @@ import { isNil } from 'lodash' import searchQuantities from '../../../searchQuantities' import InputLabel from './InputLabel' import InputTooltip from './InputTooltip' -import { useAgg, useFilterState, useFilterLocked } from '../FilterContext' +import { useAgg, useFilterState, useFilterLocked } from '../SearchContext' import { getTime } from 'date-fns' import { dateFormat } from '../../../config' diff --git a/gui/src/components/search/input/InputPeriodicTable.js b/gui/src/components/search/input/InputPeriodicTable.js index 42fb63e028..dbab24a31f 100644 --- a/gui/src/components/search/input/InputPeriodicTable.js +++ b/gui/src/components/search/input/InputPeriodicTable.js @@ -17,7 +17,7 @@ */ import React, { useCallback } from 'react' import PropTypes from 'prop-types' -import periodicTableData from './PeriodicTableData' +import elementData from '../../../elementData' import { Typography, Button, @@ -30,7 +30,7 @@ const elements = [] for (var i = 0; i < 10; i++) { elements[i] = Array.apply(null, Array(18)) } -periodicTableData.elements.forEach(element => { +elementData.elements.forEach(element => { elements[element.ypos - 1][element.xpos - 1] = element element.category = element.category.replace(' ', '') }) diff --git a/gui/src/components/search/input/InputRadio.js b/gui/src/components/search/input/InputRadio.js index 62f31450f4..cc80c2354f 100644 --- a/gui/src/components/search/input/InputRadio.js +++ b/gui/src/components/search/input/InputRadio.js @@ -28,7 +28,7 @@ import PropTypes from 'prop-types' import clsx from 'clsx' import InputLabel from './InputLabel' import searchQuantities from '../../../searchQuantities' -import { useFilterState, useFilterLocked } from '../FilterContext' +import { useFilterState, useFilterLocked } from '../SearchContext' const useStyles = makeStyles(theme => ({ root: { diff --git a/gui/src/components/search/input/InputSelect.js b/gui/src/components/search/input/InputSelect.js index 7d4904cac0..bff291adc6 100644 --- a/gui/src/components/search/input/InputSelect.js +++ b/gui/src/components/search/input/InputSelect.js @@ -31,7 +31,7 @@ import FilterChip from '../FilterChip' import searchQuantities from '../../../searchQuantities' import InputLabel from './InputLabel' import InputTooltip from './InputTooltip' -import { useFilterState, useFilterLocked, useAgg } from '../FilterContext' +import { useFilterState, useFilterLocked, useAgg } from '../SearchContext' // This forces the menu to have a fixed anchor instead of jumping around const MenuProps = { diff --git a/gui/src/components/search/input/InputSlider.js b/gui/src/components/search/input/InputSlider.js index ca9ad855ad..ac9532082e 100644 --- a/gui/src/components/search/input/InputSlider.js +++ b/gui/src/components/search/input/InputSlider.js @@ -31,7 +31,7 @@ import InputTooltip from './InputTooltip' import { Quantity, Unit, toUnitSystem, toSI } from '../../../units' import { formatNumber } from '../../../utils' import searchQuantities from '../../../searchQuantities' -import { useFilterState, useFilterLocked, useAgg } from '../FilterContext' +import { useFilterState, useFilterLocked, useAgg } from '../SearchContext' function format(value) { return formatNumber(value, 'float', 6, true) diff --git a/gui/src/components/search/input/InputText.js b/gui/src/components/search/input/InputText.js index 4e13b3047c..5c2b5a6b60 100644 --- a/gui/src/components/search/input/InputText.js +++ b/gui/src/components/search/input/InputText.js @@ -33,7 +33,7 @@ import { useApi } from '../../apiV1' import searchQuantities from '../../../searchQuantities' import InputLabel from './InputLabel' import InputTooltip from './InputTooltip' -import { useSetFilter, useFilterLocked } from '../FilterContext' +import { useSetFilter, useFilterLocked } from '../SearchContext' const useStyles = makeStyles(theme => ({ root: { diff --git a/gui/src/components/search/input/PeriodicTable.js b/gui/src/components/search/input/PeriodicTable.js deleted file mode 100644 index 127b2d82a7..0000000000 --- a/gui/src/components/search/input/PeriodicTable.js +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright The NOMAD Authors. - * - * This file is part of NOMAD. See https://nomad-lab.eu for further info. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react' -import PropTypes from 'prop-types' -import periodicTableData from './PeriodicTableData' -import { withStyles, Typography, Button, Tooltip, FormControlLabel, Checkbox } from '@material-ui/core' -import chroma from 'chroma-js' -import { nomadSecondaryColor } from '../../../config.js' - -const elements = [] -for (var i = 0; i < 10; i++) { - elements[i] = Array.apply(null, Array(18)) -} -periodicTableData.elements.forEach(element => { - elements[element.ypos - 1][element.xpos - 1] = element - element.category = element.category.replace(' ', '') -}) - -class ElementUnstyled extends React.Component { - static propTypes = { - classes: PropTypes.object.isRequired, - element: PropTypes.object.isRequired, - onClick: PropTypes.func, - selected: PropTypes.bool, - count: PropTypes.number.isRequired, - heatmapScale: PropTypes.func.isRequired - } - - static styles = theme => ({ - root: { - position: 'relative' - }, - button: { - border: '1px solid', - paddingTop: theme.spacing(1), - paddingBottom: theme.spacing(1), - paddingLeft: 0, - paddingRight: 0, - width: '100%', - textAlign: 'center', - fontSize: '1rem', - fontWeight: 700, - textTransform: 'none', - minWidth: 0, - minHeight: 0, - borderRadius: 0, - boxShadow: 'none' - }, - containedPrimary: { - backgroundColor: theme.palette.primary.dark, - color: 'white' - }, - number: { - position: 'absolute', - top: 2, - left: 2, - margin: 0, - padding: 0, - fontSize: 8, - pointerEvents: 'none' - }, - count: { - position: 'absolute', - bottom: 2, - right: 2, - margin: 0, - padding: 0, - fontSize: 8, - pointerEvents: 'none' - } - }) - - render() { - const {classes, element, selected, count, heatmapScale} = this.props - const buttonClasses = { - root: classes.button, - containedPrimary: classes.containedPrimary - } - const disabled = count <= 0 - - const style = (count > 0) ? { - backgroundColor: !selected ? heatmapScale(count).hex() : undefined, - borderColor: '#555' - } : undefined - - return ( - <div className={classes.root}> - <Tooltip title={element.name}> - <div> - <Button - disabled={disabled} - classes={buttonClasses} - style={style} - onClick={this.props.onClick} variant="contained" - color={selected ? 'primary' : 'default'} - > - {element.symbol} - </Button> - </div> - </Tooltip> - <Typography - classes={{root: classes.number}} variant="caption" - style={selected ? {color: 'white'} : disabled ? {color: '#BDBDBD'} : {}}> - {element.number} - </Typography> - {count >= 0 - ? <Typography - classes={{root: classes.count}} variant="caption" - style={selected ? {color: 'white'} : disabled ? {color: '#BDBDBD'} : {}}> - {count.toLocaleString()} - </Typography> : '' - } - </div> - ) - } -} - -const Element = withStyles(ElementUnstyled.styles)(ElementUnstyled) - -class PeriodicTable extends React.Component { - static propTypes = { - classes: PropTypes.object.isRequired, - aggregations: PropTypes.object, - metric: PropTypes.string.isRequired, - values: PropTypes.array.isRequired, - onChanged: PropTypes.func.isRequired, - exclusive: PropTypes.bool, - onExclusiveChanged: PropTypes.func.isRequired - } - - static styles = theme => ({ - root: { - position: 'relative' - }, - table: { - margin: 'auto', - width: '100%', - minWidth: 500, - maxWidth: 900, - tableLayout: 'fixed', - borderSpacing: theme.spacing(0.5) - }, - formContainer: { - position: 'absolute', - top: theme.spacing(0), - left: '10%', - textAlign: 'center' - } - }) - - onElementClicked(element) { - const index = this.props.values.indexOf(element) - const isClicked = index >= 0 - let selected - if (isClicked) { - selected = [...this.props.values] - selected.splice(index, 1) - } else { - selected = [element, ...this.props.values] - } - - this.props.onChanged(selected) - } - - unSelectedAggregations() { - const { aggregations, metric, values } = this.props - return Object.keys(aggregations) - .filter(key => values.indexOf(key) === -1) - .map(key => aggregations[key][metric]) - } - - render() { - const {classes, aggregations, metric, values, exclusive, onExclusiveChanged} = this.props - const max = aggregations ? Math.max(...this.unSelectedAggregations()) || 1 : 1 - const heatmapScale = chroma.scale([nomadSecondaryColor.veryLight, nomadSecondaryColor.main]).domain([1, max], 10, 'log') - return ( - <div className={classes.root}> - <table className={classes.table}> - <tbody> - {elements.map((row, i) => ( - <tr key={i}> - {row.map((element, j) => ( - <td key={j}> - {element - ? <Element - element={element} - count={aggregations ? (aggregations[element.symbol] || {})[metric] || 0 : 0} - heatmapScale={heatmapScale} - relativeCount={aggregations ? ((aggregations[element.symbol] || {})[metric] || 0) / max : 0} - onClick={() => this.onElementClicked(element.symbol)} - selected={values.indexOf(element.symbol) >= 0} - /> : ''} - </td> - ))} - </tr> - ))} - </tbody> - </table> - <div className={classes.formContainer}> - <Tooltip title={ - 'Search for entries with compositions that only (exclusively) contain the ' + - 'selected atoms. The default is to return all entries that have at least ' + - '(inclusively) the selected atoms.'}> - <FormControlLabel - control={<Checkbox checked={exclusive} onChange={onExclusiveChanged} />} - label={'only composition that exclusively contain these atoms'} - /> - </Tooltip> - </div> - </div> - ) - } -} - -export default withStyles(PeriodicTable.styles)(PeriodicTable) diff --git a/gui/src/components/search/menus/FilterMainMenu.js b/gui/src/components/search/menus/FilterMainMenu.js index c1b8559fab..302bb070e4 100644 --- a/gui/src/components/search/menus/FilterMainMenu.js +++ b/gui/src/components/search/menus/FilterMainMenu.js @@ -53,7 +53,7 @@ import { labelIDs, labelAccess, useSearchContext -} from '../FilterContext' +} from '../SearchContext' import InputCheckbox from '../input/InputCheckbox' /** diff --git a/gui/src/components/search/menus/FilterMenu.js b/gui/src/components/search/menus/FilterMenu.js index fc4add3492..5b0bf5d048 100644 --- a/gui/src/components/search/menus/FilterMenu.js +++ b/gui/src/components/search/menus/FilterMenu.js @@ -35,7 +35,7 @@ import ClearIcon from '@material-ui/icons/Clear' import Scrollable from '../../visualization/Scrollable' import FilterSummary from '../FilterSummary' import { Actions, Action } from '../../Actions' -import { filterGroups, useResetFilters } from '../FilterContext' +import { filterGroups, useResetFilters } from '../SearchContext' // The menu animations use a transition on the 'transform' property. Notice that // animating 'transform' instead of e.g. the 'left' property is much more diff --git a/gui/src/components/search/menus/FilterSubMenuElements.js b/gui/src/components/search/menus/FilterSubMenuElements.js index a2fcebe85a..31e5705284 100644 --- a/gui/src/components/search/menus/FilterSubMenuElements.js +++ b/gui/src/components/search/menus/FilterSubMenuElements.js @@ -26,7 +26,7 @@ import InputSlider from '../input/InputSlider' import { useFilterState, useAgg -} from '../FilterContext' +} from '../SearchContext' import { useUnits } from '../../../units' const useStyles = makeStyles(theme => ({ diff --git a/gui/src/components/search/results/GroupList.js b/gui/src/components/search/results/GroupList.js deleted file mode 100644 index 4f0b0c9091..0000000000 --- a/gui/src/components/search/results/GroupList.js +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Copyright The NOMAD Authors. - * - * This file is part of NOMAD. See https://nomad-lab.eu for further info. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react' -import PropTypes from 'prop-types' -import { withStyles, TableCell, Toolbar, IconButton, Table, TableHead, TableRow, TableBody, Tooltip } from '@material-ui/core' -import { compose } from 'recompose' -import { withRouter } from 'react-router' -import NextIcon from '@material-ui/icons/ChevronRight' -import StartIcon from '@material-ui/icons/SkipPrevious' -import DataTable from '../../DataTable' -import { withApi } from '../../api' -import { EntryListUnstyled } from '../EntryList' -import MoreIcon from '@material-ui/icons/MoreHoriz' -import DownloadButton from '../../DownloadButton' -import { searchContext } from '../SearchContext' - -class GroupUnstyled extends React.Component { - static contextType = searchContext - - static propTypes = { - classes: PropTypes.object.isRequired, - groupHash: PropTypes.string.isRequired, - api: PropTypes.object.isRequired, - raiseError: PropTypes.func.isRequired, - history: PropTypes.object.isRequired - } - - static styles = theme => ({ - root: { - padding: theme.spacing(3) - } - }) - - state = { - entries: [] - } - - update() { - const {groupHash, api, raiseError} = this.props - const {query} = this.context - api.search({...query, 'dft.group_hash': groupHash, per_page: 100}) - .then(data => { - this.setState({entries: data.results}) - }) - .catch(raiseError) - } - - componentDidMount() { - this.update() - } - - componentDidUpdate(prevProps) { - if (prevProps.groupHash !== this.props.groupHash || prevProps.api !== this.props.api) { - this.update() - } - } - - render() { - const {history} = this.props - const {entries} = this.state - return ( - <Table> - <TableHead> - <TableRow> - <TableCell>Mainfile</TableCell> - <TableCell>Upload time</TableCell> - <TableCell></TableCell> - </TableRow> - </TableHead> - <TableBody> - {entries.map(entry => ( - <TableRow key={entry.calc_id}> - <TableCell>{entry.mainfile}</TableCell> - <TableCell>{new Date(entry.upload_time).toLocaleString()}</TableCell> - <TableCell align="right"> - <DownloadButton query={{calc_id: entry.calc_id}} tooltip="Download files of this entry" /> - <Tooltip title="Show raw files and archive"> - <IconButton onClick={() => history.push(`/entry/id/${entry.upload_id}/${entry.calc_id}`)}> - <MoreIcon /> - </IconButton> - </Tooltip> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) - } -} - -const Group = compose(withRouter, withApi(false), withStyles(GroupUnstyled.styles))(GroupUnstyled) - -class GroupListUnstyled extends React.Component { - static propTypes = { - classes: PropTypes.object.isRequired, - data: PropTypes.object, - total: PropTypes.number, - onChange: PropTypes.func.isRequired, - history: PropTypes.any.isRequired, - groups_after: PropTypes.string, - actions: PropTypes.element, - domain: PropTypes.object.isRequired, - selectedColumns: PropTypes.arrayOf(PropTypes.string) - } - - static styles = theme => ({ - root: { - overflow: 'auto', - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2) - }, - scrollCell: { - padding: 0 - }, - scrollBar: { - minHeight: 56, - padding: 0 - }, - scrollSpacer: { - flexGrow: 1 - }, - clickableRow: { - cursor: 'pointer' - }, - details: { - padding: 0 - } - }) - - addColumns(columns) { - Object.keys(columns).forEach(key => { - const column = columns[key] - this.columns[key] = { - ...column, - supportsSort: false - } - }) - } - - constructor(props) { - super(props) - this.renderEntryActions = this.renderEntryActions.bind(this) - - this.columns = {} - } - - componentDidMount() { - this.addColumns(this.props.domain.searchResultColumns) - this.addColumns(EntryListUnstyled.defaultColumns) - this.addColumns({ - entries: { - label: 'Entries', - render: group => group.total.toLocaleString(), - description: 'Number of entries in this group' - } - }) - } - - renderEntryActions(entry, selected) { - return <DownloadButton - dark={selected} - query={{'dft.group_hash': entry.dft.group_hash}} tooltip="Download all entries of this group" - /> - } - - renderEntryDetails(entry) { - return <Group groupHash={entry.dft.group_hash} /> - } - - render() { - const { classes, data, total, groups_after, onChange, actions, domain } = this.props - const groups = data['dft.groups_grouped'] || {values: []} - const results = Object.keys(groups.values).map(group_hash => { - const example = groups.values[group_hash].examples[0] - return { - ...example, - total: groups.values[group_hash].total, - example: example - } - }) - const per_page = 10 - const after = groups.after - - const defaultSelectedColumns = this.props.selectedColumns || [ - ...domain.defaultSearchResultColumns, - 'datasets', 'authors', 'entries'] - - let paginationText - if (groups_after) { - paginationText = `next ${results.length.toLocaleString()} of ${(total || 0).toLocaleString()}` - } else { - paginationText = `1-${results.length.toLocaleString()} of ${(total || 0).toLocaleString()}` - } - - const pagination = <TableCell colSpan={1000} classes={{root: classes.scrollCell}}> - <Toolbar className={classes.scrollBar}> - <span className={classes.scrollSpacer}> </span> - <span>{paginationText}</span> - <IconButton disabled={!groups_after} onClick={() => onChange({'dft.groups_grouped_after': null})}> - <StartIcon /> - </IconButton> - <IconButton disabled={results.length < per_page} onClick={() => onChange({'dft.groups_grouped_after': after})}> - <NextIcon /> - </IconButton> - </Toolbar> - </TableCell> - - return <DataTable - classes={{details: classes.details}} - entityLabels={['group of similar entries', 'groups of similar entries']} - id={row => row.dft.group_hash} - total={total} - columns={this.columns} - selectedColumns={defaultSelectedColumns} - selectedColumnsKey="groups" - entryDetails={this.renderEntryDetails.bind(this)} - entryActions={this.renderEntryActions} - data={results} - rows={per_page} - actions={actions} - pagination={pagination} - /> - } -} - -const GroupList = compose(withRouter, withApi(false), withStyles(GroupListUnstyled.styles))(GroupListUnstyled) - -export default GroupList diff --git a/gui/src/components/search/results/MaterialsList.js b/gui/src/components/search/results/MaterialsList.js deleted file mode 100644 index 85056e7eb6..0000000000 --- a/gui/src/components/search/results/MaterialsList.js +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright The NOMAD Authors. - * - * This file is part of NOMAD. See https://nomad-lab.eu for further info. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react' -import PropTypes from 'prop-types' -import { makeStyles, TableCell, Toolbar, IconButton, Tooltip } from '@material-ui/core' -import NextIcon from '@material-ui/icons/ChevronRight' -import StartIcon from '@material-ui/icons/SkipPrevious' -import DataTable from '../../DataTable' -import DetailsIcon from '@material-ui/icons/MoreHoriz' -import { appBase } from '../../../config' - -const useStyles = makeStyles(theme => ({ - root: { - overflow: 'auto', - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2) - }, - scrollCell: { - padding: 0 - }, - scrollBar: { - minHeight: 56, - padding: 0 - }, - scrollSpacer: { - flexGrow: 1 - }, - clickableRow: { - cursor: 'pointer' - } -})) - -const columns = { - 'encyclopedia.material.formula': { - label: 'Formula' - }, - 'encyclopedia.material.material_name': { - label: 'Name' - }, - 'encyclopedia.material.material_type': { - label: 'Type' - }, - 'encyclopedia.material.bulk': { - label: 'Spacegroup', - render: entry => { - const bulk = entry.encyclopedia.material.bulk - return (bulk && bulk.space_group_international_short_symbol) || '-' - } - }, - calculations: { - label: 'No calculations', - description: 'The number of entries with data for this material', - render: entry => entry.total - } -} - -export default function MaterialsList(props) { - const { data, total, materials_after, per_page, onChange, actions } = props - const classes = useStyles() - const materials = data['encyclopedia.material.materials_grouped'] || {values: []} - const results = Object.keys(materials.values).map(id => { - return { - id: id, - total: materials.values[id].total, - ...materials.values[id].examples[0] - } - }) - const after = materials.after - const perPage = per_page || 10 - - let paginationText - if (materials_after) { - paginationText = `next ${results.length.toLocaleString()} of ${(total || 0).toLocaleString()}` - } else { - paginationText = `1-${results.length.toLocaleString()} of ${(total || 0).toLocaleString()}` - } - - /* const handleViewMaterial = useCallback((event, materialId) => { - event.stopPropagation() - history.push(`/material/${materialId}/overview`) - }, [history]) */ - - const pagination = <TableCell colSpan={1000} classes={{root: classes.scrollCell}}> - <Toolbar className={classes.scrollBar}> - <span className={classes.scrollSpacer}> </span> - <span>{paginationText}</span> - <IconButton disabled={!materials_after} onClick={() => onChange({materials_grouped_after: null})}> - <StartIcon /> - </IconButton> - <IconButton disabled={results.length < perPage} onClick={() => onChange({materials_grouped_after: after})}> - <NextIcon /> - </IconButton> - </Toolbar> - </TableCell> - - const entryActions = entry => <Tooltip title="Open this material in the Encyclopedia."> - <IconButton href={`${appBase}/encyclopedia/#/material/${entry.encyclopedia.material.material_id}`}> - <DetailsIcon /> - </IconButton> - {/* <IconButton onClick={event => handleViewMaterial(event, entry.encyclopedia.material.material_id)}> - <DetailsIcon /> - </IconButton> */} - </Tooltip> - - return <DataTable - entityLabels={['material', 'materials']} - id={row => row.id} - total={total} - columns={columns} - selectedColumns={['encyclopedia.material.formula', 'encyclopedia.material.material_name', 'encyclopedia.material.material_type', 'encyclopedia.material.bulk.spacegroup', 'calculations']} - selectedColumnsKey="materials" - data={results} - rows={perPage} - actions={actions} - pagination={pagination} - entryActions={entryActions} - /> -} -MaterialsList.propTypes = ({ - data: PropTypes.object, - total: PropTypes.number, - onChange: PropTypes.func.isRequired, - materials_after: PropTypes.string, - per_page: PropTypes.number, - actions: PropTypes.element -}) diff --git a/gui/src/components/search/results/SearchResults.js b/gui/src/components/search/results/SearchResults.js index 20f8520399..6169da1d76 100644 --- a/gui/src/components/search/results/SearchResults.js +++ b/gui/src/components/search/results/SearchResults.js @@ -24,7 +24,7 @@ import { } from '@material-ui/core' import SearchResultsMaterials from './SearchResultsMaterials' import SearchResultsEntries from './SearchResultsEntries' -import { useScrollResults, useSearchContext } from '../FilterContext' +import { useScrollResults, useSearchContext } from '../SearchContext' /** * Displays the list of search results @@ -65,7 +65,6 @@ const SearchResults = React.memo(({ // re-render the results list only when the actual results have changed, and // not just when the search query changes. Has a significant effect on // performance. - // const component = resource === 'materials' ? MaterialResults : NewEntryList const result = useMemo(() => { const Component = resource === 'materials' ? SearchResultsMaterials : SearchResultsEntries return <Paper className={clsx(className, styles.root)}> diff --git a/gui/src/components/search/results/UploadersList.js b/gui/src/components/search/results/UploadersList.js deleted file mode 100644 index 4c3db9126e..0000000000 --- a/gui/src/components/search/results/UploadersList.js +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright The NOMAD Authors. - * - * This file is part of NOMAD. See https://nomad-lab.eu for further info. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react' -import PropTypes from 'prop-types' -import Grid from '@material-ui/core/Grid' -import { Quantity } from '../QuantityHistogram' -import { withStyles } from '@material-ui/core' -import { searchContext } from '../SearchContext' -import { compose } from 'recompose' -import { withApi } from '../../api' - -class UploadersList extends React.Component { - static propTypes = { - classes: PropTypes.object.isRequired - } - - static styles = theme => ({ - root: { - marginTop: theme.spacing(2) - } - }) - - static contextType = searchContext - - render() { - const {state: {usedMetric}} = this.context - - return ( - <Grid> - <Quantity quantity="origin" title="Uploaders/origin" scale={1} metric={usedMetric} /> - </Grid> - ) - } -} -export default compose(withApi(false, false), withStyles(UploadersList.styles))(UploadersList) diff --git a/gui/src/components/search/input/PeriodicTableData.json b/gui/src/elementData.json similarity index 100% rename from gui/src/components/search/input/PeriodicTableData.json rename to gui/src/elementData.json -- GitLab