diff --git a/app/api/chemotion/search_api.rb b/app/api/chemotion/search_api.rb index 12aaac2d76..8b791eb438 100644 --- a/app/api/chemotion/search_api.rb +++ b/app/api/chemotion/search_api.rb @@ -34,7 +34,7 @@ class SearchAPI < Grape::API optional :name, type: String optional :advanced_params, type: Array do optional :link, type: String, values: ['', 'AND', 'OR'], default: '' - optional :match, type: String, values: ['=', 'LIKE', 'ILIKE', 'NOT LIKE', 'NOT ILIKE', '>', '<', '>=', '@>', '<@'], default: 'LIKE' + optional :match, type: String, values: ['=', 'LIKE', 'ILIKE', 'NOT LIKE', 'NOT ILIKE', '>', '<', '>=', '<=', '@>', '<@'], default: 'LIKE' optional :table, type: String, values: %w[samples reactions wellplates screens research_plans elements segments literatures] optional :element_id, type: Integer optional :unit, type: String @@ -42,6 +42,7 @@ class SearchAPI < Grape::API requires :value, type: String optional :smiles, type: String optional :sub_values, type: Array + optional :available_options, type: Array end optional :id_params, type: Hash do requires :model_name, type: String, values: %w[ diff --git a/app/packs/src/components/searchModal/forms/DetailSearch.js b/app/packs/src/components/searchModal/forms/DetailSearch.js index 0dae2833af..c680157739 100644 --- a/app/packs/src/components/searchModal/forms/DetailSearch.js +++ b/app/packs/src/components/searchModal/forms/DetailSearch.js @@ -11,6 +11,7 @@ import UIStore from 'src/stores/alt/stores/UIStore'; import { observer } from 'mobx-react'; import { StoreContext } from 'src/stores/mobx/RootStore'; import { ionic_liquids } from 'src/components/staticDropdownOptions/ionic_liquids'; +import { convertTemperature } from 'src/utilities/UnitsConversion'; import * as FieldOptions from 'src/components/staticDropdownOptions/options'; const DetailSearch = () => { @@ -45,6 +46,7 @@ const DetailSearch = () => { smiles: '', sub_values: [], unit: '', + available_options: [], validationState: null }]; @@ -194,7 +196,7 @@ const DetailSearch = () => { } if (value[0] === 'analyses') { fieldsByTab.push(...analysesData); - // pushDatasetsToAnalysesFields(); + pushDatasetsToAnalysesFields(); } if (value[0] === 'inventory') { fieldsByTab.push(...inventoryData); @@ -593,18 +595,16 @@ const DetailSearch = () => { case 'value_measurement': case 'solvent_ratio': case 'molecular_mass': - return '>='; + return searchStore.numeric_match; case 'unit_measurement': case 'solvent_smiles': return '='; default: - return type == 'system-defined' ? '>=' : 'ILIKE'; + return type == 'system-defined' ? searchStore.numeric_match : 'ILIKE'; } } const checkValueForNumber = (label, value) => { - if (value === '') { return null; } - let validationState = null; let message = `${label}: Only numbers are allowed`; searchStore.removeErrorMessage(message); @@ -612,7 +612,7 @@ const DetailSearch = () => { const regex = /^[0-9\s\-]+$/; let numericCheck = label.includes('point') ? !regex.test(value) : isNaN(Number(value)); - if (numericCheck) { + if (numericCheck && value !== '') { searchStore.addErrorMessage(message); validationState = 'error'; } @@ -637,8 +637,9 @@ const DetailSearch = () => { let searchValue = searchValueByStoreOrDefaultValue(column); if (optionField.value_system) { - let valueSystem = - searchValue.sub_values.length >= 1 && searchValue.sub_values[0][id] ? searchValue.sub_values[0][id].value_system : optionField.value_system; + let valueSystem = searchValue.sub_values.length >= 1 && searchValue.sub_values[0][id] + ? searchValue.sub_values[0][id].value_system + : optionField.value_system; subValue = { id: id, value: { value: value, value_system: valueSystem } }; } else { subValue = { id: id, value: value }; @@ -664,14 +665,14 @@ const DetailSearch = () => { const setSearchStoreValues = (value, option, column, type, subValue, smiles) => { let searchValue = searchValueByStoreOrDefaultValue(column); - let cleanedValue = ['>=', '<@'].includes(searchValue.match) ? value.replace(/,/g, '.') : value; + let cleanedValue = ['>=', '<=', '<@'].includes(searchValue.match) ? value.replace(/,/g, '.') : value; searchValue.field = option; searchValue.value = cleanedValue; searchValue.sub_values = subValuesForSearchValue(searchValue, subValue, cleanedValue); searchValue.match = matchByField(column, type); searchValue.smiles = smiles; - if (['>=', '<@'].includes(searchValue.match)) { + if (['>=', '<=', '<@'].includes(searchValue.match)) { searchValue.validationState = checkValueForNumber(option.label, cleanedValue); } @@ -679,10 +680,25 @@ const DetailSearch = () => { let units = optionsForSelect(option); searchValue.unit = units[0].label; } + + if (column.indexOf('temperature') !== -1 && value !== '' && value !== 0 && value !== "0") { + searchValue = availableOptionsForTemperature(searchValue, value, searchValue.unit); + } + + if (value === 'others' && option.type === 'select') { + searchValue.available_options = []; + optionsForSelect(option).map((object) => { + if (object.value !== '' && object.value !== 'others') { + searchValue.available_options.push(object); + } + }); + } + let searchSubValuesLength = searchValue.sub_values.length >= 1 ? Object.keys(searchValue.sub_values[0]).length : 0; let typesWithSubValues = ['input-group', 'table']; - if (((value === '' || value === false) && !typesWithSubValues.includes(type)) || (searchSubValuesLength === 0 && typesWithSubValues.includes(type) && value === '')) { + if (((value === '' || value === false) && !typesWithSubValues.includes(type)) + || (searchSubValuesLength === 0 && typesWithSubValues.includes(type) && value === '')) { searchStore.removeDetailSearchValue(column); } else { searchStore.addDetailSearchValue(column, searchValue); @@ -706,6 +722,22 @@ const DetailSearch = () => { return subValues; } + const availableOptionsForTemperature = (searchValue, startValue, startUnit) => { + startValue = startValue.match(/^-?\d+(\.\d+)?$/g); + + if (startValue === null || isNaN(Number(startValue))) { return searchValue; } + + searchValue.available_options = []; + searchValue.available_options.push({ value: startValue[0], unit: startUnit }); + + let [convertedValue, convertedUnit] = convertTemperature(startValue[0], startUnit); + searchValue.available_options.push({ value: convertedValue.trim(), unit: convertedUnit }); + + [convertedValue, convertedUnit] = convertTemperature(convertedValue, convertedUnit); + searchValue.available_options.push({ value: convertedValue.trim(), unit: convertedUnit }); + return searchValue; + } + const changeUnit = (units, value, column, option, subFieldId) => (e) => { let activeUnitIndex = units.findIndex((f) => { return f.label.replace('°', '') === value || f.label === value }); let nextUnitIndex = activeUnitIndex === units.length - 1 ? 0 : activeUnitIndex + 1; @@ -722,7 +754,12 @@ const DetailSearch = () => { } } + if (column.indexOf('temperature') !== -1 && searchValue.value !== '') { + const nextValue = searchValue.available_options.find((v) => newUnit.indexOf(v.unit) !== -1); + searchValue.value = nextValue.value; + } searchValue.unit = newUnit; + searchStore.addDetailSearchValue(column, searchValue); } diff --git a/app/packs/src/components/searchModal/forms/SearchModalFunctions.js b/app/packs/src/components/searchModal/forms/SearchModalFunctions.js index dfca8e8d63..51858bd6c1 100644 --- a/app/packs/src/components/searchModal/forms/SearchModalFunctions.js +++ b/app/packs/src/components/searchModal/forms/SearchModalFunctions.js @@ -110,6 +110,23 @@ const searchValuesBySubFields = (val, table) => { return searchValues; } +const searchValuesByAvailableOptions = (val, table) => { + let searchValues = []; + let link = 'OR'; + let match = val.match; + + val.available_options.map((option, i) => { + if (val.field.column.indexOf('temperature') === -1) { + link = i === 0 ? 'OR' : 'AND'; + match = 'NOT LIKE'; + } + if (!option.unit || option.unit.replace('°', '') !== val.unit.replace('°', '')) { + searchValues.push([link, table, val.field.label.toLowerCase(), match, option.value, option.unit].join(" ")); + } + }); + return searchValues; +} + const searchValuesByFilters = (store) => { const storedFilter = store.searchFilters; const filters = storedFilter.length == 0 ? [] : storedFilter[0].filters; @@ -120,11 +137,15 @@ const searchValuesByFilters = (store) => { let table = val.field.table || val.table; let value = val.value; table = table.charAt(0).toUpperCase() + table.slice(1, -1).replace('_', ' '); - value = value != true ? value.replace(/[\n\r]/g, ' OR ') : value; + value = value && value !== true ? value.replace(/[\n\r]/g, ' OR ') : value; if (val.field.sub_fields && val.field.sub_fields.length >= 1 && val.sub_values.length >= 1) { let values = searchValuesBySubFields(val, table); searchValues.push(...values); + } else if (val.available_options) { + let values = searchValuesByAvailableOptions(val, table); + searchValues.push([val.link, table, val.field.label.toLowerCase(), val.match, value, val.unit].join(" ")); + searchValues.push(...values); } else { searchValues.push([val.link, table, val.field.label.toLowerCase(), val.match, value, val.unit].join(" ")); } diff --git a/app/packs/src/components/searchModal/forms/SearchResult.js b/app/packs/src/components/searchModal/forms/SearchResult.js index f41d99625c..4d2e18fffb 100644 --- a/app/packs/src/components/searchModal/forms/SearchResult.js +++ b/app/packs/src/components/searchModal/forms/SearchResult.js @@ -170,7 +170,7 @@ const SearchResult = ({ handleClear }) => {
- + ({tabResult.total_elements})
diff --git a/app/packs/src/components/searchModal/forms/TextSearch.js b/app/packs/src/components/searchModal/forms/TextSearch.js index 50c91614c7..86a64d486d 100644 --- a/app/packs/src/components/searchModal/forms/TextSearch.js +++ b/app/packs/src/components/searchModal/forms/TextSearch.js @@ -1,8 +1,8 @@ import React, { useEffect, useContext } from 'react'; -import { ToggleButtonGroup, ToggleButton, Tooltip, OverlayTrigger, Stack, Accordion } from 'react-bootstrap'; +import { ToggleButtonGroup, ToggleButton, Tooltip, OverlayTrigger, Stack, Accordion, Form } from 'react-bootstrap'; import { - togglePanel, handleClear, showErrorMessage, handleSearch, - AccordeonHeaderButtonForSearchForm, SearchButtonToolbar, panelVariables + togglePanel, handleClear, showErrorMessage, panelVariables, + AccordeonHeaderButtonForSearchForm, SearchButtonToolbar } from './SearchModalFunctions'; import UserStore from 'src/stores/alt/stores/UserStore'; import AdvancedSearchRow from './AdvancedSearchRow'; @@ -55,6 +55,10 @@ const TextSearch = () => { searchStore.addAdvancedSearchValue(0, searchValues); } + const handleNumericMatchChange = (e) => { + searchStore.changeNumericMatchValue(e.target.value); + } + const SelectSearchTable = () => { const layout = UserStore.getState().profile.data.layout; @@ -177,7 +181,34 @@ const TextSearch = () => { ) } - +
+ + { + searchStore.searchType == 'detail' && ( + + Change search operator for numeric Fields: + ='} + onChange={handleNumericMatchChange} + /> + ='), search_results: types.map(SearchResult), tab_search_results: types.map(SearchResult), search_accordion_active_key: types.optional(types.number, 0), @@ -173,6 +175,18 @@ export const SearchStore = types self.detail_search_values.splice(index, 1); } }, + changeNumericMatchValue(match) { + self.numeric_match = match; + self.detail_search_values.map((object, i) => { + if (['>=', '<='].includes(Object.values(object)[0].match)) { + Object.entries(self.detail_search_values[i]).forEach(([key, value]) => { + let values = { ...value }; + values.match = match; + self.detail_search_values[i] = { [key]: values }; + }); + } + }); + }, changeKetcherRailsValue(key, value) { let ketcherValues = { ...self.ketcher_rails_values }; ketcherValues[key] = value; diff --git a/app/packs/src/utilities/UnitsConversion.js b/app/packs/src/utilities/UnitsConversion.js index e39ba3c10a..8a3dfc5c01 100644 --- a/app/packs/src/utilities/UnitsConversion.js +++ b/app/packs/src/utilities/UnitsConversion.js @@ -52,6 +52,7 @@ const convertTemperature = (valueToFormat, currentUnit) => { } const conversions = { K: { convertedUnit: TEMPERATURE_UNITS.CELSIUS, conversionFunc: kelvinToCelsius }, + '°K': { convertedUnit: TEMPERATURE_UNITS.CELSIUS, conversionFunc: kelvinToCelsius }, '°C': { convertedUnit: TEMPERATURE_UNITS.FAHRENHEIT, conversionFunc: celsiusToFahrenheit }, '°F': { convertedUnit: TEMPERATURE_UNITS.KELVIN, conversionFunc: fahrenheitToKelvin }, }; @@ -132,7 +133,7 @@ const calculateVolumeForFeedstockOrGas = (amountGram, molecularWeight, purity, g }; const calculateGasMoles = (volume, ppm, temperatureInKelvin) => (ppm * volume) - / (IDEAL_GAS_CONSTANT * temperatureInKelvin * PARTS_PER_MILLION_FACTOR); + / (IDEAL_GAS_CONSTANT * temperatureInKelvin * PARTS_PER_MILLION_FACTOR); const calculateFeedstockMoles = (volume, purity) => (volume * purity) / ( IDEAL_GAS_CONSTANT * DEFAULT_TEMPERATURE_IN_KELVIN); diff --git a/app/usecases/search/conditions_for_advanced_search.rb b/app/usecases/search/conditions_for_advanced_search.rb index 481719831e..cda56d405c 100644 --- a/app/usecases/search/conditions_for_advanced_search.rb +++ b/app/usecases/search/conditions_for_advanced_search.rb @@ -155,13 +155,25 @@ def sanitize_words(filter) return [filter['smiles']] if filter['field']['column'] == 'solvent' return [filter['value'].to_f] if sanitize_float_fields(filter) - no_sanitizing_matches = ['=', '>='] + no_sanitizing_matches = ['=', '>=', '<='] sanitize = no_sanitizing_matches.exclude?(filter['match']) words = filter['value'].split(/(\r)?\n/).map!(&:strip) words = words.map { |e| "%#{ActiveRecord::Base.send(:sanitize_sql_like, e)}%" } if sanitize words end + def valid_temperature(prop, number) + regex_number = "'^-{0,1}\\d+(\\.\\d+){0,1}\\Z'" + "(#{prop} ->> '#{number}' ~ #{regex_number})" + end + + def temperature_field_specials(prop, number, unit) + @conditions[:words][0] = @conditions[:words][0].to_f.to_s + @conditions[:field] = "(#{prop} ->> '#{number}')::FLOAT" + @conditions[:first_condition] += + " (#{prop} ->> '#{unit}')::TEXT != '' AND #{valid_temperature(prop, number)} AND " + end + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def special_non_generic_field_options(filter) @@ -187,14 +199,10 @@ def special_non_generic_field_options(filter) AND private_notes.noteable_id = #{@table}.id" @conditions[:condition_table] = 'private_notes.' when 'temperature' - regex_number = "'^-{0,1}\\d+(\\.\\d+){0,1}\\Z'" - is_data_valid = "(#{@table}.temperature ->> 'userText' ~ #{regex_number})" - - @conditions[:field] = - "CASE WHEN #{is_data_valid} THEN (#{@table}.temperature ->> 'userText')::FLOAT ELSE -30000 END " - - @conditions[:first_condition] += " (#{@table}.temperature ->> 'valueUnit')::TEXT != '' AND " - @conditions[:additional_condition] = "AND (#{@table}.temperature ->> 'valueUnit')::TEXT = '#{filter['unit']}'" + # is_data_valid = valid_temperature("#{@table}.temperature", 'userText') + field = filter['field']['column'] + temperature_field_specials("#{@table}.temperature", 'userText', 'valueUnit') + unit_and_available_options_conditions(filter, "#{@table}.temperature", field, 'userText', 'valueUnit') @conditions[:condition_table] = '' when 'duration' time_divisor = duration_interval_by_unit(filter['unit']) @@ -372,19 +380,56 @@ def dataset_tab_options(filter, number) prop = "prop_#{key}_#{number}" datasets_joins(filter, prop, key) - if filter['field']['column'] == 'datasets_type' + if filter['field']['column'] == 'datasets_type' && filter['field']['field'].blank? @conditions[:field] = '' else field = filter['field']['column'].remove('datasets_') @conditions[:field] = "(#{prop} ->> 'value')::TEXT" @conditions[:additional_condition] = "AND (#{prop} ->> 'field')::TEXT = '#{field}'" - if filter['unit'].present? - unit = filter['unit'].remove('°').remove(/ \(.*\)/).tr('/', '_') + + if filter['unit'].present? || filter['available_options'].present? + temperature_field_specials(prop, 'value', 'value_system') if field == 'temperature' + unit_and_available_options_conditions(filter, prop, field, 'value', 'value_system') + end + end + end + + def remove_degree_from_unit(filter) + filter['unit'].remove('°').remove(/ \(.*\)/).tr('/', '_') + end + + def remove_degree_from_property(prop, unit) + "LOWER(replace((#{prop} ->> '#{unit}')::TEXT, '°', ''))" + end + + # rubocop:disable Metrics/AbcSize + def unit_and_available_options_conditions(filter, prop, field, number, unit) + if filter['unit'].present? + @conditions[:additional_condition] += + " AND #{remove_degree_from_property(prop, unit)} = LOWER('#{remove_degree_from_unit(filter)}')" + end + + return if filter['available_options'].blank? + + conditions = '' + filter['available_options'].each do |option| + if field.include?('temperature') + next if option[:unit].remove('°') == filter['unit'].remove('°') + @conditions[:additional_condition] += - " AND LOWER((#{prop} ->> 'value_system')::TEXT) = LOWER('#{unit}')" + " OR (#{valid_temperature(prop, number)} + AND (#{prop} ->> '#{number}')::FLOAT #{@match} '#{option[:value].to_f}' + AND #{remove_degree_from_property(prop, unit)} = LOWER('#{remove_degree_from_unit(option)}'))" + else + conditions += " AND (#{prop} ->> '#{number}')::TEXT NOT ILIKE '%#{option[:value]}%'" end end + + return if field.include?('temperature') + + @conditions[:additional_condition] += " OR ((#{prop} ->> 'field')::TEXT = '#{field}'#{conditions})" end + # rubocop:enable Metrics/AbcSize def datasets_joins(filter, prop, key) datasets_join = @@ -392,7 +437,7 @@ def datasets_joins(filter, prop, key) @conditions[:joins] << datasets_join if @conditions[:joins].exclude?(datasets_join) - if filter['field']['column'] == 'datasets_type' + if filter['field']['column'] == 'datasets_type' && filter['field']['field'].blank? @conditions[:joins] << "INNER JOIN dataset_klasses ON dataset_klasses.id = datasets.dataset_klass_id AND dataset_klasses.ols_term_id = '#{filter['value']}'" diff --git a/spec/api/chemotion/search_api_spec.rb b/spec/api/chemotion/search_api_spec.rb index 96cba24378..f5a6a51d44 100644 --- a/spec/api/chemotion/search_api_spec.rb +++ b/spec/api/chemotion/search_api_spec.rb @@ -250,6 +250,9 @@ let(:advanced_params) do [ { + available_options: [ + { value: '-22', unit: '°C' }, { value: '-7.6', unit: '°F' }, { value: '251.15', unit: 'K' } + ], link: '', match: '>=', table: 'reactions',