From 0da5785d17bf111e8b576d813b5f7dafba8262de Mon Sep 17 00:00:00 2001
From: pkupczyk <piotr.kupczyk@id.ethz.ch>
Date: Mon, 21 Dec 2020 11:01:17 +0100
Subject: [PATCH] NG_UI : plugins evaluation : SSDM-10431 - entity
 autocompleter

---
 .../common/form/AutocompleterField.jsx        |  55 ++-
 .../common/form/EntityAutocompleterField.jsx  | 328 ++++++++++++++++++
 .../plugin/PluginFormControllerEvaluate.js    |  31 +-
 .../form/plugin/PluginFormControllerLoad.js   |   2 +-
 .../plugin/PluginFormEvaluateParameters.jsx   |  17 +-
 .../types/form/TypeFormParametersProperty.jsx |   2 +-
 openbis_ng_ui/src/js/services/openbis/api.js  |   8 +
 7 files changed, 408 insertions(+), 35 deletions(-)
 create mode 100644 openbis_ng_ui/src/js/components/common/form/EntityAutocompleterField.jsx

diff --git a/openbis_ng_ui/src/js/components/common/form/AutocompleterField.jsx b/openbis_ng_ui/src/js/components/common/form/AutocompleterField.jsx
index 78471eccb96..f526082d1eb 100644
--- a/openbis_ng_ui/src/js/components/common/form/AutocompleterField.jsx
+++ b/openbis_ng_ui/src/js/components/common/form/AutocompleterField.jsx
@@ -5,6 +5,7 @@ import TextField from '@material-ui/core/TextField'
 import InputAdornment from '@material-ui/core/InputAdornment'
 import ArrowDropUpIcon from '@material-ui/icons/ArrowDropUp'
 import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown'
+import CircularProgress from '@material-ui/core/CircularProgress'
 import FormFieldContainer from '@src/js/components/common/form/FormFieldContainer.jsx'
 import FormFieldLabel from '@src/js/components/common/form/FormFieldLabel.jsx'
 import FormFieldView from '@src/js/components/common/form/FormFieldView.jsx'
@@ -30,6 +31,11 @@ const styles = theme => ({
     }
   },
   adornment: {
+    marginRight: '-32px',
+    marginTop: '-16px',
+    color: '#0000008a'
+  },
+  adornmentFreeSolo: {
     marginRight: '-4px',
     marginTop: '-16px',
     color: '#0000008a'
@@ -38,6 +44,7 @@ const styles = theme => ({
 
 class AutocompleterFormField extends React.PureComponent {
   static defaultProps = {
+    freeSolo: false,
     mode: 'edit',
     variant: 'filled'
   }
@@ -54,6 +61,7 @@ class AutocompleterFormField extends React.PureComponent {
     this.handleClick = this.handleClick.bind(this)
     this.handleKeyDown = this.handleKeyDown.bind(this)
     this.handleChange = this.handleChange.bind(this)
+    this.handleInputChange = this.handleInputChange.bind(this)
     this.handleFocus = this.handleFocus.bind(this)
     this.handleBlur = this.handleBlur.bind(this)
   }
@@ -99,6 +107,14 @@ class AutocompleterFormField extends React.PureComponent {
     this.handleEvent(event, value, this.props.onChange)
   }
 
+  handleInputChange(event, value) {
+    this.setState({
+      open: true
+    })
+
+    this.handleEvent(event, value, this.props.onInputChange)
+  }
+
   handleFocus(event) {
     this.setState({
       focused: true
@@ -112,10 +128,6 @@ class AutocompleterFormField extends React.PureComponent {
       open: false
     })
 
-    if (event.target.value !== this.props.value) {
-      this.handleEvent(event, event.target.value, this.props.onChange)
-    }
-
     event.persist()
 
     setTimeout(() => {
@@ -162,12 +174,19 @@ class AutocompleterFormField extends React.PureComponent {
       name,
       description,
       value,
+      inputValue,
+      freeSolo,
       disabled,
       error,
       metadata,
       styles,
       classes,
-      variant
+      variant,
+      renderOption,
+      filterOptions,
+      getOptionLabel,
+      getOptionSelected,
+      getOptionDisabled
     } = this.props
 
     const { open, focused } = this.state
@@ -181,14 +200,21 @@ class AutocompleterFormField extends React.PureComponent {
         onClick={this.handleClick}
       >
         <Autocomplete
-          freeSolo
           disableClearable
+          freeSolo={freeSolo}
           name={name}
           disabled={disabled}
           options={this.getOptions()}
+          renderOption={renderOption}
+          filterOptions={filterOptions}
+          getOptionLabel={getOptionLabel}
+          getOptionSelected={getOptionSelected}
+          getOptionDisabled={getOptionDisabled}
           value={value}
+          inputValue={inputValue}
           open={open}
           onChange={this.handleChange}
+          onInputChange={this.handleInputChange}
           onFocus={this.handleFocus}
           onBlur={this.handleBlur}
           onKeyDown={this.handleKeyDown}
@@ -242,23 +268,32 @@ class AutocompleterFormField extends React.PureComponent {
 
   renderAdornment() {
     const { open } = this.state
-    const { classes } = this.props
+    const { loading, freeSolo, classes } = this.props
+
     return (
-      <InputAdornment position='end' classes={{ root: classes.adornment }}>
+      <InputAdornment
+        position='end'
+        classes={{
+          root: freeSolo ? classes.adornmentFreeSolo : classes.adornment
+        }}
+      >
+        {loading ? <CircularProgress color='inherit' size={20} /> : null}
         {open ? <ArrowDropUpIcon /> : <ArrowDropDownIcon />}
       </InputAdornment>
     )
   }
 
   getOptions() {
-    const { options, sort = true } = this.props
+    const { options, getOptionLabel, sort = true } = this.props
 
     if (options) {
       let result = Array.from(options)
 
       if (sort) {
         result.sort((option1, option2) => {
-          return compare(option1, option2)
+          let label1 = getOptionLabel ? getOptionLabel(option1) : option1
+          let label2 = getOptionLabel ? getOptionLabel(option2) : option2
+          return compare(label1, label2)
         })
       }
 
diff --git a/openbis_ng_ui/src/js/components/common/form/EntityAutocompleterField.jsx b/openbis_ng_ui/src/js/components/common/form/EntityAutocompleterField.jsx
new file mode 100644
index 00000000000..31abf0a451a
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/common/form/EntityAutocompleterField.jsx
@@ -0,0 +1,328 @@
+import _ from 'lodash'
+import React from 'react'
+import autoBind from 'auto-bind'
+import { withStyles } from '@material-ui/core/styles'
+import AutocompleterField from '@src/js/components/common/form/AutocompleterField.jsx'
+import openbis from '@src/js/services/openbis.js'
+import logger from '@src/js/common/logger.js'
+
+const styles = () => ({})
+
+const LOADED_OPTIONS_COUNT = 100
+
+class EntityAutocompleterField extends React.PureComponent {
+  constructor(props) {
+    super(props)
+
+    autoBind(this)
+
+    this.state = {
+      loading: false,
+      inputValue: this.getOptionLabel(props.value),
+      options: []
+    }
+  }
+
+  async load(value) {
+    this.cancelScheduledLoad()
+
+    const loadId = setTimeout(async () => {
+      try {
+        logger.log(
+          logger.DEBUG,
+          `EntityAutocompleterField - executing load request (id: ${loadId}, value: ${value})`
+        )
+
+        const results = await this.loadEntities(value, LOADED_OPTIONS_COUNT)
+
+        let options = []
+        if (results.options.length > 0) {
+          if (results.totalCount > results.options.length) {
+            options.push({
+              label: `Showing only the first ${LOADED_OPTIONS_COUNT} results (${results.totalCount} found)`
+            })
+          }
+          results.options.forEach(option => options.push(option))
+        } else {
+          options = [
+            {
+              label: 'No entities found'
+            }
+          ]
+        }
+
+        if (loadId === this.state.loadId) {
+          logger.log(
+            logger.DEBUG,
+            `EntityAutocompleterField - received valid load response (id: ${loadId}, value: ${value})`
+          )
+
+          this.setState({
+            loading: false,
+            options
+          })
+        } else {
+          logger.log(
+            logger.DEBUG,
+            `EntityAutocompleterField - ignoring old load response (id: ${loadId}, value: ${value})`
+          )
+        }
+      } catch (error) {
+        this.setState({
+          loading: false
+        })
+      }
+    }, 250)
+
+    logger.log(
+      logger.DEBUG,
+      `EntityAutocompleterField - scheduled load request (id: ${loadId}, value: ${value})`
+    )
+
+    this.setState({
+      loading: true,
+      loadId
+    })
+  }
+
+  cancelScheduledLoad() {
+    const lastLoadId = this.state.loadId
+
+    if (lastLoadId) {
+      logger.log(
+        logger.DEBUG,
+        `EntityAutocompleterField - cancelling scheduled load request (id: ${lastLoadId})`
+      )
+
+      clearTimeout(lastLoadId)
+      this.setState({
+        loadId: null
+      })
+    }
+  }
+
+  async loadEntities(value, count) {
+    const { entityKind } = this.props
+
+    if (entityKind === openbis.EntityKind.EXPERIMENT) {
+      return await this.loadExperiments(value, count)
+    } else if (entityKind === openbis.EntityKind.SAMPLE) {
+      return await this.loadSamples(value, count)
+    } else if (entityKind === openbis.EntityKind.DATA_SET) {
+      return await this.loadDataSets(value, count)
+    } else if (entityKind === openbis.EntityKind.MATERIAL) {
+      return await this.loadMaterials(value, count)
+    } else {
+      return []
+    }
+  }
+
+  async loadExperiments(value, count) {
+    const criteria = new openbis.ExperimentSearchCriteria()
+    criteria.withOrOperator()
+
+    if (value && value.trim().length > 0) {
+      //criteria.withCode().thatContains(value)
+      criteria.withIdentifier().thatContains(value)
+      criteria.withProperty('$NAME').thatContains(value)
+    }
+
+    const fo = new openbis.ExperimentFetchOptions()
+    fo.from(0).count(count)
+    fo.sortBy().identifier().asc()
+
+    const results = await openbis.searchExperiments(criteria, fo)
+
+    return {
+      options: results.getObjects().map(object => ({
+        label: object.identifier.identifier,
+        entityKind: openbis.EntityKind.EXPERIMENT,
+        entityId: object.identifier.identifier
+      })),
+      totalCount: results.totalCount
+    }
+  }
+
+  async loadSamples(value, count) {
+    const criteria = new openbis.SampleSearchCriteria()
+    criteria.withOrOperator()
+
+    if (value && value.trim().length > 0) {
+      //criteria.withCode().thatContains(value)
+      criteria.withIdentifier().thatContains(value)
+      criteria.withProperty('$NAME').thatContains(value)
+    }
+
+    const fo = new openbis.SampleFetchOptions()
+    fo.from(0).count(count)
+    fo.sortBy().identifier().asc()
+
+    const results = await openbis.searchSamples(criteria, fo)
+
+    return {
+      options: results.getObjects().map(object => ({
+        label: object.identifier.identifier,
+        entityKind: openbis.EntityKind.SAMPLE,
+        entityId: object.identifier.identifier
+      })),
+      totalCount: results.totalCount
+    }
+  }
+
+  async loadMaterials(value, count) {
+    const criteria = new openbis.MaterialSearchCriteria()
+    criteria.withOrOperator()
+
+    if (value && value.trim().length > 0) {
+      criteria.withCode().thatContains(value)
+      criteria.withProperty('$NAME').thatContains(value)
+    }
+
+    const fo = new openbis.MaterialFetchOptions()
+    fo.from(0).count(count)
+    fo.sortBy().code().asc()
+
+    const results = await openbis.searchMaterials(criteria, fo)
+
+    return {
+      options: results.getObjects().map(object => ({
+        label: object.permId.code + ' (' + object.permId.typeCode + ')',
+        entityKind: openbis.EntityKind.MATERIAL,
+        entityId: {
+          code: object.permId.code,
+          typeCode: object.permId.typeCode
+        }
+      })),
+      totalCount: results.totalCount
+    }
+  }
+
+  async loadDataSets(value, count) {
+    const criteria = new openbis.DataSetSearchCriteria()
+    criteria.withOrOperator()
+
+    if (value && value.trim().length > 0) {
+      criteria.withCode().thatContains(value)
+      criteria.withProperty('$NAME').thatContains(value)
+
+      const experimentCriteria = criteria.withExperiment()
+      experimentCriteria.withOrOperator()
+      //experimentCriteria.withCode().thatContains(value)
+      experimentCriteria.withIdentifier().thatContains(value)
+      experimentCriteria.withProperty('$NAME').thatContains(value)
+
+      const sampleCriteria = criteria.withSample()
+      sampleCriteria.withOrOperator()
+      //sampleCriteria.withCode().thatContains(value)
+      sampleCriteria.withIdentifier().thatContains(value)
+      sampleCriteria.withProperty('$NAME').thatContains(value)
+    }
+
+    const fo = new openbis.DataSetFetchOptions()
+    fo.from(0).count(count)
+    fo.sortBy().code().asc()
+
+    const results = await openbis.searchDataSets(criteria, fo)
+
+    return {
+      options: results.getObjects().map(object => ({
+        label: object.code,
+        entityKind: openbis.EntityKind.DATA_SET,
+        entityId: object.code
+      })),
+      totalCount: results.totalCount
+    }
+  }
+
+  handleFocus() {
+    this.load(this.state.inputValue)
+  }
+
+  handleInputChange(event) {
+    this.setState({
+      inputValue: event.target.value
+    })
+    this.load(event.target.value)
+  }
+
+  handleBlur(event) {
+    const { value, onChange } = this.props
+    const { inputValue } = this.state
+
+    const valueTrimmed = value ? this.getOptionLabel(value).trim() : ''
+    const inputValueTrimmed = inputValue ? inputValue.trim() : ''
+
+    if (inputValueTrimmed.length === 0 && valueTrimmed.length !== 0) {
+      if (onChange) {
+        onChange(event)
+      }
+    } else if (inputValueTrimmed !== valueTrimmed) {
+      this.setState({
+        inputValue: this.getOptionLabel(value)
+      })
+    }
+
+    this.cancelScheduledLoad()
+
+    this.setState({
+      loading: false,
+      options: []
+    })
+  }
+
+  renderOption(option) {
+    return <span>{this.getOptionLabel(option)}</span>
+  }
+
+  filterOptions(options) {
+    // do not filter options on the client side
+    return options
+  }
+
+  getOptionLabel(option) {
+    if (option) {
+      return option.label
+    } else {
+      return ''
+    }
+  }
+
+  getOptionSelected(option, value) {
+    return (
+      _.isEqual(option.entityKind, value.entityKind) &&
+      _.isEqual(option.entityId, value.entityId)
+    )
+  }
+
+  getOptionDisabled(option) {
+    return !option.entityId
+  }
+
+  render() {
+    logger.log(logger.DEBUG, 'EntityAutocompleterField.render')
+
+    const { value } = this.props
+    const { loading, inputValue, options } = this.state
+
+    return (
+      <AutocompleterField
+        {...this.props}
+        loading={loading}
+        freeSolo={true}
+        value={value}
+        inputValue={inputValue}
+        renderOption={this.renderOption}
+        filterOptions={this.filterOptions}
+        getOptionLabel={this.getOptionLabel}
+        getOptionSelected={this.getOptionSelected}
+        getOptionDisabled={this.getOptionDisabled}
+        onInputChange={this.handleInputChange}
+        onFocus={this.handleFocus}
+        onBlur={this.handleBlur}
+        options={options}
+      />
+    )
+  }
+}
+
+export default withStyles(styles)(EntityAutocompleterField)
diff --git a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerEvaluate.js b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerEvaluate.js
index 0198713edd3..8c851457490 100644
--- a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerEvaluate.js
+++ b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerEvaluate.js
@@ -50,8 +50,7 @@ export default class PluginFormControllerEvaluate {
     const pluginName = plugin.name.value
     const pluginScript = plugin.script.value
 
-    const entityKind = evaluateParameters.entityKind.value
-    const entityId = evaluateParameters.entityId.value
+    const entity = evaluateParameters.entity.value
     const entityIsNew = evaluateParameters.entityIsNew.value
 
     let options = null
@@ -63,20 +62,24 @@ export default class PluginFormControllerEvaluate {
       options.setNew(entityIsNew)
     }
 
-    let objectId = null
+    if (entity) {
+      let objectId = null
 
-    if (entityKind === openbis.EntityKind.EXPERIMENT) {
-      objectId = new openbis.ExperimentIdentifier(entityId)
-    } else if (entityKind === openbis.EntityKind.SAMPLE) {
-      objectId = new openbis.SampleIdentifier(entityId)
-    } else if (entityKind === openbis.EntityKind.DATA_SET) {
-      objectId = new openbis.DataSetPermId(entityId)
-    } else if (entityKind === openbis.EntityKind.MATERIAL) {
-      const entityIdParts = entityId.split(' ')
-      objectId = new openbis.MaterialPermId(entityIdParts[0], entityIdParts[1])
-    }
+      if (entity.entityKind === openbis.EntityKind.EXPERIMENT) {
+        objectId = new openbis.ExperimentIdentifier(entity.entityId)
+      } else if (entity.entityKind === openbis.EntityKind.SAMPLE) {
+        objectId = new openbis.SampleIdentifier(entity.entityId)
+      } else if (entity.entityKind === openbis.EntityKind.DATA_SET) {
+        objectId = new openbis.DataSetPermId(entity.entityId)
+      } else if (entity.entityKind === openbis.EntityKind.MATERIAL) {
+        objectId = new openbis.MaterialPermId(
+          entity.entityId.code,
+          entity.entityId.typeCode
+        )
+      }
 
-    options.setObjectId(objectId)
+      options.setObjectId(objectId)
+    }
 
     if (mode === PageMode.VIEW) {
       options.setPluginId(new openbis.PluginPermId(pluginName))
diff --git a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerLoad.js b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerLoad.js
index 20cedda8e63..fee629cb6e1 100644
--- a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerLoad.js
+++ b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerLoad.js
@@ -77,7 +77,7 @@ export default class PluginFormControllerLoad extends PageControllerLoad {
   _createEvaluateParameters() {
     return {
       entityKind: FormUtil.createField(),
-      entityId: FormUtil.createField(),
+      entity: FormUtil.createField(),
       entityIsNew: FormUtil.createField()
     }
   }
diff --git a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormEvaluateParameters.jsx b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormEvaluateParameters.jsx
index 819ff213135..ca5e7c251b0 100644
--- a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormEvaluateParameters.jsx
+++ b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormEvaluateParameters.jsx
@@ -2,7 +2,7 @@ import React from 'react'
 import { withStyles } from '@material-ui/core/styles'
 import Container from '@src/js/components/common/form/Container.jsx'
 import Header from '@src/js/components/common/form/Header.jsx'
-import AutocompleterField from '@src/js/components/common/form/AutocompleterField.jsx'
+import EntityAutocompleterField from '@src/js/components/common/form/EntityAutocompleterField.jsx'
 import SelectField from '@src/js/components/common/form/SelectField.jsx'
 import CheckboxField from '@src/js/components/common/form/CheckboxField.jsx'
 import PluginFormSelectionType from '@src/js/components/tools/form/plugin/PluginFormSelectionType.js'
@@ -35,7 +35,7 @@ class PluginFormEvaluateParameters extends React.PureComponent {
       <Container>
         <Header>Tester</Header>
         {this.renderEntityKind()}
-        {this.renderEntityId()}
+        {this.renderEntity()}
         {this.renderEntityIsNew()}
       </Container>
     )
@@ -64,18 +64,17 @@ class PluginFormEvaluateParameters extends React.PureComponent {
     )
   }
 
-  renderEntityId() {
+  renderEntity() {
     const { parameters, classes } = this.props
 
-    const options = []
-
     return (
       <div className={classes.parameter}>
-        <AutocompleterField
+        <EntityAutocompleterField
+          key={parameters.entityKind.value}
           label='Entity'
-          name='entityId'
-          options={options}
-          value={parameters.entityId.value}
+          name='entity'
+          entityKind={parameters.entityKind.value}
+          value={parameters.entity.value}
           onChange={this.handleChange}
         />
       </div>
diff --git a/openbis_ng_ui/src/js/components/types/form/TypeFormParametersProperty.jsx b/openbis_ng_ui/src/js/components/types/form/TypeFormParametersProperty.jsx
index cef4329c709..830a3b2b4b0 100644
--- a/openbis_ng_ui/src/js/components/types/form/TypeFormParametersProperty.jsx
+++ b/openbis_ng_ui/src/js/components/types/form/TypeFormParametersProperty.jsx
@@ -299,7 +299,7 @@ class TypeFormParametersProperty extends React.PureComponent {
             disabled={!enabled}
             value={value}
             mode={mode}
-            onChange={this.handleChange}
+            onInputChange={this.handleChange}
             onFocus={this.handleFocus}
             onBlur={this.handleBlur}
           />
diff --git a/openbis_ng_ui/src/js/services/openbis/api.js b/openbis_ng_ui/src/js/services/openbis/api.js
index 8780def033f..6342a38da5b 100644
--- a/openbis_ng_ui/src/js/services/openbis/api.js
+++ b/openbis_ng_ui/src/js/services/openbis/api.js
@@ -87,6 +87,14 @@ class Facade {
     return this.promise(this.v3.searchSamples(criteria, fo))
   }
 
+  searchExperiments(criteria, fo) {
+    return this.promise(this.v3.searchExperiments(criteria, fo))
+  }
+
+  searchDataSets(criteria, fo) {
+    return this.promise(this.v3.searchDataSets(criteria, fo))
+  }
+
   searchVocabularies(criteria, fo) {
     return this.promise(this.v3.searchVocabularies(criteria, fo))
   }
-- 
GitLab