diff --git a/gui/src/components/search/Query.js b/gui/src/components/search/Query.js index 972de233c6d389712621db0cd6667f50703489b1..5443482cb228e20c8a3f5e3d9fc193002478fac2 100644 --- a/gui/src/components/search/Query.js +++ b/gui/src/components/search/Query.js @@ -198,14 +198,15 @@ export const QueryCurlyBracketRight = React.memo((props) => { }) // Custom function for chip creation -const createChips = (name, filterValue, onDelete, filterData, units) => { +const createChips = (name, filterValue, onDelete, filterData, units, queryModes) => { if (isNil(filterValue)) return [] const { serializerPretty: serializer, customSerialization, queryMode } = filterData[name] + const finalQueryMode = queryModes?.[name] || queryMode const isArray = Array.isArray(filterValue) const isSet = filterValue instanceof Set const isObj = isPlainObject(filterValue) - const op = queryMode === "any" ? <QueryOr/> : <QueryAnd/> + const op = finalQueryMode === "any" ? <QueryOr/> : <QueryAnd/> const chips = [] const createChip = (label, onDelete, single = false) => ( @@ -278,7 +279,8 @@ const useStyles = makeStyles(theme => ({ } })) const QueryChips = React.memo(({ className, classes }) => { - const { filterData, useQuery, useUpdateFilter } = useSearchContext() + const { filterData, useQuery, useUpdateFilter, useQueryModes } = useSearchContext() + const queryModes = useQueryModes() const query = useQuery() const updateFilter = useUpdateFilter() const theme = useTheme() @@ -307,7 +309,7 @@ const QueryChips = React.memo(({ className, classes }) => { } newChips.push( <React.Fragment key={`${quantity}.${key}`}> - {createChips(`${quantity}.${key}`, value, onDelete, filterData, units)} + {createChips(`${quantity}.${key}`, value, onDelete, filterData, units, queryModes)} </React.Fragment> ) }) @@ -322,12 +324,12 @@ const QueryChips = React.memo(({ className, classes }) => { // Regular chips get their own group } else { const onDelete = (newValue) => updateFilter([quantity, newValue]) - chips.push(createChips(quantity, filterValue, onDelete, filterData, units)) + chips.push(createChips(quantity, filterValue, onDelete, filterData, units, queryModes)) } } return chips - }, [query, filterData, units, updateFilter]) + }, [query, filterData, units, updateFilter, queryModes]) return ( <div className={clsx(className, styles.root)}> diff --git a/gui/src/components/search/SearchContext.js b/gui/src/components/search/SearchContext.js index 785b63db51490b938515664903199051f58ef3d6..8a4909a550b28baf34e0021af05e4264d6ae7a2f 100644 --- a/gui/src/components/search/SearchContext.js +++ b/gui/src/components/search/SearchContext.js @@ -425,7 +425,7 @@ export const SearchContextRaw = React.memo(({ useFilterMaps, useResetFilters, useQueryString, - useDynamicQueryModes, + useQueryModes, aggsFamily, useWidgetValue, useSetWidget, @@ -550,8 +550,8 @@ export const SearchContextRaw = React.memo(({ default: (name) => initialQuery[name] }) - const dynamicQueryModesFamily = atomFamily({ - key: `dynamicQueryModesFamily_${contextID}`, + const queryModesFamily = atomFamily({ + key: `queryModesFamily_${contextID}`, default: (name) => undefined }) @@ -637,32 +637,22 @@ export const SearchContextRaw = React.memo(({ /** * A Recoil.js selector that return the dynamic query modes state */ - const dynamicQueryModesState = selector({ - key: `dynamicQueryModes_${contextID}`, + const queryModesState = selector({ + key: `queryModes_${contextID}`, get: ({get}) => { - const query = {} + const queryModes = {} for (const key of get(filterNamesState)) { - const filter = get(dynamicQueryModesFamily(key)) - if (filter !== undefined) { - query[key] = filter - } - } - return query - }, - set: ({ set, get }, data) => { - for (const filter of get(filterNamesState)) { - set(dynamicQueryModesFamily(filter), undefined) - } - if (data) { - for (const [key, value] of Object.entries(data)) { - set(dynamicQueryModesFamily(key), value) + const queryMode = get(queryModesFamily(key)) + if (queryMode !== undefined) { + queryModes[key] = queryMode } } + return queryModes } }) - function useDynamicQueryModes() { - return useRecoilValue(dynamicQueryModesState) + function useQueryModes() { + return useRecoilValue(queryModesState) } const widgetFamily = atomFamily({ @@ -898,11 +888,11 @@ export const SearchContextRaw = React.memo(({ const section = sectionContext?.section const subname = useMemo(() => section ? name.slice(section.length + 1) : undefined, [name, section]) const setter = useSetRecoilState(queryFamily(section || name)) - const dynamicQueryModeSetter = useSetRecoilState(dynamicQueryModesFamily(section || name)) + const queryModeSetter = useSetRecoilState(queryModesFamily(section || name)) return useCallback((value, config = undefined) => { updatedFilters.current.add(name) - dynamicQueryModeSetter(config?.queryMode) + queryModeSetter(config?.queryMode) section ? setter(old => { const newValue = isNil(old) ? {} : {...old} @@ -915,7 +905,7 @@ export const SearchContextRaw = React.memo(({ : setter(isFunction(value) ? (old) => clearEmpty(value(old)) : clearEmpty(value)) - }, [name, dynamicQueryModeSetter, section, setter, subname]) + }, [name, queryModeSetter, section, setter, subname]) } /** @@ -957,7 +947,7 @@ export const SearchContextRaw = React.memo(({ const reset = useRecoilCallback(({set}) => () => { for (const filter of filterNames) { set(queryFamily(filter), undefined) - set(dynamicQueryModesFamily(filter), undefined) + set(queryModesFamily(filter), undefined) } }, []) return reset @@ -1137,7 +1127,7 @@ export const SearchContextRaw = React.memo(({ const useUpdateFilter = () => { return useRecoilCallback(({set}) => ([key, value, queryMode]) => { set(queryFamily(key), value) - if (queryMode) set(dynamicQueryModesFamily(key), queryMode) + if (queryMode) set(queryModesFamily(key), queryMode) }, []) } @@ -1167,7 +1157,7 @@ export const SearchContextRaw = React.memo(({ useFilters, useResetFilters, useQueryString, - useDynamicQueryModes, + useQueryModes, aggsFamily, useWidgetValue, useSetWidget, @@ -1233,7 +1223,7 @@ export const SearchContextRaw = React.memo(({ const updateAggsResponse = useSetAggsResponse() const aggs = useAggs() const query = useQuery() - const dynamicQueryModes = useDynamicQueryModes() + const queryModes = useQueryModes() const filtersLocked = useFiltersLocked() const required = useRequired() const resultsUsed = useResultsUsed() @@ -1327,8 +1317,8 @@ export const SearchContextRaw = React.memo(({ // The locked filters are applied as a parallel AND query. This is the only // way to consistently apply them. If we mix them inside 'regular' filters, // they can be accidentally overwritten with an OR statement. - const customQuery = convertQueryGUIToAPI(apiQuery, resource, filtersData, dynamicQueryModes) - const lockedQuery = convertQueryGUIToAPI(filtersLocked, resource, filtersData, dynamicQueryModes) + const customQuery = convertQueryGUIToAPI(apiQuery, resource, filtersData, queryModes) + const lockedQuery = convertQueryGUIToAPI(filtersLocked, resource, filtersData, queryModes) let finalQuery = customQuery if (!isEmpty(lockedQuery)) { @@ -1418,7 +1408,7 @@ export const SearchContextRaw = React.memo(({ } resolve({undefined, ...resolveArgs}) }) - }, [filtersData, filterDefaults, filtersLocked, resource, api, raiseError, resolve, dynamicQueryModes, setApiQuery]) + }, [filtersData, filterDefaults, filtersLocked, resource, api, raiseError, resolve, queryModes, setApiQuery]) // This is a debounced version of apiCall. const apiCallDebounced = useMemo(() => debounce(apiCall, debounceTime), [apiCall]) @@ -1714,6 +1704,7 @@ export const SearchContextRaw = React.memo(({ useApiData, useApiQuery, useQuery, + useQueryModes, useSetPagination, useParseQuery, useParseQueries, @@ -1790,6 +1781,7 @@ export const SearchContextRaw = React.memo(({ useRemoveAgg, useAggs, useQuery, + useQueryModes, useSetFilters, useUpdateFilter, apiCallInterMediate, diff --git a/gui/src/components/search/input/InputTerms.js b/gui/src/components/search/input/InputTerms.js index e71af1169764c90f0f993f44a9ed4684627c24cc..72eea032c407c93f55e4441793bee6ef68f86b84 100644 --- a/gui/src/components/search/input/InputTerms.js +++ b/gui/src/components/search/input/InputTerms.js @@ -93,6 +93,7 @@ const InputTerms = React.memo(({ showStatistics, showSuggestions, options, + queryMode, sortStatic, className, classes, @@ -230,8 +231,8 @@ const InputTerms = React.memo(({ const checked = Object.entries(newOptions) .filter(([key, value]) => value.checked) .map(([key, value]) => getFinalKey(key, filterData[searchQuantity]?.dtype)) - setFilter(new Set(checked)) - }, [setFilter, visibleOptions, filterData, searchQuantity]) + setFilter(new Set(checked), {queryMode: {and: 'all', or: 'any'}[queryMode]}) + }, [setFilter, visibleOptions, filterData, searchQuantity, queryMode]) // Create the search component const searchComponent = useMemo(() => { @@ -396,6 +397,7 @@ InputTerms.propTypes = { showHeader: PropTypes.bool, // Whether to show the header showStatistics: PropTypes.bool, // Whether to show statistics showSuggestions: PropTypes.bool, // Whether to show the text field suggestions + queryMode: PropTypes.string, className: PropTypes.string, classes: PropTypes.object, 'data-testid': PropTypes.string diff --git a/gui/src/components/search/widgets/WidgetTerms.js b/gui/src/components/search/widgets/WidgetTerms.js index 050812a9ce348712f4b9a38e148751af97dda40a..5eeb11cf096dab3952e4fdc40dcb6396f752c350 100644 --- a/gui/src/components/search/widgets/WidgetTerms.js +++ b/gui/src/components/search/widgets/WidgetTerms.js @@ -45,6 +45,11 @@ import { scales } from '../../plotting/common' // Predefined in order to not break memoization const dtypes = new Set([DType.String, DType.Enum, DType.Boolean]) +const queryModes = { + 'and': 'and', + 'or': 'or' +} + /** * Displays a terms widget. */ @@ -105,6 +110,7 @@ export const WidgetTerms = React.memo(( search_quantity, scale, show_input, + query_mode, className, 'data-testid': testID }) => { @@ -141,8 +147,8 @@ export const WidgetTerms = React.memo(( const newValue = new Set(old) selected ? newValue.add(key) : newValue.delete(key) return newValue - }) - }, [setFilter]) + }, {queryMode: {and: 'all', or: 'any'}[query_mode]}) + }, [setFilter, query_mode]) const handleEdit = useCallback(() => { setWidget(old => { return {...old, editing: true } }) @@ -249,6 +255,7 @@ WidgetTerms.propTypes = { scale: PropTypes.string, autorange: PropTypes.bool, show_input: PropTypes.bool, + query_mode: PropTypes.string, className: PropTypes.string, 'data-testid': PropTypes.string } @@ -348,6 +355,20 @@ export const WidgetTermsEdit = React.memo((props) => { onChange={(event) => handleChange('title', event.target.value)} /> </WidgetEditOption> + <WidgetEditOption> + <TextField + select + fullWidth + label="Query mode" + variant="filled" + value={settings.query_mode} + onChange={(event) => { handleChange('query_mode', event.target.value) }} + > + {Object.keys(queryModes).map((key) => + <MenuItem value={key} key={key}>{key}</MenuItem> + )} + </TextField> + </WidgetEditOption> <WidgetEditOption> <FormControlLabel control={<Checkbox checked={settings.show_input} onChange={(event, value) => handleChange('show_input', value)}/>} @@ -367,11 +388,13 @@ WidgetTermsEdit.propTypes = { nbins: PropTypes.number, autorange: PropTypes.bool, show_input: PropTypes.bool, + query_mode: PropTypes.bool, onClose: PropTypes.func } export const schemaWidgetTerms = schemaWidget.shape({ search_quantity: string().required('Search quantity is required.'), scale: string().required('Scale is required.'), - show_input: bool() + show_input: bool(), + query_mode: string() }) diff --git a/nomad/config/models/ui.py b/nomad/config/models/ui.py index 345ead0c6d53a160561c17d0f60c183e7d3ea545..682ec2364b2865d49c64a8858b3403525b3a34b7 100644 --- a/nomad/config/models/ui.py +++ b/nomad/config/models/ui.py @@ -574,6 +574,11 @@ class Axis(AxisScale, AxisQuantity): """Configuration for a plot axis with limited scaling options.""" +class QueryModeEnum(str, Enum): + AND = 'and' + OR = 'or' + + class TermsBase(ConfigBaseModel): """Base model for configuring terms components.""" @@ -591,6 +596,10 @@ class TermsBase(ConfigBaseModel): None, deprecated='The "showinput" field is deprecated, use "show_input" instead.', ) + query_mode: QueryModeEnum | None = Field( + None, + description='The query mode to use when multiple terms are selected.', + ) @model_validator(mode='before') @classmethod