diff --git a/gui/src/components/api.js b/gui/src/components/api.js index e8fc61e04d1abeb9946b315aee704db1d72de069..ee56cd4d732260a9f24e9d7ee94d3e6dbdbd256c 100644 --- a/gui/src/components/api.js +++ b/gui/src/components/api.js @@ -474,13 +474,14 @@ class Api { .finally(this.onFinishLoading) } - async quantity_search(quantity, search, size, noLoadingIndicator) { + async suggestions_search(quantity, search, include, size, noLoadingIndicator) { if (!noLoadingIndicator) { this.onStartLoading() } return this.swagger() - .then(client => client.apis.repo.quantity_search({ + .then(client => client.apis.repo.suggestions_search({ size: size || 20, + include: include, quantity: quantity, ...search })) diff --git a/gui/src/components/search/SearchBarNew.js b/gui/src/components/search/SearchBarNew.js index 07cbf51661bcca8434242ab909499dbe644cebd6..d6c8055a1673ab6a670fd5f6e13ec6b0203dfe0c 100644 --- a/gui/src/components/search/SearchBarNew.js +++ b/gui/src/components/search/SearchBarNew.js @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useRef, useState, useContext, useCallback, useMemo } from 'react' import {searchContext} from './SearchContext' import Autocomplete from '@material-ui/lab/Autocomplete' import TextField from '@material-ui/core/TextField' @@ -6,15 +6,21 @@ import { CircularProgress } from '@material-ui/core' import * as searchQuantities from '../../searchQuantities.json' import { apiContext } from '../api' -const defaultOptions = Object.keys(searchQuantities).map(quantity => searchQuantities[quantity].name) - export default function SearchBar() { - const [open, setOpen] = React.useState(false) - const [options, setOptions] = React.useState(defaultOptions) - const [loading, setLoading] = React.useState(false) - const [inputValue, setInputValue] = React.useState('') - const {response: {statistics, pagination}, domain, query, setQuery} = React.useContext(searchContext) - const {api} = React.useContext(apiContext) + const suggestionsTimerRef = useRef(null) + const {response: {statistics, pagination}, domain, query, setQuery} = useContext(searchContext) + const defaultOptions = useMemo(() => { + return Object.keys(searchQuantities) + .map(quantity => searchQuantities[quantity].name) + .filter(quantity => !quantity.includes('.') || quantity.startsWith(domain.key + '.')) + }, [domain.key]) + + const [open, setOpen] = useState(false) + const [options, setOptions] = useState(defaultOptions) + const [loading, setLoading] = useState(false) + const [inputValue, setInputValue] = useState('') + + const {api} = useContext(apiContext) const autocompleteValue = Object.keys(query).map(quantity => `${quantity}=${query[quantity]}`) @@ -33,36 +39,52 @@ export default function SearchBar() { } } - const loadValues = (quantity, value) => { - setLoading(true) - const size = searchQuantities[quantity].statistic_size || 20 - api.quantity_search(quantity, query, size, true) - .then(response => { - setLoading(false) - const options = Object.keys(response.quantity.values).map(value => `${quantity}=${value}`) - setOptions(options) - setOpen(true) - }) - .catch(() => { - setLoading(false) - }) - } + const filterOptions = useCallback((options, params) => { + const [quantity, value] = params.inputValue.split('=') + const filteredOptions = options.filter(option => { + const [optionQuantity, optionValue] = option.split('=') + if (!value) { + return optionQuantity.includes(quantity) || optionQuantity === quantity + } else { + return optionValue.includes(value) || optionValue === value + } + }) + return filteredOptions + }, []) - const handleInputChange = (event, value, reason) => { + const loadOptions = useCallback((quantity, value) => { + if (suggestionsTimerRef.current !== null) { + clearTimeout(suggestionsTimerRef.current) + } + suggestionsTimerRef.current = setTimeout(() => { + setLoading(true) + api.suggestions_search(quantity, query, value, 20, true) + .then(response => { + setLoading(false) + const options = response.suggestions.map(value => `${quantity}=${value}`) + setOptions(options) + setOpen(true) + }) + .catch(() => { + setLoading(false) + }) + }, 200) + }, [api, suggestionsTimerRef]) + + const handleInputChange = useCallback((event, value, reason) => { if (reason === 'input') { setInputValue(value) const [quantity, quantityValue] = value.split('=') - if (!quantityValue) { - if (searchQuantities[quantity]) { - loadValues(quantity, quantityValue) - } else { - setOptions(defaultOptions) - } + + if (searchQuantities[quantity]) { + loadOptions(quantity, quantityValue) + } else { + setOptions(defaultOptions) } } - } + }, [loadOptions]) - const handleChange = (event, entries, reason) => { + const handleChange = (event, entries) => { const newQuery = entries.reduce((query, entry) => { if (entry) { const [quantity, value] = entry.split('=') @@ -91,7 +113,7 @@ export default function SearchBar() { setInputValue('') } else { setInputValue(`${entry}=`) - loadValues(quantity) + loadOptions(quantity) } } } @@ -119,9 +141,9 @@ export default function SearchBar() { onChange={handleChange} onInputChange={handleInputChange} getOptionSelected={(option, value) => option === value} - getOptionLabel={(option) => option} options={options} loading={loading} + filterOptions={filterOptions} renderInput={(params) => ( <TextField {...params} diff --git a/nomad/app/api/repo.py b/nomad/app/api/repo.py index 6b41856a370a7fc1e46aea07aa0476cab428354f..cc18e6cb5b3d159d9fcdfcbdc92483ec3d3f75d2 100644 --- a/nomad/app/api/repo.py +++ b/nomad/app/api/repo.py @@ -566,6 +566,8 @@ _repo_quantity_search_request_parser.add_argument( 'after', type=str, help='The after value to use for "scrolling".') _repo_quantity_search_request_parser.add_argument( 'size', type=int, help='The max size of the returned values.') +_repo_quantity_search_request_parser.add_argument( + 'value', type=str, help='A partial value. Only values that include this will be returned') _repo_quantity_model = api.model('RepoQuantity', { 'after': fields.String(description='The after value that can be used to retrieve the next set of values.'), @@ -631,6 +633,62 @@ class RepoQuantityResource(Resource): abort(400, 'Given quantity does not exist: %s' % str(e)) +_repo_suggestions_search_request_parser = api.parser() +add_search_parameters(_repo_suggestions_search_request_parser) +_repo_suggestions_search_request_parser.add_argument( + 'size', type=int, help='The max size of the returned values.') +_repo_suggestions_search_request_parser.add_argument( + 'include', type=str, help='A substring that all values need to include.') + +_repo_suggestions_model = api.model('RepoSuggestionsValues', { + 'suggestions': fields.List(fields.String, description='A list with the suggested values.') +}) + + +@ns.route('/suggestions/<string:quantity>') +class RepoSuggestionsResource(Resource): + @api.doc('suggestions_search') + @api.response(400, 'Invalid requests, e.g. wrong owner type, bad quantity, bad search parameters') + @api.expect(_repo_suggestions_search_request_parser, validate=True) + @api.marshal_with(_repo_suggestions_model, skip_none=True, code=200, description='Suggestions send') + @authenticate() + def get(self, quantity: str): + ''' + Retrieve the top values for the given quantity from entries matching the search. + Values can be filtered by to include a given value. + + There is no ordering, no pagination, and no scroll interface. + + The result will contain a 'suggestions' key with values. There will be upto 'size' many values. + ''' + + search_request = search.SearchRequest() + args = { + key: value + for key, value in _repo_suggestions_search_request_parser.parse_args().items() + if value is not None} + + apply_search_parameters(search_request, args) + size = args.get('size', 20) + include = args.get('include', None) + + try: + assert size >= 0 + except AssertionError: + abort(400, message='invalid size') + + try: + search_request.statistic(quantity, size=size, include=include) + results = search_request.execute() + results['suggestions'] = list(results['statistics'][quantity].keys()) + + return results, 200 + except KeyError as e: + import traceback + traceback.print_exc() + abort(400, 'Given quantity does not exist: %s' % str(e)) + + _repo_quantities_search_request_parser = api.parser() add_search_parameters(_repo_quantities_search_request_parser) _repo_quantities_search_request_parser.add_argument( diff --git a/nomad/search.py b/nomad/search.py index 7b1499c606c527a9515d7a30680779a60e76ad96..51c827ae18b06c7c7798cc3d610d7ea465e3917e 100644 --- a/nomad/search.py +++ b/nomad/search.py @@ -297,7 +297,7 @@ class SearchRequest: def statistic( self, quantity_name: str, size: int, metrics_to_use: List[str] = [], - order: Dict[str, str] = dict(_key='asc')): + order: Dict[str, str] = dict(_key='asc'), include: str = None): ''' This can be used to display statistics over the searched entries and allows to implement faceted search on the top values for each quantity. @@ -322,9 +322,15 @@ class SearchRequest: ``unique_code_runs``, ``datasets``, other domain specific metrics. The basic doc_count metric ``code_runs`` is always given. order: The order dictionary is passed to the elastic search aggregation. + include: + Uses an regular expression in ES to only return values that include + the given substring. ''' quantity = search_quantities[quantity_name] - terms = A('terms', field=quantity.search_field, size=size, order=order) + terms_kwargs = {} + if include is not None: + terms_kwargs['include'] = '.*%s.*' % include + terms = A('terms', field=quantity.search_field, size=size, order=order, **terms_kwargs) buckets = self._search.aggs.bucket('statistics:%s' % quantity_name, terms) self._add_metrics(buckets, metrics_to_use) diff --git a/tests/app/test_api.py b/tests/app/test_api.py index 018b982a86761b228b3360833565332a99a1cebc..51b9a4cb4ab0f62b086eaacd3ca727f004649fb6 100644 --- a/tests/app/test_api.py +++ b/tests/app/test_api.py @@ -1048,6 +1048,23 @@ class TestRepo(): rv = api.get('/repo/?owner=user') assert rv.status_code == 401 + @pytest.mark.parametrize('suggestions, quantity, value', [ + (1, 'dft.system', 'bulk'), + (1, 'dft.system', 'ulk'), + (1, 'dft.system', 'ul'), + (0, 'dft.system', 'notbulk'), + (1, 'dft.system', None) + ]) + def test_suggestions_search(self, api, example_elastic_calcs, no_warn, test_user_auth, suggestions, quantity, value): + url = '/repo/suggestions/%s' % quantity + if value is not None: + url = url + '?include=%s' % value + rv = api.get(url, headers=test_user_auth) + assert rv.status_code == 200 + data = json.loads(rv.data) + values = data['suggestions'] + assert len(values) == suggestions + @pytest.mark.parametrize('calcs, quantity, value', [ (2, 'dft.system', 'bulk'), (0, 'dft.system', 'atom'), diff --git a/tests/test_search.py b/tests/test_search.py index 82d50ce091ef5c46aa133896dff76dd90bbfebf0..40ed5a9716039532e51c8de717f2d3f0a8f1b0e7 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -170,6 +170,14 @@ def test_search_statistics(elastic, example_search_data): assert 'quantities' not in results +def test_suggest_statistics(elastic, example_search_data): + results = SearchRequest(domain='dft').statistic('dft.system', include='ulk', size=2).execute() + assert len(results['statistics']['dft.system']) == 1 + + results = SearchRequest(domain='dft').statistic('dft.system', include='not_ulk', size=2).execute() + assert len(results['statistics']['dft.system']) == 0 + + def test_search_totals(elastic, example_search_data): use_metrics = search_extension.metrics.keys()