diff --git a/gui/src/components/editQuantity/QueryEditQuantity.spec.js b/gui/src/components/editQuantity/QueryEditQuantity.spec.js index 87cd951f0d6b7c8c157afec3822c28050a4ca99f..5fc095d6f572351382c4471eef05e0893997dcf6 100644 --- a/gui/src/components/editQuantity/QueryEditQuantity.spec.js +++ b/gui/src/components/editQuantity/QueryEditQuantity.spec.js @@ -34,7 +34,7 @@ const quantityDef = { const testSearchDialogCancelButton = async () => { const dialog = screen.getByTestId('search-dialog') - await waitFor(() => expect(screen.queryByText('visibility=visible')).toBeInTheDocument()) + await waitFor(() => expect(screen.queryByText('visible')).toBeInTheDocument()) // cancel the search await userEvent.click(within(dialog).getByRole('button', {name: /cancel/i})) @@ -43,7 +43,7 @@ const testSearchDialogCancelButton = async () => { const testSearchDialogOkButton = async () => { const dialog = screen.getByTestId('search-dialog') - await waitFor(() => expect(screen.queryByText('visibility=visible')).toBeInTheDocument()) + await waitFor(() => expect(screen.queryByText('visible')).toBeInTheDocument()) // accept the search await userEvent.click(within(dialog).getByTestId('search-dialog-ok')) diff --git a/gui/src/components/search/FilterChip.js b/gui/src/components/search/FilterChip.js deleted file mode 100644 index 7c0382b4f5ae14a74eed9fa4eb7d609e7bec7d47..0000000000000000000000000000000000000000 --- a/gui/src/components/search/FilterChip.js +++ /dev/null @@ -1,151 +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 clsx from 'clsx' -import { makeStyles } from '@material-ui/core/styles' -import LockIcon from '@material-ui/icons/Lock' -import { Chip, Tooltip, Typography } from '@material-ui/core' -import PropTypes from 'prop-types' -import FilterTitle from './FilterTitle' - -/** - * 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), - boxSizing: 'border-box', - maxWidth: '100%' - }, - chip: { - padding: theme.spacing(0.5), - maxWidth: '100%' - } -})) -export const FilterChip = React.memo(({ - label, - onDelete, - color, - className, - locked -}) => { - const styles = useStyles() - - return <div className={clsx(className, styles.root)}> - <Tooltip title={locked ? 'This filter is locked in the current search context: it cannot be removed or modified.' : ''}> - <Chip - label={label} - onDelete={locked ? undefined : onDelete} - color={locked ? undefined : color} - className={styles.chip} - icon={locked ? <LockIcon/> : undefined} - /> - </Tooltip> - </div> -}) - -FilterChip.propTypes = { - label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - onDelete: PropTypes.func, - color: PropTypes.string, - className: PropTypes.string, - locked: PropTypes.bool -} -FilterChip.defaultProps = { - color: 'primary' -} - -export default FilterChip - -/** - * Used to group several related filter chips inside one container. - */ -const useFilterChipGroupStyles = makeStyles(theme => ({ - root: { - borderRadius: theme.spacing(1), - boxSizing: 'border-box', - width: '100%' - }, - paper: { - display: 'flex', - flexDirection: 'row', - flexWrap: 'wrap', - alignItems: 'center', - width: '100%', - boxSizing: 'border-box' - }, - title: { - marginLeft: theme.spacing(0.5), - color: theme.palette.grey[600] - } -})) -export const FilterChipGroup = React.memo(({ - quantity, - className, - children -}) => { - const styles = useFilterChipGroupStyles() - - return <div className={clsx(className, styles.root)}> - <FilterTitle - quantity={quantity} - variant="caption" - classes={{text: styles.title}} - /> - <div className={styles.paper}> - {children} - </div> - </div> -}) - -FilterChipGroup.propTypes = { - quantity: PropTypes.string, - color: PropTypes.string, - className: PropTypes.string, - children: PropTypes.node -} -FilterChipGroup.defaultProps = { - color: 'primary' -} - -/** - * Operators between filter chips. - */ -const useFilterOpStyles = makeStyles(theme => ({ - root: { - fontSize: '0.65rem' - } -})) -const FilterOp = React.memo(({className, children}) => { - const styles = useFilterOpStyles() - return <Typography variant="caption" className={clsx(className, styles.root)}>{children}</Typography> -}) - -FilterOp.propTypes = { - className: PropTypes.string, - children: PropTypes.node -} - -export const FilterAnd = React.memo(() => { - return <FilterOp>AND</FilterOp> -}) - -export const FilterOr = React.memo(() => { - return <FilterOp>OR</FilterOp> -}) diff --git a/gui/src/components/search/FilterSummary.js b/gui/src/components/search/FilterSummary.js deleted file mode 100644 index 16b21329cfe66fbfb261875c7e14315feef577fc..0000000000000000000000000000000000000000 --- a/gui/src/components/search/FilterSummary.js +++ /dev/null @@ -1,217 +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 } from 'react' -import { makeStyles, useTheme } from '@material-ui/core/styles' -import PropTypes from 'prop-types' -import clsx from 'clsx' -import { isNil, isPlainObject } from 'lodash' -import { FilterChip, FilterChipGroup, FilterAnd, FilterOr } from './FilterChip' -import { useSearchContext } from './SearchContext' -import { useUnitContext } from '../units/UnitContext' - -/** - * Smart component that displays a set of FilterGroups and FilterChips for the - * given quantities. - */ -const useStyles = makeStyles(theme => { - const paddingVertical = theme.spacing(1) - const paddingHorizontal = theme.spacing(1.5) - return { - root: { - boxShadow: 'inset 0 0 8px 1px rgba(0,0,0, 0.06)', - backgroundColor: theme.palette.background.default, - width: '100%', - paddingRight: paddingHorizontal, - paddingLeft: paddingHorizontal, - paddingTop: paddingVertical, - paddingBottom: paddingVertical, - boxSizing: 'border-box', - display: 'flex', - flexDirection: 'row', - flexWrap: 'wrap', - alignItems: 'center', - justifyContent: 'center' - }, - chip: { - padding: theme.spacing(0.5) - } - } -}) -const FilterSummary = React.memo(({ - quantities, - className, - classes -}) => { - const { filterData, filterAbbreviations, useFilters, useUpdateFilter } = useSearchContext() - const filters = useFilters(quantities) - const updateFilter = useUpdateFilter() - const theme = useTheme() - const {units} = useUnitContext() - const styles = useStyles({classes: classes, theme: theme}) - - // Creates a set of chips for a quantity - const createChips = useCallback((name, label, filterValue, onDelete, locked, nested) => { - const newChips = [] - if (isNil(filterValue)) { - return - } - // If query has multiple elements, we display a chip for each. For - // numerical values we also display the quantity name. - const {serializerPretty: serializer, customSerialization} = filterData[name] - const isArray = filterValue instanceof Array - const isSet = filterValue instanceof Set - const isObj = isPlainObject(filterValue) - let op = null - if (locked) { - op = <FilterAnd/> - } else if (filterData[name].queryMode === "any") { - op = <FilterOr/> - } else if (filterData[name].queryMode === "all") { - op = <FilterAnd/> - } - if (customSerialization) { - const item = <FilterChip - locked={locked} - label={serializer(filterValue)} - onDelete={() => { - onDelete(undefined) - }} - /> - newChips.push({comp: item, op}) - } else if (isArray || isSet) { - filterValue.forEach((value, index) => { - const displayValue = serializer(value, units) - const item = <FilterChip - locked={locked} - label={nested ? `${label}=${displayValue}` : displayValue} - onDelete={() => { - let newValue - if (isSet) { - newValue = new Set(filterValue) - newValue.delete(value) - } else if (isArray) { - newValue = [...filterValue] - newValue.splice(index, 1) - } - onDelete(newValue) - }} - /> - newChips.push({comp: item, op}) - }) - } else if (isObj) { - // Range queries - const lte = serializer(filterValue.lte, units) - const gte = serializer(filterValue.gte, units) - const lt = serializer(filterValue.lt, units) - const gt = serializer(filterValue.gt, units) - if (!isNil(gte) || !isNil(gt) || !isNil(lte) || !isNil(lt)) { - let content - if ((!isNil(gte) || !isNil(gt)) && (isNil(lte) && isNil(lt))) { - content = `${label}${!isNil(gte) ? ` >= ${gte}` : ''}${!isNil(gt) ? ` > ${gt}` : ''}` - } else if ((!isNil(lte) || !isNil(lt)) && (isNil(gte) && isNil(gt))) { - content = `${label}${!isNil(lte) ? ` <= ${lte}` : ''}${!isNil(lt) ? ` < ${lt}` : ''}` - } else { - content = `${!isNil(gte) ? `${gte} <= ` : ''}${!isNil(gt) ? `${gt} < ` : ''}${label}${!isNil(lte) ? ` <= ${lte}` : ''}${!isNil(lt) ? ` < ${lt}` : ''}` - } - const item = <FilterChip - locked={locked} - label={content} - onDelete={() => { - onDelete(undefined) - }} - /> - newChips.push({comp: item, op}) - } - } else { - const item = <FilterChip - locked={locked} - label={`${label}=${serializer(filterValue)}`} - onDelete={() => { - onDelete(undefined) - }} - /> - newChips.push({comp: item, op}) - } - return newChips.map((chip, index) => ( - <React.Fragment key={`${name}${index}`}> - {chip.comp}{index !== newChips.length - 1 && chip.op} - </React.Fragment> - )) - }, [filterData, units]) - - // Create chips for all of the requested quantities - const chips = [] - for (const quantity of quantities || []) { - const filterValue = filters[quantity] - if (isNil(filterValue)) { - continue - } - // Nested filters - const isSection = filterData[quantity].section - let newChips = [] - if (isSection) { - function addChipsForSection(data, locked) { - if (isNil(data)) return - const entries = Object.entries(data) - entries.forEach(([key, value], index) => { - const onDelete = (newValue) => { - const newSection = {...data} - if (newValue === undefined) { - delete newSection[key] - } else { - newSection[key] = newValue - } - updateFilter([quantity, newSection]) - } - newChips = newChips.concat(createChips(`${quantity}.${key}`, key, value, onDelete, locked, true)) - if (index !== entries.length - 1) { - newChips.push(<FilterAnd key={`${quantity}-and`}/>) - } - }) - } - addChipsForSection(filterValue, false) - // Regular non-nested filters - } else { - const onDelete = (newValue) => updateFilter([quantity, newValue]) - const label = filterAbbreviations[quantity] - newChips = newChips.concat(createChips(quantity, label, filterValue, onDelete, false)) - } - - // Place the chips in a group - if (newChips.length > 0) { - const group = <FilterChipGroup - key={quantity} - quantity={quantity} - >{newChips} - </FilterChipGroup> - chips.push(group) - } - } - - return chips.length !== 0 && <div className={clsx(className, styles.root)}> - {chips} - </div> -}) - -FilterSummary.propTypes = { - quantities: PropTypes.object, // Set of searchQuantities for which the filters are displayed - className: PropTypes.string, - classes: PropTypes.object -} - -export default FilterSummary diff --git a/gui/src/components/search/FilterTitle.js b/gui/src/components/search/FilterTitle.js index 2fb098425a479aba8af3ef16038a6c43f495cf02..625a58af43503d569999c997b8b6078cc5e9c971 100644 --- a/gui/src/components/search/FilterTitle.js +++ b/gui/src/components/search/FilterTitle.js @@ -24,6 +24,7 @@ import { useSearchContext } from './SearchContext' import { inputSectionContext } from './input/InputSection' import { Unit } from '../units/Unit' import { useUnitContext } from '../units/UnitContext' +import Ellipsis from '../visualization/Ellipsis' /** * Title for a metainfo quantity or section that is used in a search context. @@ -94,8 +95,18 @@ const FilterTitle = React.memo(({ // Determine the final description const finalDescription = description || filterData[quantity]?.description || '' + let tooltip = '' + if (finalDescription && quantity) { + tooltip = ( + <> + <Typography>{finalLabel}</Typography> + <b>Description: </b>{finalDescription}<br/> + <b>Path: </b>{quantity} + </> + ) + } - return <Tooltip title={finalDescription} placement="bottom" {...(TooltipProps || {})}> + return <Tooltip title={tooltip} interactive enterDelay={400} enterNextDelay={400} {...(TooltipProps || {})}> <div className={clsx(className, styles.root, rotation === 'right' && styles.right, rotation === 'down' && styles.down, @@ -108,7 +119,7 @@ const FilterTitle = React.memo(({ onMouseDown={onMouseDown} onMouseUp={onMouseUp} > - {finalLabel} + <Ellipsis>{finalLabel}</Ellipsis> </Typography> </div> </Tooltip> @@ -127,6 +138,7 @@ FilterTitle.propTypes = { TooltipProps: PropTypes.object, // Properties forwarded to the Tooltip onMouseDown: PropTypes.func, onMouseUp: PropTypes.func, + placement: PropTypes.string, noWrap: PropTypes.bool } diff --git a/gui/src/components/search/Query.js b/gui/src/components/search/Query.js new file mode 100644 index 0000000000000000000000000000000000000000..2dc722de8dfde2856377eafcf09aadf03d209f6a --- /dev/null +++ b/gui/src/components/search/Query.js @@ -0,0 +1,395 @@ +/* + * 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, { useMemo } from 'react' +import { makeStyles, useTheme } from '@material-ui/core/styles' +import PropTypes from 'prop-types' +import clsx from 'clsx' +import { isNil, isEmpty, isPlainObject } from 'lodash' +import { useSearchContext } from './SearchContext' +import { useUnitContext } from '../units/UnitContext' +import { Typography, Box, Chip } from '@material-ui/core' +import FilterTitle from './FilterTitle' +import Ellipsis from '../visualization/Ellipsis' +import ClearIcon from '@material-ui/icons/Clear' +import ReplayIcon from '@material-ui/icons/Replay' +import LockIcon from '@material-ui/icons/Lock' +import CodeIcon from '@material-ui/icons/Code' +import { Actions, Action } from '../Actions' +import { SourceApiCall, SourceApiDialogButton, SourceDialogDivider, SourceJsonCode } from '../buttons/SourceDialogButton' + +/** + * Thin wrapper for MUI Chip that is used for displaying (and possibly removing) + * query values. + */ +const useQueryChipStyles = makeStyles(theme => ({ + root: { + maxWidth: '100%' + }, + chipRoot: { + width: '100%', + maxWidth: '100%' + }, + chipLabel: { + minWidth: '1rem', + maxWidth: '25rem' + } +})) +export const QueryChip = React.memo(({ + label, + onDelete, + color, + className, + locked +}) => { + const styles = useQueryChipStyles() + + return <div className={clsx(className, styles.root)}> + <Chip + label={<Ellipsis tooltip={label}>{label}</Ellipsis>} + onDelete={locked ? undefined : onDelete} + color={locked ? undefined : color} + icon={locked ? <LockIcon/> : undefined} + classes={{root: styles.chipRoot, label: styles.chipLabel}} + /> + </div> +}) + +QueryChip.propTypes = { + label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + onDelete: PropTypes.func, + color: PropTypes.string, + className: PropTypes.string, + locked: PropTypes.bool +} +QueryChip.defaultProps = { + color: 'primary' +} + +/** + * Used to group several related query chips inside one container. + */ +export const queryTitleHeight = 2.2 +export const queryGroupHeight = 4.1 + queryTitleHeight +export const queryGroupSpacing = 0.5 +const useQueryChipGroupStyles = makeStyles(theme => ({ + root: { + position: 'relative', + minHeight: theme.spacing(queryGroupHeight), + marginLeft: theme.spacing(queryGroupSpacing), + marginRight: theme.spacing(queryGroupSpacing) + }, + chips: { + marginTop: theme.spacing(queryTitleHeight), + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.palette.primary.main, + borderRadius: theme.spacing(2) + }, + titleRoot: { + position: 'absolute', + left: theme.spacing(0.4), + right: 0, + top: 0, + height: theme.spacing(queryTitleHeight) + }, + title: { + color: theme.palette.grey[600] + } +})) +export const QueryChipGroup = React.memo(({ + quantity, + className, + children +}) => { + const styles = useQueryChipGroupStyles() + return <div className={clsx(className, styles.root)}> + <FilterTitle + quantity={quantity} + variant="caption" + classes={{root: styles.titleRoot, text: styles.title}} + disableUnit + /> + <div className={styles.chips}> + {children} + </div> + </div> +}) + +QueryChipGroup.propTypes = { + quantity: PropTypes.string, + color: PropTypes.string, + className: PropTypes.string, + children: PropTypes.node +} +QueryChipGroup.defaultProps = { + color: 'primary' +} + +/** + * Operators between query chips. + */ +const useQueryOpStyles = makeStyles(theme => ({ + root: { + fontSize: '0.6rem', + marginLeft: theme.spacing(0.2), + marginRight: theme.spacing(0), + color: 'white' + } +})) +export const QueryOp = React.memo(({className, children}) => { + const styles = useQueryOpStyles() + return <Typography variant="caption" className={clsx(className, styles.root)}>{children}</Typography> +}) + +QueryOp.propTypes = { + className: PropTypes.string, + children: PropTypes.node +} + +export const QueryAnd = React.memo(() => { + return <QueryOp>AND</QueryOp> +}) + +export const QueryOr = React.memo(() => { + return <QueryOp>OR</QueryOp> +}) + +// Custom function for chip creation +const createChips = (name, filterValue, onDelete, filterData, units) => { + if (isNil(filterValue)) return [] + + const { serializerPretty: serializer, customSerialization, queryMode } = filterData[name] + const isArray = Array.isArray(filterValue) + const isSet = filterValue instanceof Set + const isObj = isPlainObject(filterValue) + const op = queryMode === "any" ? <QueryOr/> : <QueryAnd/> + const chips = [] + + const createChip = (label, onDelete, single = false) => ( + <QueryChip key={label} label={label} onDelete={onDelete} single={single}/> + ) + + if (customSerialization) { + chips.push({ comp: createChip(serializer(filterValue), () => onDelete(undefined)), op }) + } else if (isArray || isSet) { + Array.from(filterValue).forEach((value, index) => { + chips.push({ + comp: createChip(serializer(value, units), () => { + const newValue = isSet ? new Set(filterValue) : [...filterValue] + isSet ? newValue.delete(value) : newValue.splice(index, 1) + onDelete(newValue) + }), + op + }) + }) + } else if (isObj) { + const createRangeChip = (label, comparison) => { + const content = `${comparison} ${serializer(filterValue[label], units)}` + if (!isNil(filterValue[label])) { + let newValue = {...filterValue} + delete newValue[label] + newValue = isEmpty(newValue) ? undefined : newValue + chips.push({ comp: createChip(content, () => onDelete(newValue)), op }) + } + } + + createRangeChip('lte', '<=') + createRangeChip('lt', '<') + createRangeChip('gte', '>=') + createRangeChip('gt', '>') + } else { + chips.push({ comp: createChip(serializer(filterValue), () => onDelete(undefined)), op }) + } + + return chips.length ? ( + <QueryChipGroup key={name} quantity={name}> + {chips.map((chip, index) => ( + <React.Fragment key={index}> + {chip.comp} + {index < chips.length - 1 && chip.op} + </React.Fragment> + ))} + </QueryChipGroup> + ) : null +} + +/* + * Displays chips for the current query. + */ +const useStyles = makeStyles(theme => ({ + root: { + width: '100%', + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'flex-end', + marginLeft: theme.spacing(-queryGroupSpacing), + marginRight: theme.spacing(-queryGroupSpacing) + }, + empty: { + marginTop: theme.spacing(1.8) + }, + chip: { + padding: theme.spacing(0.5) + } +})) +const QueryChips = React.memo(({ className, classes }) => { + const { filterData, useQuery, useUpdateFilter } = useSearchContext() + const query = useQuery() + const updateFilter = useUpdateFilter() + const theme = useTheme() + const { units } = useUnitContext() + const styles = useStyles({ classes, theme }) + + const chips = useMemo(() => { + const chips = [] + // The query chips are created in alphabetical order + const keys = Object.keys(query) + keys.sort() + for (const quantity of keys) { + const filterValue = query[quantity] + // Each key in a section is mapped into a group + const isSection = filterData[quantity].section + if (isSection) { + const addChipsForSection = (data) => { + const newChips = [] + Object.entries(data).forEach(([key, value], index) => { + // Empty filters are skipped + if (isEmpty(value)) return + const onDelete = (newValue) => { + const newSection = { ...data, [key]: newValue } + if (newValue === undefined) delete newSection[key] + updateFilter([quantity, newSection]) + } + newChips.push( + <React.Fragment key={`${quantity}.${key}`}> + {createChips(`${quantity}.${key}`, value, onDelete, filterData, units)} + {index < Object.entries(data).length - 1 && <QueryAnd/>} + </React.Fragment> + ) + }) + return newChips + } + const newChips = addChipsForSection(filterValue) + newChips.length && chips.push(newChips) + // Regular chips get their own group + } else { + const onDelete = (newValue) => updateFilter([quantity, newValue]) + chips.push(createChips(quantity, filterValue, onDelete, filterData, units)) + } + } + + return chips + }, [query, filterData, units, updateFilter]) + + return ( + <div className={clsx(className, styles.root)}> + {chips.length + ? chips + : <Typography className={styles.empty}><i>Your query will be shown here</i></Typography> + } + </div> + ) +}) + +QueryChips.propTypes = { + className: PropTypes.string, + classes: PropTypes.object +} + +const useQueryStyles = makeStyles(theme => ({ + root: { + minHeight: theme.spacing(queryGroupHeight), + margin: theme.spacing(1, 0.25), + width: '100%', + display: 'flex', + flexDirection: 'row', + alignItems: 'flex-start' + }, + offset: { + height: theme.spacing(queryGroupHeight), + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + width: 'unset' + } +})) + +/** + * Displays the current query and actions for it. + */ +const Query = React.memo(() => { + const {useResetFilters, useRefresh, useApiData} = useSearchContext() + const styles = useQueryStyles() + const resetFilters = useResetFilters() + const refresh = useRefresh() + const apiData = useApiData() + + return <Box className={styles.root}> + <QueryChips/> + <Actions className={styles.offset}> + <Action + tooltip="Clear query" + onClick={() => resetFilters()} + > + <ClearIcon fontSize="small"/> + </Action> + <Action + tooltip="Refresh results" + onClick={() => refresh()} + > + <ReplayIcon fontSize="small"/> + </Action> + <Action + tooltip="" + ButtonComponent={SourceApiDialogButton} + ButtonProps={{ + tooltip: "View API call for the query", + maxWidth: "lg", + fullWidth: true, + icon: <CodeIcon fontSize="small"/>, + buttonProps: { + size: "small" + } + }} + > + <Typography> + NOMAD uses the same query format throughout its API. This is the query + based on the current filters: + </Typography> + <SourceJsonCode data={{owner: apiData?.body?.owner, query: apiData?.body?.query}}/> + <SourceDialogDivider/> + <Typography> + One application of the above query is this API call. This is what is currently + used to render this page and includes all displayed statistics data + (aggregations). + </Typography> + <SourceApiCall + {...apiData} + /> + </Action> + </Actions> + </Box> +}) +Query.propTypes = {} + +export default Query diff --git a/gui/src/components/search/FilterSummary.spec.js b/gui/src/components/search/Query.spec.js similarity index 84% rename from gui/src/components/search/FilterSummary.spec.js rename to gui/src/components/search/Query.spec.js index db890b76e05e7b2f140b56f87e8f9a286759396f..f8c5f8d0b3d57d71b036057fe7d977ae7c20fb8a 100644 --- a/gui/src/components/search/FilterSummary.spec.js +++ b/gui/src/components/search/Query.spec.js @@ -18,14 +18,14 @@ import React from 'react' import { ui } from '../../config' import { render, screen } from '../conftest.spec' -import FilterSummary from './FilterSummary' +import QueryChips from './Query' import { SearchContext } from './SearchContext' test.each([ - ['integer', 'results.material.n_elements', 12, 'N elements', 'n_elements=12'], + ['integer', 'results.material.n_elements', 12, 'N elements', '12'], ['string', 'results.material.symmetry.crystal_system', 'cubic', 'Crystal system', 'cubic'], - ['float', 'results.method.simulation.precision.k_line_density', 12.3, 'k-line density (Ã…)', 'k_line_density=12.3'], - ['datetime', 'upload_create_time', 0, 'Upload create time', 'upload_create_time=01/01/1970'], + ['float', 'results.properties.electronic.band_gap.value', '12.3 eV', 'Value', '12.3 eV'], + ['datetime', 'upload_create_time', 0, 'Upload create time', '01/01/1970'], ['boolean', 'results.properties.electronic.dos_electronic.spin_polarized', 'false', 'Spin-polarized', 'false'] ])('%s', async (name, quantity, input, title, output) => { const context = ui.apps.options.entries @@ -41,7 +41,7 @@ test.each([ initialFilterValues={{[quantity]: input}} initialSearchSyntaxes={context?.search_syntaxes} > - <FilterSummary quantities={new Set([quantity])}/> + <QueryChips/> </SearchContext> ) expect(screen.getByText(title, {exact: false})).toBeInTheDocument() diff --git a/gui/src/components/search/SearchBar.js b/gui/src/components/search/SearchBar.js index 221a36a7757bfb83297b9574752fd55c2f50cf47..4437ce145d48af9ee75a5e2a6d05132bb3f0a622 100644 --- a/gui/src/components/search/SearchBar.js +++ b/gui/src/components/search/SearchBar.js @@ -1,3 +1,4 @@ +/* eslint-disable no-unused-vars */ /* * Copyright The NOMAD Authors. * @@ -25,7 +26,7 @@ import SearchIcon from '@material-ui/icons/Search' import HistoryIcon from '@material-ui/icons/History' import CloseIcon from '@material-ui/icons/Close' import { HelpButton } from '../../components/Help' -import { Paper, Tooltip, Chip, List, ListSubheader, Typography, IconButton } from '@material-ui/core' +import { Paper, Tooltip, Chip, List, ListSubheader, Typography, IconButton, Box } from '@material-ui/core' import { parseQuantityName, getSchemaAbbreviation } from '../../utils' import { useSuggestions } from '../../hooks' import { useSearchContext } from './SearchContext' @@ -131,11 +132,29 @@ Suggestion.propTypes = { tooltip: PropTypes.string } +const queryControlsHeight = 5.5 export const useStyles = makeStyles(theme => ({ root: { + width: '100%', display: 'flex', - alignItems: 'center', - position: 'relative' + flexDirection: 'column' + }, + paper: { + width: '100%' + }, + offset: { + height: theme.spacing(queryControlsHeight), + display: 'flex', + flexDirection: 'row', + alignItems: 'center' + }, + queryContainer: { + minHeight: theme.spacing(queryControlsHeight), + marginTop: theme.spacing(0.5), + width: '100%', + display: 'flex', + flexDirection: 'row', + alignItems: 'flex-start' }, iconButton: { padding: 10 @@ -160,8 +179,12 @@ const SearchBar = React.memo(({ usePushSearchSuggestion, useRemoveSearchSuggestion, useSetPagination, - searchSyntaxes + searchSyntaxes, + useResetFilters, + useRefresh, + useApiData } = useSearchContext() + const includedFormats = Object .keys(SearchSyntaxes) .filter((key) => !searchSyntaxes?.exclude?.includes(key)) @@ -410,54 +433,56 @@ const SearchBar = React.memo(({ setSuggestionInput(input) }, [quantitiesSuggestable, filterFullnames, getSuggestionsMatch, clearSuggestions]) - return <Paper className={clsx(className, styles.root)}> - <InputText - value={inputValue || null} - onChange={handleInputChange} - onSelect={handleAccept} - onAccept={handleAccept} - onHighlight={handleHighlight} - suggestions={keys} - disableAcceptOnBlur - autoHighlight={inputValue?.trim() === suggestions[keys[0]]?.input?.trim()} - ListboxComponent={ListboxSuggestion} - TextFieldProps={{ - variant: 'outlined', - placeholder: 'Type your query or keyword here', - label: error || undefined, - error: !!error, - InputLabelProps: { shrink: true }, - size: "medium" - }} - InputProps={{ - startAdornment: <SearchIcon className={styles.iconButton} color="action" />, - endAdornment: <Tooltip title="Search bar syntax help"> - <HelpButton - IconProps={{fontSize: 'small'}} - maxWidth="md" - size="small" - heading="Search bar help" - text={` -The search bar provides a fast way to start formulating queries. -Once you start typing a keyword or a query, suggestions for queries -and metainfo names are given based on your search history and the -available data. This search bar supports the following syntaxes: - -${formatReadmeList}`} - /> - </Tooltip>, - inputRef: inputRef - }} - getOptionLabel={option => option} - filterOptions={(options) => options} - loading={loading} - renderOption={(id) => <Suggestion - suggestion={suggestions[id]} - onDelete={() => removeSuggestion(suggestions[id].key)} - tooltip={suggestions[id].type === SuggestionType.Name ? filterData[suggestions[id].input]?.description : undefined} - />} - /> - </Paper> + return <Box className={clsx(className, styles.root)}> + <Paper className={styles.paper}> + <InputText + value={inputValue || null} + onChange={handleInputChange} + onSelect={handleAccept} + onAccept={handleAccept} + onHighlight={handleHighlight} + suggestions={keys} + disableAcceptOnBlur + autoHighlight={inputValue?.trim() === suggestions[keys[0]]?.input?.trim()} + ListboxComponent={ListboxSuggestion} + TextFieldProps={{ + variant: 'outlined', + placeholder: 'Type your query or keyword here', + label: error || undefined, + error: !!error, + InputLabelProps: { shrink: true }, + size: "medium" + }} + InputProps={{ + startAdornment: <SearchIcon className={styles.iconButton} color="action" />, + endAdornment: <Tooltip title="Search bar syntax help"> + <HelpButton + IconProps={{fontSize: 'small'}} + maxWidth="md" + size="small" + heading="Search bar help" + text={` + The search bar provides a fast way to start formulating queries. + Once you start typing a keyword or a query, suggestions for queries + and metainfo names are given based on your search history and the + available data. This search bar supports the following syntaxes: + + ${formatReadmeList}`} + /> + </Tooltip>, + inputRef: inputRef + }} + getOptionLabel={option => option} + filterOptions={(options) => options} + loading={loading} + renderOption={(id) => <Suggestion + suggestion={suggestions[id]} + onDelete={() => removeSuggestion(suggestions[id].key)} + tooltip={suggestions[id].type === SuggestionType.Name ? filterData[suggestions[id].input]?.description : undefined} + />} + /> + </Paper> + </Box> }) SearchBar.propTypes = { diff --git a/gui/src/components/search/SearchContext.js b/gui/src/components/search/SearchContext.js index b6c87d255f16b72071fb4e5a604b5d8c36bce7d4..ebcdc52944785aaea22ca82a516ff1e48e9e8113 100644 --- a/gui/src/components/search/SearchContext.js +++ b/gui/src/components/search/SearchContext.js @@ -37,6 +37,7 @@ import { isPlainObject, isNil, isSet, + isObject, isFunction, size, cloneDeep @@ -616,7 +617,7 @@ export const SearchContextRaw = React.memo(({ const query = {} for (const key of get(filterNamesState)) { const filter = get(queryFamily(key)) - if (filter !== undefined) { + if (!isNil(filter) && (!isObject(filter) || !isEmpty(filter))) { query[key] = filter } } diff --git a/gui/src/components/search/SearchPage.js b/gui/src/components/search/SearchPage.js index 4845cb1cf41ec2968da080bd79bca6a7674e13f4..6d366f15af8185b0fbbe267f80a8c321f7488076 100644 --- a/gui/src/components/search/SearchPage.js +++ b/gui/src/components/search/SearchPage.js @@ -23,6 +23,7 @@ import { makeStyles } from '@material-ui/core/styles' import FilterMainMenu from './menus/FilterMainMenu' import { collapsedMenuWidth } from './menus/FilterMenu' import SearchBar from './SearchBar' +import Query from './Query.js' import { SearchResultsWithContext } from './SearchResults' import Dashboard from './widgets/Dashboard' import { useSearchContext } from './SearchContext' @@ -52,12 +53,13 @@ const useStyles = makeStyles(theme => { center: { flexGrow: 1, height: '100%', - overflow: 'auto' + overflowY: 'scroll' }, searchBar: { display: 'flex', flexGrow: 0, - zIndex: 1 + zIndex: 1, + marginBottom: theme.spacing(0.3) }, shadow: { pointerEvents: 'none', @@ -103,12 +105,13 @@ const SearchPage = React.memo(({ /> </div> <div className={styles.center} onClick={() => setIsMenuOpen(false)}> - <Box margin={3} paddingBottom={3}> + <Box margin={2.5} paddingBottom={3}> <Box marginBottom={2}> {header} </Box> - <Box marginBottom={1}> + <Box marginBottom={0}> <SearchBar className={styles.searchBar} /> + <Query/> </Box> <Box marginBottom={1} zIndex={0}> <Dashboard/> diff --git a/gui/src/components/search/conftest.spec.js b/gui/src/components/search/conftest.spec.js index 2052e282981591deee38acb6a5071cb989dfb13c..1e1fabe57952918207b128a3eba16e76e66d8b99 100644 --- a/gui/src/components/search/conftest.spec.js +++ b/gui/src/components/search/conftest.spec.js @@ -19,7 +19,7 @@ import React from 'react' import PropTypes from 'prop-types' import assert from 'assert' -import { within, waitFor } from '@testing-library/dom' +import { within, waitFor, waitForElementToBeRemoved } from '@testing-library/dom' import elementData from '../../elementData.json' import { screen, WrapperDefault } from '../conftest.spec' import { render } from '@testing-library/react' @@ -68,15 +68,29 @@ export const renderSearchEntry = (ui, options) => export async function expectFilterTitle(quantity, label, description, unit, disableUnit, root = screen) { const data = defaultFilterData[quantity] let finalLabel = label || data?.label - const finalDescription = description || data?.description if (!disableUnit) { const finalUnit = unit || ( data?.unit && new Unit(data?.unit).toSystem(ui.unit_systems.options.Custom.units).label() ) if (finalUnit) finalLabel = `${finalLabel} (${finalUnit})` } - await root.findAllByText(finalLabel) - expect(root.getAllByTooltip(finalDescription)[0]).toBeInTheDocument() + const labelElement = root.getAllByText(finalLabel)[0] + + // Test that the tooltip appears after hover. The tooltip is only shown if the + // quantity is defined. + if (quantity) { + const finalDescription = description || data?.description + const options = { + name: new RegExp(String.raw`${finalDescription.substring(0, 20)}`) + } + + await userEvent.hover(labelElement) + await waitFor(() => screen.getByRole('tooltip', options)) + + // We need to unhover and wait until tooltip disappears to not disturb other tests. + await userEvent.unhover(labelElement) + await waitForElementToBeRemoved(() => screen.getByRole('tooltip', options)) + } } /** diff --git a/gui/src/components/search/menus/FilterMenu.js b/gui/src/components/search/menus/FilterMenu.js index bfb91ad9da3ea75218cf4a658d473164066eb72a..a53dd697f1f4021606eccc7d4d72d4925214b5ea 100644 --- a/gui/src/components/search/menus/FilterMenu.js +++ b/gui/src/components/search/menus/FilterMenu.js @@ -32,18 +32,13 @@ import { import ArrowForwardIcon from '@material-ui/icons/ArrowForward' import ArrowBackIcon from '@material-ui/icons/ArrowBack' import NavigateNextIcon from '@material-ui/icons/NavigateNext' -import ClearIcon from '@material-ui/icons/Clear' -import ReplayIcon from '@material-ui/icons/Replay' import MoreVert from '@material-ui/icons/MoreVert' -import CodeIcon from '@material-ui/icons/Code' import Scrollable from '../../visualization/Scrollable' -import FilterSummary from '../FilterSummary' import FilterSettings from './FilterSettings' import { Actions, ActionHeader, Action } from '../../Actions' import { useSearchContext } from '../SearchContext' import { pluralize } from '../../../utils' import { isNil } from 'lodash' -import { SourceApiCall, SourceApiDialogButton, SourceDialogDivider, SourceJsonCode } from '../../buttons/SourceDialogButton' // The menu animations use a transition on the 'transform' property. Notice that // animating 'transform' instead of e.g. the 'left' property is much more @@ -287,14 +282,11 @@ export const FilterMenuItems = React.memo(({ className, children }) => { - const { useResetFilters, useRefresh, useApiData } = useSearchContext() + // const { useResetFilters, useRefresh, useApiData } = useSearchContext() const styles = useFilterMenuItemsStyles() const { open, onOpenChange, collapsed, onCollapsedChange } = useContext(filterMenuContext) const [anchorEl, setAnchorEl] = React.useState(null) const isSettingsOpen = Boolean(anchorEl) - const resetFilters = useResetFilters() - const refresh = useRefresh() - const apiData = useApiData() // Callbacks const openMenu = useCallback((event) => { @@ -316,46 +308,6 @@ export const FilterMenuItems = React.memo(({ <FilterMenuHeader title="Filters" actions={<> - <Action - tooltip="Refresh results" - onClick={() => refresh()} - > - <ReplayIcon fontSize="small"/> - </Action> - <Action - tooltip="Clear filters" - onClick={() => resetFilters()} - > - <ClearIcon fontSize="small"/> - </Action> - <Action - tooltip="" - ButtonComponent={SourceApiDialogButton} - ButtonProps={{ - tooltip: "API", - maxWidth: "lg", - fullWidth: true, - icon: <CodeIcon fontSize="small"/>, - ButtonProps: { - size: "small" - } - }} - > - <Typography> - NOMAD uses the same query format throughout its API. This is the query - based on the current filters: - </Typography> - <SourceJsonCode data={{owner: apiData?.body?.owner, query: apiData?.body?.query}}/> - <SourceDialogDivider/> - <Typography> - One application of the above query is this API call. This is what is currently - used to render this page and includes all displayed statistics data - (aggregations). - </Typography> - <SourceApiCall - {...apiData} - /> - </Action> <Action tooltip={'Hide filter menu'} onClick={() => { @@ -467,7 +419,6 @@ const useFilterMenuItemStyles = makeStyles(theme => { export const FilterMenuItem = React.memo(({ id, label, - group, onClick, actions, disableButton, @@ -475,8 +426,6 @@ export const FilterMenuItem = React.memo(({ }) => { const styles = useFilterMenuItemStyles() const theme = useTheme() - const {filterGroups} = useSearchContext() - const groupFinal = group || filterGroups[id] const { selected, open, onChange } = useContext(filterMenuContext) const handleClick = disableButton ? undefined : (onClick || onChange) const opened = open && id === selected @@ -508,7 +457,6 @@ export const FilterMenuItem = React.memo(({ > {actions} </div>} - {groupFinal && <FilterSummary quantities={groupFinal}/>} <Divider className={styles.divider}/> </div> }) @@ -516,7 +464,6 @@ export const FilterMenuItem = React.memo(({ FilterMenuItem.propTypes = { id: PropTypes.string, label: PropTypes.string, - group: PropTypes.string, onClick: PropTypes.func, actions: PropTypes.node, disableButton: PropTypes.bool, diff --git a/gui/src/components/search/widgets/Dashboard.spec.js b/gui/src/components/search/widgets/Dashboard.spec.js index 80078fb1d8f266b70aa74669faf4bd7e007ac734..51e32cdec9a73a3b4d983396c1c11df2213f9d67 100644 --- a/gui/src/components/search/widgets/Dashboard.spec.js +++ b/gui/src/components/search/widgets/Dashboard.spec.js @@ -89,7 +89,6 @@ describe('displaying an initial widget and removing it', () => { { type: 'scatterplot', title: 'Test title', - description: 'Custom scatter plot', x: {quantity: 'results.properties.optoelectronic.solar_cell.open_circuit_voltage'}, y: {quantity: 'results.properties.optoelectronic.solar_cell.efficiency'}, markers: {color: {quantity: 'results.properties.optoelectronic.solar_cell.short_circuit_current_density'}}, diff --git a/gui/src/components/search/widgets/WidgetScatterPlot.js b/gui/src/components/search/widgets/WidgetScatterPlot.js index 727245e9e8b25ca253ef2730611a54a56b42c283..84efcd71be70b6d08dede7e870aed3f0507e8a5f 100644 --- a/gui/src/components/search/widgets/WidgetScatterPlot.js +++ b/gui/src/components/search/widgets/WidgetScatterPlot.js @@ -377,7 +377,7 @@ export const WidgetScatterPlot = React.memo(( <Widget id={id} title={title || "Scatter plot"} - description={description || 'Custom scatter plot'} + description={description} onEdit={handleEdit} actions={actions} className={styles.widget} diff --git a/gui/src/components/visualization/Ellipsis.js b/gui/src/components/visualization/Ellipsis.js index c7c4eca49ab71b2eaeac55d7e45ff46fcbc86102..806c0b75aa481c21fd4f748a0312afc26ec9c2b7 100644 --- a/gui/src/components/visualization/Ellipsis.js +++ b/gui/src/components/visualization/Ellipsis.js @@ -15,14 +15,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react' +import React, { useEffect, useState, useCallback } from 'react' import PropTypes from 'prop-types' import clsx from 'clsx' -import { makeStyles } from '@material-ui/core' +import { makeStyles, Tooltip } from '@material-ui/core' +import { useResizeDetector } from 'react-resize-detector' /** * Component for displaying text that should be truncated with an Ellipsis - * either in front of the text or after the text. + * either in front of the text or after the text. Can lso show the full text on + * hover as a tooltip. */ const useStyles = makeStyles(theme => ({ root: { @@ -42,14 +44,39 @@ const useStyles = makeStyles(theme => ({ } })) -const Ellipsis = React.memo(({front, children}) => { +const Ellipsis = React.memo(({front, children, tooltip}) => { const styles = useStyles() - return <span className={clsx(styles.root, front && styles.ellipsisFront)}>{children}</span> + const {width, ref} = useResizeDetector() + const [showTooltip, setShowTooltip] = useState(false) + + // Used to determine whether to show the tooltip or not + const compareSize = useCallback(() => { + if (!ref?.current) return + const compare = + ref.current.scrollWidth > ref.current.clientWidth + setShowTooltip(compare) + }, [ref]) + + // Whenever the component width changes, check if tooltip should be shown + useEffect(() => { compareSize() }, [width, compareSize]) + + // Define state and function to update the value + return <Tooltip + title={showTooltip ? tooltip : ''} + disableHoverListener={!showTooltip} + > + <span ref={ref} className={clsx(styles.root, front && styles.ellipsisFront)}>{children}</span> + </Tooltip> }) Ellipsis.propTypes = { children: PropTypes.node, + tooltip: PropTypes.string, front: PropTypes.bool } +Ellipsis.defaultProps = { + tooltip: '' +} + export default Ellipsis