All Downloads are FREE. Search and download functionalities are using the official Maven repository.

components.form.fields.InputSelectField.js Maven / Gradle / Ivy

import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Autocomplete, Box, Chip, CircularProgress, TextField, Typography } from "@mui/material";
import { useApolloClient } from "@apollo/client";
import InputError from "components/form/fields/InputError";
import Listbox from "components/form/fields/select/Listbox";
import { isEmptyValue, useDebounce } from "utils/utils";
import Report from "helpers/report";
import { validateField } from "components/form/utils/validate-utils";
import { useNotifier } from "hooks/notification";
import { useFormInfo } from 'hooks/form';
import { useFieldInfo } from "hooks/field";
import { GET_CHOICES } from "queries/task";
import { toOption } from "utils/option-utils";
import { useDependants } from "hooks/dependants";
import { useAutoSubmitSignal } from "hooks/autosubmit";
import { fieldMinWidthStyle } from "components/form/utils/field-utils";
import { useConfig } from "hooks/config";
import { useTranslator } from "hooks/translator";

const InputSelectField = (props) => (
  
    
  
)

const InputSelectContent = (props) =>  {
  const { setFocus, selectProps, setViewOpen }          = props
  const {mode}                                          = selectProps
  const { t }                                           = useTranslation()
  const { translator }                                  = useTranslator()
  const client                                          = useApolloClient()
  const notifier                                        = useNotifier()
  const [options, setOptions]                           = useState([])
  const formInfo                                        = useFormInfo()
  const { augProps, fieldProps, info }                  = useFieldInfo()
  const [selected, setSelected]                         = React.useState(getDefaultValue(mode, fieldProps.value));  // this value is never allowed to be 'undefined'!!
  const [open, setOpen]                                 = React.useState(false);
  const pageSize                                        = 100
  const [tabLoading, setTabLoading]                     = useState(false)
  const [loading, setLoading]                           = useState(false)
  const [filter, setFilter]                             = useState(undefined)
  const [hasNextPage, setHasNextPage]                   = useState(true)
  const [highlighted, setHighlighted]                   = useState({option: null, index: -1})
  const [request, setRequest]                           = useState(undefined)
  const dependantValues                                 = useDependants()
  const [input, setInput, debouncedInput, skipDebounce] = useDebounce("", 500)
  const {signal}                                        = useAutoSubmitSignal()
  const {props: {taskRendering}}                        = useConfig()


  useEffect(() => {
    if (_.isEmpty(dependantValues))
      return undefined

    const value = getDefaultValue(mode, fieldProps.value)
    setSelected(value)
    setInput("")
    setOptions([])
    handleValidate(null, value)
  },[dependantValues])

  // adjust selected value, provided by formik
  React.useEffect(() => {
    var selected = fieldProps.value
    if (mode == 'multiple') {
      selected = selected && Array.isArray(selected) ? selected.map(toOption) : []
    } else {
      selected = toOption(selected)
    }

    setSelected(selected)
  }, [fieldProps.value]);

  // load new options based on input
  React.useEffect(() => {
    if (!open){
      request?.cancel()
      setOptions([])
      setFilter(undefined)

      return undefined;
    }
      
    if (input != debouncedInput) {
      request?.cancel()
    } else if (options.length == 0){
      loadOptions(input, true, true)
    } else if (debouncedInput !== selected?.label) { // TODO: does this handle well with multiple select... I think not.
      loadOptions(debouncedInput, true)
    } 
  }, [open, debouncedInput, input]);


  function updateOptions(newOptions, filter) {
    setOptions(newOptions)
    setFilter(filter)
    setHasNextPage(!(newOptions.length < pageSize))
  }

  const loadOptions = async (newFilter, reset, overwrite) => {
    request?.cancel()

    if (!open)
      return undefined

    if (!overwrite && reset && newFilter === filter)
      return undefined

    const nextPage = reset ? 1 : Number(Math.floor(options.length / pageSize)) + 1
    const usedFilter = reset ? newFilter : filter

    setLoading(true)

    let cancelled = false
    const cancel = () => {
      if (!cancelled) {
        cancelled = true
        setLoading(false)
      }
    }

    function updateOptionsLocal(newOptions) {
      if (!cancelled) {
        const combinedOptions = reset ? newOptions : [...options].concat(newOptions)
        updateOptions(combinedOptions, usedFilter)
      }
    }

    const variables = {
      ...formInfo.reference,
      path:   info.rpath,
      filter: usedFilter,
      start:  (nextPage - 1) * pageSize,
      count:  pageSize,
      values: dependantValues
    }

    console.log("field %o requests choices with: %o", info.rpath, variables)
    const newRequest = client.query({ query: GET_CHOICES, variables })
    newRequest.cancel = cancel
    setRequest(newRequest)

    newRequest
      .then(
        result => {
          if (!cancelled){
            console.debug("loadOptions: searchQuery=%o, page=%o, variables=%o, result=%o", usedFilter, nextPage, variables, result)
            const newOptions = result.data.choices
            updateOptionsLocal(newOptions)
          }
        },
        error => {
          if (!cancelled) {
            console.error("failed to get options: %o", error.message)
            const report = Report.from(error, translator, { category: Report.backend })
            report.addToNotifier(notifier)
          }
        }
      )
      .catch(reason => {
        if (!cancelled) {
          notifier.error("Could not retrieve more options")
          console.error("the frontend has an issue with retrieving options: " + reason)
        }
      })
      .finally(() => {
        if (!cancelled){
          setLoading(false)
        }
      })
  }

  const loadTabOption = (e, filter) => {
    request?.cancel()
    setTabLoading(true)

    const variables = {
      ...formInfo.reference,
      path  : info.rpath,
      filter: filter,
      start : 0,
      count : pageSize,
    }

    console.log("field %o requests tab choices with: %o", info.rpath, variables)
    const newRequest = client.query({ query: GET_CHOICES, variables })
    newRequest
      .then(
        result => {
          console.debug("loadTabOptions: filter=%o, result=%o", filter, result)
          const newOptions = result.data.choices
          updateOptions(newOptions)

          if (newOptions.length > 0)
            handleChange(e, newOptions[0])
          else
            notifier.error("Could not select a value in field '" + info.rpath + "' given filter '"+ filter + "'.")
        },
        error => {
          console.error("the frontend has an issue with retrieving options: " + JSON.stringify(error))
        }
      )
      .catch(reason => {
        console.error("the frontend has an issue with retrieving options: " + reason)
      })
      .finally(() => {
        setTabLoading(false)
      })
  }

  const loadNextPage = () => {
    loadOptions(input, false)
  }

  function handleKeyDown(e) {
    switch (e.key) {
      case "ArrowDown":
      case "ArrowUp":
        const focusElement = document.getElementsByClassName("MuiAutocomplete-option Mui-focused")
        if (focusElement.length > 0) {
          const focusType = e.key === "ArrowUp"
            ? {
            behavior: 'smooth',
            block: 'center',
            inline: 'center'
          } :{
            behavior: 'smooth',
            block: 'start',
            inline: 'start'
          }

          focusElement[0].scrollIntoView(focusType);
        }

        break;

      case "Tab": // tab completion
        if (input == "")
          return

        if ((mode == "multiple" &&  input.length == 0) || (mode == "single" && input == selected?.label))
          return undefined

        if (mode == "multiple") {
          e.preventDefault();
          e.stopPropagation()
        }

        const option = highlighted?.option
        const tabAlreadyMatches = Boolean(option?.label?.toLowerCase().includes(input?.toLowerCase()))
        if (tabAlreadyMatches){
          handleChange(e, option)
        } else {
          skipDebounce()
          loadTabOption(e, input)
        }
        break;

      case "Enter": // enter completion
        e.preventDefault();
        if (input !== debouncedInput) {
          e.stopPropagation()
          skipDebounce()
        }
        break;

      default:
    }
  }

  const getHighlightedIndex = (e, option, reason) => {
    switch (reason) {
      case "keyboard":
      case "auto":     return option ? options.indexOf(option) : -1
      case "mouse":    return e?.target?.dataset?.optionIndex || -1
      default:         return -1
    }
  }

  const handleHighlightChange = (e, value, reason) => {
    const index = getHighlightedIndex(e, value, reason)
    if (highlighted?.option?.value != value?.value)
      setHighlighted({ option: value ? value : null, index})
  }

  function handleValidate(e, option) {
    switch (mode) {
      case 'multiple':
        augProps.setError(validateField("multiselect", fieldProps.required, e, option))
        break
      case 'single':
        augProps.setError(validateField("select", fieldProps.required, e, option))
        break
      default:
    }
  }

  function handleBlur (e) {
    setFocus(false)
    fieldProps.onBlur(e)
    handleValidate(e, selected)
    signal()
  }

  function handleFocus(e) {
    setFocus(true)
    fieldProps.onFocus(e)
  }

  // store selected option locally and with formik. This HAS to be in two places, as a workaround for re-render behavior.
  const getSelectedChange = (value, previousSelected, allowRemoval) => {
    const matchesOption = (option) => option.value === value?.value || option.value === value
    const addValueTo    = (selected) => {
      if (selected.some(matchesOption))
        return allowRemoval ? selected.filter(o => !matchesOption(o)) : selected
      else
        return [...selected, value]
    }

    if (Array.isArray(value))
      return value
    else {
      if (mode == "multiple") {
        const selected = !Array.isArray(previousSelected) ? [] : previousSelected
        return value ? addValueTo(selected) : selected
      } else
        return value
    }
  }

  function handleChange(e, value) {
    request?.cancel()

    augProps.setRuntimeError(undefined)
    const newSelected = getSelectedChange(value, selected, true)
    setSelected(newSelected)
    augProps.setValue(newSelected)
    handleValidate(e, newSelected)

    // in case of multiple select, trigger autosubmit on change 
    if (mode == "multiple" && !isEmptyValue(value))
      signal()
  }

  function handleInputChange(e, value) {
    setInput(value)
  }

  const listboxProps = {
    highlightedIndex: highlighted.index,
    hasNextPage,
    isNextPageLoading: loading,
    loadNextPage
  }

  const multipleProps = {
    renderTags: (value, getTagProps) =>
      value.map((option, index) =>
        {option.label}}
        />
      ),
    multiple: true,
    limitTags: 5,
    disableCloseOnSelect: true
  }

  const autocompleteProps = mode === 'multiple' ? multipleProps : {}
  const { onChange, ...localFieldProps } = fieldProps
  
  return (
     x}
      options={options}
      isOptionEqualToValue={(option, value) => option.value === value?.value}

      value={!selected ? (mode === 'multiple' ? [] : null) : selected}
      onChange={handleChange}

      inputValue={input}
      onInputChange={handleInputChange}

      autoHighlight
      onHighlightChange={handleHighlightChange}

      open={open}
      onOpen={()  => { setOpen(true); setViewOpen(true) }}
      onClose={() => { setOpen(false); setViewOpen(false) }}

      // localization options
      loadingText={t("select.loading")}
      clearText={t("select.clear")}
      closeText={t("select.close")}
      openText={t("select.open")}
      noOptionsText={t("select.nooptions")}

      fullWidth
      disableListWrap

      ListboxProps={{listboxProps}}
      ListboxComponent={Listbox}


      //renderInput={(params) =>
      //  
      //}


       renderInput={(params) => {
        params.inputProps.onKeyDown = handleKeyDown; // add onKey event here, because it is overwritten if you give it to 'autocomplete'
        return (
          
                  {tabLoading || loading ?  : null}
                  {params.InputProps.endAdornment}
                
              ),
              inputProps: {
                ...params.inputProps,
                "data-state": "dynamic"
              }
            }}
            size={fieldProps.size}
            style={{flexGrow: 1, ...(taskRendering == 'standard' ? fieldMinWidthStyle(formInfo, info.field) : undefined)}}

            fullWidth
          />
        )
      }}


      renderOption={(props, option, state) => renderOptionbox(props, option, state, highlighted)}
    />
  );
}

function getDefaultValue(mode, value) {
  const defaultValue = mode == 'multiple' ? [] : null

  if (value instanceof Boolean || value)
    return value
  else
    return defaultValue
}

function renderOptionbox(props, option, state, highlighted) {
  const index     = props['data-option-index']
  const className = index == highlighted.index ? 'MuiAutocomplete-option Mui-focused' : 'MuiAutocomplete-option'

  return (
    
      {option.label}
    
  )
}

const wrapStyle = {
  whiteSpace: 'nowrap',
  overflow: 'hidden',
  textOverflow: 'ellipsis'
}


export default InputSelectField




© 2015 - 2024 Weber Informatics LLC | Privacy Policy