Home Reference Source

src/components/controls/InputSelect/InputSelect.jsx

import React from 'react';
import { findDOMNode } from 'react-dom';
import PropTypes from 'prop-types';
import onClickOutside from 'react-onclickoutside';
import cx from 'classnames';
import InputSelectGroup from './InputSelectGroup';
import PopupList from './PopupList';
import InputContent from './InputContent';
import { find, isEqual, isEmpty } from 'lodash';
import Alert from '../../snippets/Alerts/Alert';
import { Manager, Reference, Popper } from 'react-popper';
import { MODIFIERS } from '../DatePicker/utils';

/**
 * InputSelect
 * @reactProps {object} style - css стили
 * @reactProps {boolean} loading - флаг анимации загрузки
 * @reactProps {array} options - данные
 * @reactProps {string} valueFieldId - значение ключа value в данных
 * @reactProps {string} labelFieldId - значение ключа label в данных
 * @reactProps {string} iconFieldId - поле для иконки
 * @reactProps {string} imageFieldId - поле для картинки
 * @reactProps {string} badgeFieldId - поле для баджей
 * @reactProps {string} badgeColorFieldId - поле для цвета баджа
 * @reactProps {boolean} disabled - флаг неактивности
 * @reactProps {array} disabledValues - неактивные данные
 * @reactProps {string} filter - варианты фильтрации
 * @reactProps {string} value - текущее значение
 * @reactProps {function} onToggle
 * @reactProps {function} onInput - callback при вводе в инпут
 * @reactProps {function} onChange - callback при выборе значения или вводе
 * @reactProps {function} onSelect
 * @reactProps {function} onScrollENd - callback при прокрутке скролла popup
 * @reactProps {string} placeHolder - подсказка в инпуте
 * @reactProps {boolean} resetOnBlur - фича, при которой сбрасывается значение контрола, если оно не выбрано из popup
 * @reactProps {function} onOpen - callback на открытие попапа
 * @reactProps {function} onClose - callback на закрытие попапа
 * @reactProps {boolean} multiSelect - флаг мульти выбора
 * @reactProps {string} groupFieldId - поле для группировки
 * @reactProps {boolean} closePopupOnSelect - флаг закрытия попапа при выборе
 * @reactProps {boolean} hasCheckboxes - флаг наличия чекбоксов
 * @reactProps {string} format - формат
 * @reactProps {function} onSearch
 * @reactProps {boolean} expandPopUp
 * @reactProps {array} alerts
 * @reactProps {boolean} popupAutoSize - флаг включения автоматическиого расчета длины PopUp
 */

class InputSelect extends React.Component {
  constructor(props) {
    super(props);
    const {
      value,
      options,
      valueFieldId,
      labelFieldId,
      multiSelect,
    } = this.props;
    const valueArray = Array.isArray(value) ? value : value ? [value] : [];
    const input = value && !multiSelect ? value[labelFieldId] : '';
    this.state = {
      inputFocus: false,
      isExpanded: false,
      isInputSelected: false,
      value: valueArray,
      activeValueId: null,
      options,
      input,
    };

    this._hideOptionsList = this._hideOptionsList.bind(this);
    this._handleItemSelect = this._handleItemSelect.bind(this);
    this._removeSelectedItem = this._removeSelectedItem.bind(this);
    this._setIsExpanded = this._setIsExpanded.bind(this);
    this._handleClick = this._handleClick.bind(this);
    this._clearSelected = this._clearSelected.bind(this);
    this._setNewInputValue = this._setNewInputValue.bind(this);
    this._setInputFocus = this._setInputFocus.bind(this);
    this._setActiveValueId = this._setActiveValueId.bind(this);
    this._handleValueChangeOnSelect = this._handleValueChangeOnSelect.bind(
      this
    );
    this._handleValueChangeOnBlur = this._handleValueChangeOnBlur.bind(this);
    this._handleDataSearch = this._handleDataSearch.bind(this);
    this._handleElementClear = this._handleElementClear.bind(this);
    this.setSelectedItemsRef = this.setSelectedItemsRef.bind(this);
    this.setTextareaRef = this.setTextareaRef.bind(this);
    this.setSelectedListRef = this.setSelectedListRef.bind(this);
    this.onInputBlur = this.onInputBlur.bind(this);
    this.onFocus = this.onFocus.bind(this);
    this.setInputRef = this.setInputRef.bind(this);
  }

  setTextareaRef(poperRef) {
    return r => {
      this._textarea = r;
      poperRef(r);
    };
  }

  setSelectedListRef(selectedList) {
    this._selectedList = selectedList;
  }

  componentWillReceiveProps(nextProps) {
    const {
      multiSelect,
      value,
      valueFieldId,
      labelFieldId,
      options,
      loading,
    } = nextProps;
    if (!isEqual(nextProps.options, this.state.options)) {
      this.setState({ options });
    }
    if (!isEqual(nextProps.value, this.props.value)) {
      const valueArray = Array.isArray(value) ? value : value ? [value] : [];
      const input = value && !multiSelect ? value[labelFieldId] : '';

      this.setState({ value: valueArray, input });
    }
  }

  /**
   * установить акстивный элемент дропдауна
   * @param activeValueId
   * @private
   */
  _setActiveValueId(activeValueId) {
    this.setState({ activeValueId });
  }

  /**
   * обработка изменения значения при потери фокуса(считаем, что при потере фокуса пользователь закончил вводить новое значение)
   * @private
   */
  _handleValueChangeOnBlur() {
    const { value, input, options } = this.state;
    const { onChange, multiSelect, resetOnBlur, labelFieldId } = this.props;
    const newValue = find(options, { [labelFieldId]: input });

    const findValue = find(value, [labelFieldId, input]);

    if (input && isEmpty(findValue)) {
      this.setState(
        {
          input: multiSelect ? '' : (value[0] && value[0][labelFieldId]) || '',
          value,
        },
        () => onChange(this._getValue())
      );
    }
    if (!input && value.length) {
      this.setState(
        {
          input: '',
          value: multiSelect ? value : [],
        },
        () => onChange(this._getValue())
      );
    }
  }

  /**
   * Обработка клика на инпут
   * @private
   */
  _handleClick() {
    // const searchCallback = () => {
    this._setIsExpanded(true);
    //};
    //this._handleDataSearch(this.state.input, false, searchCallback);
    this._setSelected(false);
    this._setInputFocus(true);
  }

  /**
   * Обработка изменения значения при выборе из дропдауна
   * @param item
   * @private
   */
  _handleValueChangeOnSelect(item) {
    const { value, input, options } = this.state;
    const { onChange, multiSelect, resetOnBlur, labelFieldId } = this.props;
    this.setState(
      {
        input: multiSelect ? item[labelFieldId] : '',
        value: multiSelect ? [...value, item] : [item],
      },
      () => {
        onChange(this._getValue());
      }
    );
  }

  /**
   * Возвращает текущее значение (массив - если ипут селект, объект - если нет)
   * или null если пусто
   * @returns {*}
   * @private
   */

  _getValue() {
    const { multiSelect } = this.props;
    const { value } = this.state;
    const rObj = multiSelect ? value : value[0];
    return rObj || null;
  }

  /**
   * Удаляет элемент из списка выбранных
   * @param item - элемент
   * @private
   */

  _removeSelectedItem(item) {
    const { onChange } = this.props;
    const value = this.state.value.filter(i => i.id !== item.id);
    this.setState({ value }, onChange(value));
  }

  /**
   * Скрывает popUp
   * @private
   */

  _hideOptionsList() {
    this._setIsExpanded(false);
    this._setInputFocus(false);
  }

  /**
   * Очищает выбранный элемент
   * @private
   */

  _clearSelected() {
    const { onChange } = this.props;
    this.setState({ value: [], input: '' }, () => onChange(this._getValue()));
  }

  /**
   * установить / сбросить фокус
   * @param inputFocus
   * @private
   */
  _setInputFocus(inputFocus) {
    this.setState({ inputFocus });
  }

  /**
   * скрыть / показать попап
   * @param isExpanded
   * @private
   */
  _setIsExpanded(isExpanded) {
    const { disabled, onToggle, onClose, onOpen } = this.props;
    const { isExpanded: previousIsExpanded } = this.state;
    if (!disabled && isExpanded !== previousIsExpanded) {
      this.setState({ isExpanded });
      onToggle(isExpanded);
      isExpanded ? onOpen() : onClose();
    }
  }

  /**
   * выделить текст
   * @param isInputSelected
   * @private
   */
  _setSelected(isInputSelected) {
    this.setState({ isInputSelected });
  }

  /**
   * Выполняет поиск элементов для popUp, если установлен фильтр
   * @param newValue - значение для поиска
   * @private
   */

  _handleDataSearch(input, delay = 400, callback) {
    const { onSearch, filter, labelFieldId, options } = this.props;

    if (filter && ['includes', 'startsWith', 'endsWith'].includes(filter)) {
      const filterFunc = item => String.prototype[filter].call(item, input);
      const filteredData = options.filter(item =>
        filterFunc(item[labelFieldId])
      );
      this.setState({ options: filteredData });
    } else {
      //серверная фильтрация
      const labels = this.state.value.map(item => item[labelFieldId]);
      if (labels.some(label => label === input)) {
        onSearch('', delay, callback);
      } else {
        onSearch(input, delay, callback);
      }
    }
  }

  /**
   * новое значение инпута search)
   * @param input
   * @private
   */
  _setNewInputValue(input) {
    const { onInput, resetOnBlur, multiSelect } = this.props;
    const { value } = this.state;
    const onSetNewInputValue = input => {
      onInput(input);
      this._handleDataSearch(input);
    };

    if (this.state.input !== input) {
      this._setSelected(false);
      this.setState({ input }, () => onSetNewInputValue(input));
    }
  }

  /**
   * Обрабатывает выбор элемента из popUp
   * @param item - элемент массива options
   * @private
   */

  _handleItemSelect(item) {
    const {
      multiSelect,
      closePopupOnSelect,
      labelFieldId,
      options,
      onSelect,
      onChange,
    } = this.props;
    const selectCallback = () => {
      closePopupOnSelect && this._hideOptionsList();
      onSelect(item);
      onChange(this._getValue());
      this._setSelected(true);
    };

    this.setState(
      prevState => ({
        value: multiSelect ? [...prevState.value, item] : [item],
        input: multiSelect ? '' : item[labelFieldId],
        options,
      }),
      () => {
        selectCallback();
        this.props.onBlur();
      }
    );
  }

  /**
   * Очищает инпут и результаты поиска
   * @private
   */

  _clearSearchField() {
    this.setState({ input: '' }, this._handleDataSearch);
  }

  /**
   * Очищениеб сброс фокуса, выделенного значения
   * @private
   */
  _handleElementClear() {
    if (!this.props.disabled) {
      this._clearSearchField();
      this._clearSelected();
      this._setInputFocus(false);
    }
  }

  /**
   * Обрабатывает клик за пределы компонента
   * @param evt
   */

  handleClickOutside(evt) {
    const { resetOnBlur } = this.props;
    const { isExpanded } = this.state;
    if (isExpanded) {
      this._hideOptionsList();
      resetOnBlur && this._handleValueChangeOnBlur();
      this.props.onBlur();
    }
  }

  setSelectedItemsRef(ref) {
    this._selectedItems = ref;
  }

  calcSelectedItemsWidth() {
    if (this._selectedItems) {
      const element = findDOMNode(this._selectedItems);
      if (element && element.getBoundingClientRect) {
        return element.getBoundingClientRect().width || undefined;
      }
    }
  }

  onInputBlur() {
    if (!this.state.isExpanded) {
      this.props.onBlur();
    }
  }

  onFocus() {
    const { openOnFocus } = this.props;
    if (openOnFocus) {
      this._setIsExpanded(true);
    }
  }

  setInputRef(popperRef) {
    return r => {
      this._input = r;
      popperRef(r);
    };
  }

  calcPopperWidth() {
    if ((this._input || this._textarea) && !this.props.popupAutoSize) {
      return this._input
        ? this._input.getBoundingClientRect().width
        : this._textarea.getBoundingClientRect().width;
    }
  }
  /**
   * Рендер
   */
  render() {
    const {
      loading,
      className,
      valueFieldId,
      labelFieldId,
      iconFieldId,
      disabled,
      placeholder,
      multiSelect,
      disabledValues,
      imageFieldId,
      groupFieldId,
      hasCheckboxes,
      format,
      badgeFieldId,
      badgeColorFieldId,
      onScrollEnd,
      expandPopUp,
      style,
      alerts,
      flip,
      autoFocus,
    } = this.props;
    const inputSelectStyle = { width: '100%', cursor: 'text', ...style };
    const selectedPadding = this.calcSelectedItemsWidth();
    const needAddFilter = !find(
      this.state.value,
      item => item[labelFieldId] === this.state.input
    );

    return (
      <div
        style={inputSelectStyle}
        className={cx('n2o-input-select n2o-input-select--default', {
          disabled,
        })}
      >
        <Manager>
          <Reference>
            {({ ref }) => (
              <InputSelectGroup
                isExpanded={this.state.isExpanded}
                setIsExpanded={this._setIsExpanded}
                loading={loading}
                selected={this.state.value}
                input={this.state.input}
                iconFieldId={iconFieldId}
                imageFieldId={imageFieldId}
                multiSelect={multiSelect}
                isInputInFocus={this.state.inputFocus}
                onClearClick={this._handleElementClear}
                disabled={disabled}
                className={className}
                setSelectedItemsRef={this.setSelectedItemsRef}
              >
                <InputContent
                  setRef={this.setInputRef(ref)}
                  onFocus={this.onFocus}
                  onBlur={this.onInputBlur}
                  loading={loading}
                  value={this.state.input}
                  disabled={disabled}
                  disabledValues={disabledValues}
                  valueFieldId={valueFieldId}
                  placeholder={placeholder}
                  options={this.state.options}
                  openPopUp={this._setIsExpanded}
                  closePopUp={this._setIsExpanded}
                  onInputChange={this._setNewInputValue}
                  onRemoveItem={this._removeSelectedItem}
                  isExpanded={this.state.isExpanded}
                  isSelected={this.state.isInputSelected}
                  inputFocus={this.state.inputFocus}
                  iconFieldId={iconFieldId}
                  activeValueId={this.state.activeValueId}
                  setActiveValueId={this._setActiveValueId}
                  imageFieldId={imageFieldId}
                  selected={this.state.value}
                  labelFieldId={labelFieldId}
                  clearSelected={this._clearSelected}
                  multiSelect={multiSelect}
                  onClick={this._handleClick}
                  onSelect={this._handleItemSelect}
                  autoFocus={autoFocus}
                  selectedPadding={selectedPadding}
                  setTextareaRef={this.setTextareaRef(ref)}
                  setSelectedListRef={this.setSelectedListRef}
                  _textarea={this._textarea}
                  _selectedList={this._selectedList}
                />
              </InputSelectGroup>
            )}
          </Reference>
          {this.state.isExpanded && (
            <Popper
              placement="bottom-start"
              modifiers={MODIFIERS}
              positionFixed={true}
            >
              {({ ref, style, placement }) => (
                <div
                  ref={ref}
                  style={{
                    ...style,
                    minWidth: this.calcPopperWidth(),
                    maxWidth: 600,
                  }}
                  data-placement={placement}
                  className="n2o-pop-up"
                >
                  <PopupList
                    isExpanded={this.state.isExpanded}
                    activeValueId={this.state.activeValueId}
                    setActiveValueId={this._setActiveValueId}
                    onScrollEnd={onScrollEnd}
                    filterValue={{
                      [labelFieldId]: this.state.input,
                    }}
                    needAddFilter={needAddFilter}
                    options={this.state.options}
                    valueFieldId={valueFieldId}
                    labelFieldId={labelFieldId}
                    iconFieldId={iconFieldId}
                    imageFieldId={imageFieldId}
                    badgeFieldId={badgeFieldId}
                    badgeColorFieldId={badgeColorFieldId}
                    onSelect={this._handleItemSelect}
                    selected={this.state.value}
                    disabledValues={disabledValues}
                    groupFieldId={groupFieldId}
                    hasCheckboxes={hasCheckboxes}
                    onRemoveItem={this._removeSelectedItem}
                    format={format}
                    inputSelect={this.inputSelect}
                  >
                    <div className="n2o-alerts">
                      {alerts &&
                        alerts.map(alert => (
                          <Alert
                            key={alert.id}
                            onDismiss={() => this.props.onDismiss(alert.id)}
                            {...alert}
                          />
                        ))}
                    </div>
                  </PopupList>
                </div>
              )}
            </Popper>
          )}
        </Manager>
      </div>
    );
  }
}

InputSelect.propTypes = {
  style: PropTypes.object,
  loading: PropTypes.bool,
  options: PropTypes.array.isRequired,
  valueFieldId: PropTypes.string.isRequired,
  labelFieldId: PropTypes.string.isRequired,
  iconFieldId: PropTypes.string,
  imageFieldId: PropTypes.string,
  badgeFieldId: PropTypes.string,
  badgeColorFieldId: PropTypes.string,
  disabled: PropTypes.bool,
  disabledValues: PropTypes.array,
  filter: PropTypes.oneOf(['includes', 'startsWith', 'endsWith', false]),
  value: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
  onToggle: PropTypes.func,
  onInput: PropTypes.func,
  onChange: PropTypes.func,
  onSelect: PropTypes.func,
  onScrollEnd: PropTypes.func,
  placeholder: PropTypes.string,
  resetOnBlur: PropTypes.bool,
  onOpen: PropTypes.func,
  onClose: PropTypes.func,
  multiSelect: PropTypes.bool,
  groupFieldId: PropTypes.string,
  closePopupOnSelect: PropTypes.bool,
  hasCheckboxes: PropTypes.bool,
  format: PropTypes.string,
  onSearch: PropTypes.func,
  expandPopUp: PropTypes.bool,
  alerts: PropTypes.array,
  flip: PropTypes.bool,
  autoFocus: PropTypes.bool,
  popupAutoSize: PropTypes.bool,
};

InputSelect.defaultProps = {
  valueFieldId: 'id',
  labelFieldId: 'name',
  iconFieldId: 'icon',
  imageFieldId: 'image',
  badgeFieldId: 'badge',
  loading: false,
  disabled: false,
  disabledValues: [],
  resetOnBlur: false,
  filter: false,
  multiSelect: false,
  closePopupOnSelect: true,
  hasCheckboxes: false,
  expandPopUp: false,
  flip: false,
  autoFocus: false,
  popupAutoSize: false,
  onSearch() {},
  onSelect() {},
  onToggle() {},
  onInput() {},
  onOpen() {},
  onClose() {},
  onChange() {},
  onScrollEnd() {},
  onBlur() {},
};

export default onClickOutside(InputSelect);