Commit 9fc526b7 authored by Markus Scheidgen's avatar Markus Scheidgen
Browse files

Merge branch 'gui-fixes' into 'v1.0.2'

Resolve "GUI search fixes"

See merge request !555
parents e6b235d7 0496b7a9
Pipeline #121547 passed with stages
in 24 minutes and 29 seconds
......@@ -15,7 +15,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isNil } from 'lodash'
import { isNil, isArray } from 'lodash'
import { setToArray, getDatatype, getSerializer, getDeserializer, getLabel } from '../../utils'
import searchQuantities from '../../searchQuantities'
import { getDimension, Quantity } from '../../units'
......@@ -51,6 +51,26 @@ export const labelDataset = 'Dataset'
export const labelIDs = 'IDs'
export const labelArchive = 'Archive'
/**
* Used to gather a list of fixed filter options from the metainfo.
* @param {*} quantity Metainfo name
* @returns Dictionary containing the available options and their labels.
*/
function getEnumOptions(quantity) {
const metainfoOptions = searchQuantities?.[quantity]?.type?.type_data
if (isArray(metainfoOptions) && metainfoOptions.length > 0) {
const opt = {}
for (const name of metainfoOptions) {
opt[name] = {label: name}
}
// We do not display the option for 'not processed': it is more of a
// debug value
delete opt['not processed']
return opt
}
}
/**
* This function is used to register a new filter within the SearchContext.
* Filters are entities that can be searched through the filter panel and the
......@@ -118,21 +138,34 @@ function saveFilter(name, group, config) {
}
const data = filterData[name] || {}
data.options = config.options || getEnumOptions(name)
const agg = config.agg
if (agg) {
// Notice how here we have to introduce another inner function in order to
// get the value of "name" and "type" at the time this function is created.
data.aggGet = agg.get || ((name, type) => (aggs) => aggs[name][type].data)(name, agg)
data.aggSet = agg.set || {[name]: {[agg]: {}}}
// If a list of explicit options is given we will only include them in the
// aggregations. This ensures that the GUI is not messed up due to
// unexpected aggregation values.
if (agg.set) {
data.aggSet = agg.set
} else {
if (data.options && agg === 'terms') {
data.aggSet = {[name]: {[agg]: {include: Object.keys(data.options)}}}
} else {
data.aggSet = {[name]: {[agg]: {}}}
}
}
}
if (config.value) {
data.valueSet = config.value.set
}
data.aggDefaultSize = config.aggDefaultSize
data.aggDefaultSize = config.aggDefaultSize || (config.options && Object.keys(config.options).length)
data.multiple = config.multiple === undefined ? true : config.multiple
data.exclusive = config.exclusive === undefined ? true : config.exclusive
data.stats = config.stats
data.options = config.options
data.unit = config.unit || searchQuantities[name]?.unit
data.minOverride = config.minOverride
data.maxOverride = config.maxOverride
......@@ -155,7 +188,8 @@ function saveFilter(name, group, config) {
data.dimension = getDimension(data.unit)
data.deserializer = getDeserializer(data.dtype, data.dimension)
data.label = config.label || getLabel(name)
data.section = !isNil(searchQuantities[name]?.nested)
data.nested = searchQuantities[name]?.nested
data.section = !isNil(data.nested)
data.description = config.description || searchQuantities[name]?.description
data.scale = config.scale || 1
if (data.queryMode && !data.multiple) {
......@@ -211,6 +245,7 @@ function registerFilterOptions(name, group, target, label, description, options)
},
multiple: true,
exclusive: false,
queryMode: 'all',
options: options,
label: label,
description: description
......@@ -277,8 +312,8 @@ registerFilter('results.method.simulation.dft.xc_functional_type', labelDFT, {..
registerFilter('results.method.simulation.dft.xc_functional_names', labelDFT, {...termQuantityNonExclusive, scale: 1 / 2, label: 'XC Functional Names'})
registerFilter('results.method.simulation.dft.relativity_method', labelDFT, termQuantity)
registerFilter('results.method.simulation.gw.type', labelGW, {...termQuantity, label: 'GW Type'})
registerFilter('external_db', labelAuthor, {...termQuantity, label: 'External Database'})
registerFilter('authors.name', labelAuthor, {...termQuantity, label: 'Author Name'})
registerFilter('external_db', labelAuthor, {...termQuantity, label: 'External Database', scale: 1 / 4})
registerFilter('authors.name', labelAuthor, {...termQuantityNonExclusive, label: 'Author Name'})
registerFilter('upload_create_time', labelAuthor, rangeQuantity)
registerFilter('datasets.dataset_name', labelDataset, {...termQuantity, label: 'Dataset Name', aggDefaultSize: 10})
registerFilter('datasets.doi', labelDataset, {...noAggQuantity, label: 'Dataset DOI'})
......@@ -299,16 +334,8 @@ registerFilter(
stats: listStatConfig,
agg: {
set: {
'results.properties.spectroscopy.eels.min_energy': {
min_max: {
exclude: (updated) => updated?.has('results.properties.spectroscopy.eels.energy_window')
}
},
'results.properties.spectroscopy.eels.max_energy': {
min_max: {
exclude: (updated) => updated?.has('results.properties.spectroscopy.eels.energy_window')
}
}
'results.properties.spectroscopy.eels.min_energy': {min_max: {}},
'results.properties.spectroscopy.eels.max_energy': {min_max: {}}
},
get: (aggs) => {
const min = aggs['results.properties.spectroscopy.eels.min_energy']
......@@ -383,7 +410,7 @@ registerFilter(
registerFilter(
'results.properties.available_properties',
labelProperties,
noAggQuantity
{noAggQuantity, multiple: true, exclusive: false, queryMode: 'all'}
)
registerFilter(
'results.properties.mechanical.energy_volume_curve',
......
......@@ -111,7 +111,6 @@ export const SearchContext = React.memo(({
const apiQueue = useRef([])
const apiMap = useRef({})
const updatedFilters = useRef(new Set())
const refreshFilters = useRef(new Set())
const firstLoad = useRef(true)
const disableUpdate = useRef(false)
......@@ -749,7 +748,7 @@ export const SearchContext = React.memo(({
* function, as it is the final one that gets called after the debounce
* interval.
*/
const apiCall = useCallback((query, aggs, pagination, queryChanged, paginationChanged, updateAggs, refresh = false, callback = undefined) => {
const apiCall = useCallback((query, aggs, pagination, queryChanged, paginationChanged, updateAggs, callback = undefined) => {
// Create the final search object.
const aggsToUpdate = Object.keys(aggs).filter(key => aggs[key].update)
const aggsChanged = Object.keys(aggs).filter(key => aggs[key].changed)
......@@ -766,7 +765,6 @@ export const SearchContext = React.memo(({
query: toAPIFilter(apiQuery, resource),
aggregations: toAPIAgg(
aggs,
refresh ? refreshFilters.current : updatedFilters.current,
resource
),
pagination: {...pagination}
......@@ -797,11 +795,7 @@ export const SearchContext = React.memo(({
aggsToUpdate.forEach((agg) => { updatedAggsMap.current[agg] = aggs[agg] })
}
// The list of updated filters are always reset, and the list of updated
// filters is stored for refresh purposes (when refreshing, the last updated
// filters need to be marked with exclude_from_search in order to not modify
// the currently shown aggregation results)
refreshFilters.current = updatedFilters.current
// The list of updated filters are always reset.
updatedFilters.current = new Set()
firstLoad.current = false
oldQuery.current = query
......@@ -837,7 +831,7 @@ export const SearchContext = React.memo(({
* - Calls are debounced when necessary
* - API calls are made only if necessary
*/
const apiCallInterMediate = useCallback((query, aggs, pagination, refresh = false, callback = undefined) => {
const apiCallInterMediate = useCallback((query, aggs, pagination, callback = undefined, forceUpdate = false) => {
if (disableUpdate.current) {
disableUpdate.current = false
return
......@@ -848,9 +842,14 @@ export const SearchContext = React.memo(({
// about what will be updated by this query. This ensures that the next query
// immediately knows the current state even before the API call is finished
// (or even if no API call is made).
const queryChanged = query !== oldQuery.current
const queryChanged = forceUpdate ? true : query !== oldQuery.current
const paginationChanged = pagination !== oldPagination.current
const [reducedAggs, updateAggs] = reduceAggs(aggs, updatedAggsMap.current, queryChanged)
let [reducedAggs, updateAggs] = reduceAggs(
aggs,
updatedAggsMap.current,
queryChanged,
updatedFilters.current
)
// If the query and pagination has not changed AND aggregations do not need
// to be updated, no update is necessary. The API calls is made immediately
......@@ -858,9 +857,9 @@ export const SearchContext = React.memo(({
// when only aggregations need to be updated. Otherwise it is debounced.
if (paginationChanged || queryChanged || updateAggs) {
if (firstLoad.current || paginationChanged || !queryChanged) {
apiCall(query, reducedAggs, pagination, queryChanged, paginationChanged, updateAggs, false, callback)
apiCall(query, reducedAggs, pagination, queryChanged, paginationChanged, updateAggs, callback)
} else {
apiCallDebounced(query, reducedAggs, pagination, queryChanged, paginationChanged, updateAggs, false, callback)
apiCallDebounced(query, reducedAggs, pagination, queryChanged, paginationChanged, updateAggs, callback)
}
} else {
callback && callback(undefined, undefined)
......@@ -877,15 +876,12 @@ export const SearchContext = React.memo(({
const query = useRecoilValue(queryState)
const aggs = useRecoilValue(aggsState)
const pagination = useRecoilValue(paginationState)
const queryChanged = true
const paginationChanged = false
const updateAggs = true
const refresh = useCallback(() => {
apiCallDebounced(query, aggs, pagination, queryChanged, paginationChanged, updateAggs, true)
}, [aggs, pagination, paginationChanged, query, queryChanged, updateAggs])
apiCallInterMediate(query, aggs, pagination, undefined, true)
}, [aggs, pagination, query])
return refresh
}, [aggsState, apiCallDebounced, paginationState, queryState])
}, [aggsState, apiCallInterMediate, paginationState, queryState])
// Hook for imperatively requesting aggregation data. By using this hook you
// can track the state of individual calls and perform callbacks.
......@@ -897,16 +893,14 @@ export const SearchContext = React.memo(({
/**
* @param {number} size The new aggregation size
* @param {string} id Identifier for this call
* @param {boolean} update Whether to mark the filter as being updated
* @param {function} callback: Function that returns an array containing the
* new aggregation response and an error if one was encountered. Returns the
* special value 'undefined' for the response if no update was necessary.
*/
const aggCall = useCallback((size, id, update, callback) => {
update && updatedFilters.current.add(name)
const aggCall = useCallback((size, id, callback) => {
const aggMap = {[id]: {size, update: true}}
const aggs = {[name]: aggMap}
apiCallInterMediate(query, aggs, pagination, false, (response) => callback(response && response[name]))
apiCallInterMediate(query, aggs, pagination, (response) => callback(response && response[name]))
// We also need to update aggregation request state, otherwise the
// subsequent calls will not be able to know what was done by this call.
......@@ -1140,28 +1134,37 @@ export function toAPIFilter(query, resource) {
}
// Perform custom transformations
function customize(key, value) {
function customize(key, value, parent, subKey = undefined) {
const data = filterDataGlobal[key]
// Filters that affect the GUI only do not need to be considered
const guiOnly = data?.guiOnly
if (guiOnly) {
return
}
// Sections need to be recursively handled. Notice that we cant directly
// write to an recoil Atom and create a new object for storing the values.
const section = data?.section
if (section) {
const sectionData = {}
for (let [keyNested, valueNested] of Object.entries(value)) {
customize(`${key}.${keyNested}`, valueNested)
customize(`${key}.${keyNested}`, valueNested, sectionData, keyNested)
}
parent[key] = sectionData
// Regular values are set directly to the parent. A custom setter may be
// used.
} else {
const guiOnly = data?.guiOnly
const setter = data?.valueSet
if (guiOnly) {
return
}
if (setter) {
setter(queryCustomized, query, value)
} else {
queryCustomized[key] = value
parent[subKey || key] = value
}
}
}
for (let [k, v] of Object.entries(query)) {
customize(k, v)
customize(k, v, queryCustomized)
}
// Create the API-compatible keys and values.
......@@ -1354,25 +1357,23 @@ export function toGUIFilterSingle(key, value, units = undefined, path = undefine
*
* @returns {object} Aggregation query that is usable by the API.
*/
function toAPIAgg(aggs, updatedFilters, resource) {
function toAPIAgg(aggs, resource) {
const apiAggs = {}
for (const [key, value] of Object.entries(aggs)) {
if (value.update) {
const agg = aggs[key]
const aggSet = filterDataGlobal[key].aggSet
const exclusive = filterDataGlobal[key].exclusive
if (aggSet) {
for (const [quantity, data] of Object.entries(aggSet)) {
// If filter has been updated and the filter values are exclusive, the
// filter is excluded from the aggregation.
for (const [type, options] of Object.entries(data)) {
const exclude = options.exclude
? options.exclude(updatedFilters)
: filterDataGlobal[key].exclusive
const name = resource === 'materials' ? materialNames[quantity.split(':')[0]] : quantity
const apiAgg = apiAggs[name] || {}
apiAgg[type] = {
quantity: name,
exclude_from_search: exclude,
// Exclusive quantities (quantities that have one value per entry) are
// always fetched with exclude_from_search
exclude_from_search: exclusive,
...options,
size: agg.size
}
......@@ -1444,7 +1445,7 @@ function toGUIAgg(aggs, filters, resource) {
*
* @returns {object} Reduced aggregation config.
*/
function reduceAggs(aggs, oldAggs, queryChanged) {
function reduceAggs(aggs, oldAggs, queryChanged, updatedFilters) {
const reducedAggs = {}
let updateAggs = false
for (let [key, agg] of Object.entries(aggs)) {
......@@ -1477,6 +1478,13 @@ function reduceAggs(aggs, oldAggs, queryChanged) {
}
}
}
// If the filter is exclusive, and ONLY it has been modified in this query,
// we do not update it's aggregation.
if (filterDataGlobal[key].exclusive && updatedFilters.has(key) && updatedFilters.size === 1) {
update = false
}
const newAgg = {update, changed}
if (update) {
updateAggs = true
......
......@@ -21,14 +21,13 @@ import { Button, Tooltip } from '@material-ui/core'
import PropTypes from 'prop-types'
import clsx from 'clsx'
import { useRecoilValue } from 'recoil'
import searchQuantities from '../../../searchQuantities'
import InputHeader from './InputHeader'
import InputTooltip from './InputTooltip'
import InputItem, { inputItemHeight } from './InputItem'
import InputUnavailable from './InputUnavailable'
import Placeholder from '../../visualization/Placeholder'
import { useSearchContext } from '../SearchContext'
import { isNil, isArray } from 'lodash'
import { isNil } from 'lodash'
import LoadingButton from '../../buttons/LoadingButton'
import { guiState } from '../../GUIMenu'
import { InputTextQuantity } from './InputText'
......@@ -100,39 +99,21 @@ const InputField = React.memo(({
const aggCollapse = useRecoilValue(guiState('aggCollapse'))
const [scale, setScale] = useState(initialScale || filterData[quantity].scale)
// Check if the metainfo defines the available options and if so, what is the
// maximum number of options.
const metainfoOptions = useMemo(() => {
const metainfoOptions = searchQuantities?.[quantity]?.type?.type_data
if (isArray(metainfoOptions) && metainfoOptions.length > 0) {
const opt = {}
for (const name of metainfoOptions) {
opt[name] = {label: name}
}
return opt
}
}, [quantity])
// See if the filter has a fixed amount of options. These may have been
// explicitly provided or defined in the metainfo. If you explicitly specify
// an initialSize, any fixed options are ignored and the data is retrieved
// through and aggregation (this is done because the top aggregations may not
// through an aggregation (this is done because the top aggregations may not
// match the list of explicit options).
const fixedOptions = useMemo(() => {
if (!isNil(initialSize)) {
return
}
const registryOptions = filterData[quantity].options
if (registryOptions) {
return registryOptions
}
return metainfoOptions
}, [initialSize, filterData, quantity, metainfoOptions])
return isNil(initialSize)
? filterData[quantity]?.options
: undefined
}, [initialSize, filterData, quantity])
const nFixedOptions = fixedOptions && Object.keys(fixedOptions).length
const minSize = disableOptions ? 0 : initialSize || nFixedOptions || filterData[quantity]?.aggDefaultSize
const [requestedAggSize, setRequestedAggSize] = useState(minSize)
const nMaxOptions = metainfoOptions && Object.keys(metainfoOptions).length
const nMaxOptions = fixedOptions && Object.keys(fixedOptions).length
const incr = useState(increment || minSize)[0]
const [loading, setLoading] = useState(false)
const agg = useAgg(quantity, visible && !disableOptions, minSize)
......@@ -146,10 +127,7 @@ const InputField = React.memo(({
// Form the final list of options. If no fixed options are available, the
// options are gathered from the aggregation.
const finalOptions = useMemo(() => {
// We do not display the option for 'not processed': it is more of a
// debug value
if (fixedOptions) {
delete fixedOptions['not processed']
return fixedOptions
}
if (agg?.data) {
......@@ -194,7 +172,7 @@ const InputField = React.memo(({
const handleShowMore = useCallback(() => {
setLoading(true)
let newSize = requestedAggSize + incr
aggCall(newSize, 'scroll', true, (response, error) => {
aggCall(newSize, 'scroll', (response, error) => {
if (response?.data?.length === requestedAggSize) {
newSize = requestedAggSize
}
......@@ -207,7 +185,7 @@ const InputField = React.memo(({
const handleShowLess = useCallback(() => {
setRequestedAggSize(old => {
const newSize = Math.max(old - incr, minSize)
aggCall(newSize, 'scroll', true, () => {})
aggCall(newSize, 'scroll', () => {})
return newSize
})
}, [aggCall, incr, minSize])
......
......@@ -249,7 +249,7 @@ const InputSlider = React.memo(({
// power of ten (in the current unit system) is used.
const rangeSI = maxLocal - minLocal
const rangeCustom = toUnitSystem(rangeSI, unitSI, units)
const stepFinalCustom = Math.pow(10, (Math.ceil(Math.log10(rangeCustom / nSteps))))
const stepFinalCustom = Math.pow(10, (Math.floor(Math.log10(rangeCustom / nSteps))))
const stepFinalSI = toSI(stepFinalCustom, units[getDimension(unitSI)])
const stepFinal = stepSI || stepFinalSI || undefined
......@@ -322,7 +322,7 @@ InputSlider.propTypes = {
}
InputSlider.defaultProps = {
nSteps: 20
nSteps: 10
}
export default InputSlider
......@@ -68,7 +68,6 @@ const FilterSubMenuMechanical = React.memo(({
<InputField
quantity="results.properties.mechanical.shear_modulus.type"
visible={visible}
initialSize={3}
disableSearch
/>
<InputSlider
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment