diff --git a/gui/src/components/DatasetPage.js b/gui/src/components/DatasetPage.js index 8b218644c30dc5ed8a9238061da05296a06c34f3..6d30b2c041d6acf1c323139dd89c8453bba2452e 100644 --- a/gui/src/components/DatasetPage.js +++ b/gui/src/components/DatasetPage.js @@ -16,11 +16,12 @@ * limitations under the License. */ import React, { useContext, useState, useEffect, useMemo } from 'react' +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 { Typography, makeStyles } from '@material-ui/core' import { DOI } from './search/results/DatasetList' export const help = ` @@ -29,122 +30,50 @@ to explore a dataset with similar controls that the search page offers. ` const useStyles = makeStyles(theme => ({ - description: { - flexGrow: 1, - marginRight: theme.spacing(1) - }, header: { display: 'flex', - flexDirection: 'row', - padding: theme.spacing(3) + flexDirection: 'column' } })) -const UserdataPage = React.memo(() => { +const UserdataPage = React.memo(({match}) => { const styles = useStyles() - const [dataset, setDataset] = useState({}) + const [dataset, setDataset] = useState() const {raiseError} = useContext(errorContext) const {api} = useApi() - // Read dataset id from URL. - const datasetId = useMemo(() => { - const location = window.location.href - const split = location.split('?') - return split.length === 1 ? split.pop() : split[0] - }, []) + // Router provides the URL parameters via props, here we read the dataset ID. + const datasetId = match?.params?.datasetId + const datasetFilter = useMemo(() => ({'datasets.dataset_id': datasetId}), [datasetId]) // Fetch the dataset information from API. useEffect(() => { - api.search({ - owner: 'all', - dataset_id: datasetId, - page: 1, - per_page: 1 - }).then(data => { - const entry = data.results[0] - const dataset = entry && entry.datasets.find(ds => ds.dataset_id + '' === datasetId) - if (!dataset) { - setDataset({isEmpty: true}) - } - setDataset({...dataset, example: entry}) - }).catch(error => { - setDataset({}) - raiseError(error) - }) + api.datasets(datasetId) + .then(setDataset) + .catch(error => { + setDataset(undefined) + raiseError(error) + }) }, [datasetId, api, raiseError]) // Shows basic dataset information above the searchbar - const header = <div className={styles.header}> - <div className={styles.description}> - <Typography variant="h4">{dataset.name || (dataset.isEmpty && 'Empty or non existing dataset') || 'loading ...'}</Typography> - <Typography> - dataset{dataset.doi ? <span>, with DOI <DOI doi={dataset.doi} /></span> : ''} - </Typography> - </div> - </div> - - return <SearchContext + return dataset && <SearchContext resource="entries" - query={undefined} - filtersLocked={{dataset_id: datasetId}} - header={header} + filtersLocked={datasetFilter} > - <NewSearch/> + <NewSearch header={ + <div className={styles.header}> + <Typography variant="h4"> + {dataset.name || (dataset.isEmpty && 'Empty or non existing dataset') || 'loading ...'} + </Typography> + <Typography> + dataset{dataset.doi ? <span>, with DOI <DOI doi={dataset.doi} /></span> : ''} + </Typography> + </div>} + /> </SearchContext> }) +UserdataPage.propTypes = { + match: PropTypes.object +} export default UserdataPage - -// export default function DatasetPage() { -// const classes = useStyles() -// const [dataset, setDataset] = useState({}) - -// const {api} = useContext(apiContext) -// const {raiseError} = useContext(errorContext) -// const location = useLocation() -// const match = useRouteMatch() - -// const {datasetId} = match.params - -// useEffect(() => { -// api.search({ -// owner: 'all', -// dataset_id: datasetId, -// page: 1, -// per_page: 1 -// }).then(data => { -// const entry = data.results[0] -// const dataset = entry && entry.datasets.find(ds => ds.dataset_id + '' === datasetId) -// if (!dataset) { -// setDataset({isEmpty: true}) -// } -// setDataset({...dataset, example: entry}) -// }).catch(error => { -// setDataset({}) -// raiseError(error) -// }) -// }, [datasetId, location.pathname, api, raiseError]) - -// if (!dataset) { -// return <div>loading...</div> -// } - -// return <div> -// <div className={classes.header}> -// <div className={classes.description}> -// <Typography variant="h4">{dataset.name || (dataset.isEmpty && 'Empty or non existing dataset') || 'loading ...'}</Typography> -// <Typography> -// dataset{dataset.doi ? <span>, with DOI <DOI doi={dataset.doi} /></span> : ''} -// </Typography> -// </div> -// </div> - -// <Search -// initialQuery={{owner: 'all'}} -// query={{dataset_id: [datasetId]}} -// ownerTypes={['all', 'public']} -// initialResultTab="entries" -// resultListProps={{showAccessColumn: true}} -// availableResultTabs={['entries', 'groups', 'datasets']} -// /> -// </div> -// } diff --git a/gui/src/components/UserdataPage.js b/gui/src/components/UserdataPage.js index e8c6e9984dd39a5483bd2e9eaf93b514feff34d3..f8570f6e7c69f79f28b2a8c0debe15779ac9cffc 100644 --- a/gui/src/components/UserdataPage.js +++ b/gui/src/components/UserdataPage.js @@ -16,7 +16,7 @@ * limitations under the License. */ import React from 'react' -import { withApi } from './api' +import { withLoginRequired } from './apiV1' import { SearchContext } from './search/FilterContext' import NewSearch from './search/NewSearch' @@ -73,13 +73,13 @@ Once you assigned a DOI to a dataset, no entries can be removed or added to the const filtersLocked = { 'visibility': 'user' } -function UserdataPage() { +const UserdataPage = React.memo(() => { return <SearchContext resource="entries" filtersLocked={filtersLocked} > <NewSearch/> </SearchContext> -} +}) -export default withApi(true, false, 'Please login to search your data.')(UserdataPage) +export default withLoginRequired(UserdataPage, 'Please login to search your data.') diff --git a/gui/src/components/apiV1.js b/gui/src/components/apiV1.js index e8084d9c979f089de47ebf818436254b8371c14f..a100cec0e17dd07f70672fc8a6b9d46fa222f945 100644 --- a/gui/src/components/apiV1.js +++ b/gui/src/components/apiV1.js @@ -157,6 +157,25 @@ class Api { } } + /** + * Returns the data related to the specified dataset. + * + * @param {string} datasetID + * @returns Object containing the search index contents. + */ + async datasets(datasetId) { + this.onStartLoading() + const auth = await this.authHeaders() + try { + const entry = await this.axios.get(`/datasets/${datasetId}`, auth) + return entry.data.data + } catch (errors) { + handleApiError(errors) + } finally { + this.onFinishLoading() + } + } + /** * Returns section_results from the archive corresponding to the given entry. * Some large quantities which are not required by the GUI are filtered out. @@ -335,7 +354,8 @@ function parse(result) { } /** - * Hook that returns a shared instance of the API class. + * Hook that returns a shared instance of the API class and information about + * the currently logged in user. */ let api = null let user = null @@ -410,6 +430,13 @@ LoginRequired.propTypes = { ]).isRequired } +/** + * HOC for wrapping components that require a login. Without login will return + * the given message together with a login link. + * + * @param {*} Component + * @param {*} message The message to display + */ export function withLoginRequired(Component, message) { return ({...props}) => <LoginRequired message={message}> <Component {...props} /> diff --git a/gui/src/components/nav/Routes.js b/gui/src/components/nav/Routes.js index faa4b353340eb83eb1c5146e99f5a2f62470afdc..be8d7c78f515db6a2c2cf166286e0e33eaebce38 100644 --- a/gui/src/components/nav/Routes.js +++ b/gui/src/components/nav/Routes.js @@ -29,7 +29,8 @@ import UploadPage from '../uploads/UploadPage' import UploadsPage, { help as uploadsHelp } from '../uploads/UploadsPage' import UserdataPage, { help as userdataHelp } from '../UserdataPage' import APIs from '../APIs' -import NewSearchPage, {help as searchHelp} from '../search/NewSearchPage' +import SearchPageEntries, {help as searchEntriesHelp} from '../search/SearchPageEntries' +import SearchPageMaterials, {help as searchMaterialsHelp} from '../search/SearchPageMaterials' import { aitoolkitEnabled, appBase, oasis } from '../../config' import EntryQuery from '../entry/EntryQuery' import ResolvePID from '../entry/ResolvePID' @@ -121,7 +122,7 @@ const datasetRoutes = [ routes: entryRoutes, help: { title: 'Datasets', - help: datasetHelp + content: datasetHelp } }, { @@ -191,9 +192,9 @@ export const routes = [ { path: 'search', exact: true, - menu: 'Your data', + menu: 'Search your data', breadcrumb: 'Search your data', - tooltip: 'Manage your uploaded data', + tooltip: 'Search the data you have uploaded', help: { title: 'How to manage your data', content: userdataHelp @@ -211,23 +212,27 @@ export const routes = [ { path: 'entries', exact: true, - component: NewSearchPage, + component: SearchPageEntries, menu: 'Entries Repository', tooltip: 'Search individual database entries', breadcrumb: 'Entries search', help: { title: 'How to find and download data', - content: searchHelp + content: searchEntriesHelp }, routes: entryRoutes }, { path: 'materials', exact: true, - component: NewSearchPage, + component: SearchPageMaterials, menu: 'Material Encyclopedia', tooltip: 'Search materials', - breadcrumb: 'Materials search' + breadcrumb: 'Materials search', + help: { + title: 'How to find and download data', + content: searchMaterialsHelp + } } ] }, diff --git a/gui/src/components/search/FilterContext.js b/gui/src/components/search/FilterContext.js index 8d6c4ae5ce30910a2c603b72693d99438f08a5fd..2e519099334472153b5959d41a30ee375dfca384 100644 --- a/gui/src/components/search/FilterContext.js +++ b/gui/src/components/search/FilterContext.js @@ -278,20 +278,19 @@ export const SearchContext = React.memo(({ reset() }, [reset]) - // Read the target resource and initial query from the URL - const [resourceFinal, query] = useMemo(() => { + // Read the initial query from the URL + const query = useMemo(() => { const location = window.location.href const split = location.split('?') - let qs, path, query + let qs, query if (split.length === 1) { - path = split.pop() query = {} } else { - [path, qs] = split + qs = split.pop() query = qsToQuery(qs) } - return [resource || path.split('/').pop(), query] - }, [resource]) + return query + }, []) // Save the initial query and locked filters. Cannot be done inside useMemo // due to bad setState. @@ -312,7 +311,7 @@ export const SearchContext = React.memo(({ const aggRequest = {} const aggNames = [...filters].filter(name => filterData[name].aggGet) for (const filter of aggNames) { - toAPIAgg(aggRequest, filter, resourceFinal) + toAPIAgg(aggRequest, filter, resource) } const search = { @@ -322,16 +321,16 @@ export const SearchContext = React.memo(({ pagination: {page_size: 0} } - api.query(resourceFinal, search, false) + api.query(resource, search, false) .then(data => { - data = toGUIAgg(data.aggregations, aggNames, resourceFinal) + data = toGUIAgg(data.aggregations, aggNames, resource) setInitialAggs(data) }) - }, [api, setInitialAggs, resourceFinal]) + }, [api, setInitialAggs, resource]) const values = useMemo(() => ({ - resource: resourceFinal - }), [resourceFinal]) + resource: resource + }), [resource]) return <searchContext.Provider value={values}> {children} @@ -601,8 +600,8 @@ export function useQuery() { export function useUpdateQueryString() { const history = useHistory() - const updateQueryString = useCallback((query) => { - const queryString = queryToQs(query) + const updateQueryString = useCallback((query, locked) => { + const queryString = queryToQs(query, locked) history.replace(history.location.pathname + '?' + queryString) }, [history]) @@ -645,9 +644,12 @@ function qsToQuery(queryString) { * filters. * @returns URL querystring, not encoded if possible to improve readability. */ -function queryToQs(query) { +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] @@ -785,6 +787,7 @@ export function useScrollResults(pageSize, orderBy, order, delay = 500) { 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() @@ -794,7 +797,7 @@ export function useScrollResults(pageSize, orderBy, order, delay = 500) { // 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, pageSize, orderBy, order) => { + const apiCall = useCallback((query, locked, pageSize, orderBy, order) => { pageAfterValue.current = undefined const restricted = query.restricted const cleanedQuery = toAPIQuery(query, resource, restricted) @@ -823,8 +826,8 @@ export function useScrollResults(pageSize, orderBy, order, delay = 500) { // 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) - }, [api, updateQueryString, resource]) + updateQueryString(query, locked) + }, [resource, api, updateQueryString]) // This is a debounced version of apiCall. const debounced = useCallback(debounce(apiCall, delay), []) @@ -857,12 +860,12 @@ export function useScrollResults(pageSize, orderBy, order, delay = 500) { return } if (firstRender.current) { - apiCall(query, pageSize, orderBy, order) + apiCall(query, locked, pageSize, orderBy, order) firstRender.current = false } else { - debounced(query, pageSize, orderBy, order) + debounced(query, locked, pageSize, orderBy, order) } - }, [apiCall, debounced, query, pageSize, order, orderBy]) + }, [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 diff --git a/gui/src/components/search/NewSearch.js b/gui/src/components/search/NewSearch.js index d0f151af3516ba8a45351f93e3b94300987d1251..e80dec2e9f3dc8a9f079b98e09cfce6183ddd356 100644 --- a/gui/src/components/search/NewSearch.js +++ b/gui/src/components/search/NewSearch.js @@ -63,6 +63,9 @@ const useStyles = makeStyles(theme => { spacer: { flexGrow: 1 }, + header: { + marginTop: theme.spacing(2) + }, searchBar: { marginTop: theme.spacing(2), display: 'flex', @@ -122,7 +125,9 @@ const NewSearch = React.memo(({ /> </div> <div className={styles.center} onClick={() => setIsMenuOpen(false)}> - {header} + <div className={styles.header}> + {header} + </div> <NewSearchBar quantities={filters} className={styles.searchBar} diff --git a/gui/src/components/search/NewSearchPage.js b/gui/src/components/search/SearchPageEntries.js similarity index 94% rename from gui/src/components/search/NewSearchPage.js rename to gui/src/components/search/SearchPageEntries.js index fe1e417d2d9a129a54545f16622e20513aca8050..58644f1f2be623d1b0196a45da38a3ad0b300245 100644 --- a/gui/src/components/search/NewSearchPage.js +++ b/gui/src/components/search/SearchPageEntries.js @@ -19,7 +19,7 @@ import React from 'react' import NewSearch from './NewSearch' import { SearchContext } from './FilterContext' -const help = ` +export const help = ` This page allows you to **search** in NOMAD's data. 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. @@ -50,10 +50,11 @@ individual entries or even download selections of the data. The arrow button shown for each entry will navigate you to that entry's page. This entry page will show more metadata, raw files, the entry's archive, and processing logs. ` -export {help} -export default function NewSearchPage() { - return <SearchContext> +const SearchPageEntries = React.memo(() => { + return <SearchContext resource="entries"> <NewSearch/> </SearchContext> -} +}) + +export default SearchPageEntries diff --git a/gui/src/components/search/SearchPageMaterials.js b/gui/src/components/search/SearchPageMaterials.js new file mode 100644 index 0000000000000000000000000000000000000000..2fe3df17a882925ad337d63d035bd201aee6c8c8 --- /dev/null +++ b/gui/src/components/search/SearchPageMaterials.js @@ -0,0 +1,60 @@ +/* + * 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 NewSearch from './NewSearch' +import { SearchContext } from './FilterContext' + +export const help = ` +This page allows you to **search** in NOMAD's data. 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 page consists of three main elements: the filter panel, the search +bar, and the result list. + +The filter panel on the left allows you to graphically explore and enter +different search filters. It also gives a visual indication of the currently +active search filters for each category. This is a good place to start exploring +the available search filters and their meaning. + +The search bar allows you to specify filters by typing them in and pressing +enter. You can also start by simply typing keywords of interest, which will +toggle a list of suggestions. For numerical data you can also use range queries, +e.g. \`0.0 < band_gap <= 0.1\`. The units used in the queries can be changed in +the settings. + +The result list is automatically updated according to the filters you have +specified. You can browse through the results by simply scrolling through the +available items. Here you can also change the sorting of the results, modify the +displayed columns, access individual entries or even download selections of the +data. The results tabs gives you a quick overview of all entries and datasets +that fit your search and it is automatically updated based on your filters. You +can browse through all of the results by scrolling down the list. Here you can +also change the sorting of the results, modify the displayed columns, access +individual entries or even download selections of the data. The arrow button +shown for each entry will navigate you to that entry's page. This entry page +will show more metadata, raw files, the entry's archive, and processing logs. +` + +const SearchPageMaterials = React.memo(() => { + return <SearchContext resource="materials"> + <NewSearch/> + </SearchContext> +}) + +export default SearchPageMaterials diff --git a/gui/src/components/visualization/Placeholder.js b/gui/src/components/visualization/Placeholder.js index 591b4566a430667e6b4c92422087614e2a7d363e..8a08c89706238e7a01b1cd77884276f9d831697d 100644 --- a/gui/src/components/visualization/Placeholder.js +++ b/gui/src/components/visualization/Placeholder.js @@ -78,7 +78,7 @@ const Placeholder = React.memo(({ return <div className={clsx(className, styles.root)} data-testid={testID}> <div className={stylesDynamic.containerOuter}> <div className={styles.placeholder}> - <Skeleton className={styles.skeleton} {...other}/> + <Skeleton className={styles.skeleton} {...other} /> </div> </div> </div>