Home Reference Source

src/components/controls/InputSelectTree/InputSelectTree.jsx

import React, { Fragment } from 'react';
import TreeSelect from 'rc-tree-select';
import ReactDOM from 'react-dom';
import {
  difference,
  filter as filterF,
  eq,
  every,
  find,
  isArray,
  isNumber,
  isString,
  isEmpty,
  keys,
  forEach,
  map,
  reduce,
  memoize,
  some,
  uniq,
  uniqBy,
  unionWith,
  isEqual,
} from 'lodash';
import Icon from '../../snippets/Icon/Icon';
import InlineSpinner from '../../snippets/Spinner/InlineSpinner';
import CheckboxN2O from '../Checkbox/CheckboxN2O';
import { defaultProps, propTypes } from './allProps';
import { compose, withState } from 'recompose';
import propsResolver from '../../../utils/propsResolver';
import { visiblePartPopup, getCheckedStrategy } from './until';
import TreeNode from './TreeSelectNode';
import { injectIntl } from 'react-intl';
import cx from 'classnames';

/**
 * @param onOpen - callback функция вызываемая при открытии popup
 * @param {function} onFocus
 * @param value - выбранное значение
 * @reactProps {function} onBlur
 * @reactProps {any} searchPlaceholder
 * @reactProps {string} placeholder
 * @reactProps {function} setTreeExpandedKeys
 * @reactProps {array} treeExpandedKeys
 * @reactProps {function} closePopupOnSelect
 * @reactProps {node} children
 * @reactProps {function} closePopupOnSelect
 * @param loading -  флаг анимации загрузки
 * @param parentFieldId - значение ключа parent в данных
 * @param valueFieldId - значение ключа value в данных
 * @param labelFieldId - значение ключа label в данных
 * @param iconFieldId - значение ключа icon в данных
 * @param badgeFieldId - значение ключа badge в данных
 * @param badgeColorFieldId - значение ключа badgeColor в данных
 * @param hasChildrenFieldId - значение ключа hasChildren в данных
 * @param format - формат
 * @param data - данные для построения дерева
 * @param onSearch - callback функция вызываемая поиске
 * @param onSelect - callback функция вызываемая выборе элемента дерева
 * @param onChange - callback функция вызываемая изменении элемента дерева
 * @param hasCheckboxes - флаг для показа чекбоксов в элементах дерева. Переводит InputSelectTree в мульти режим
 * @param filter - варианты фильтрации
 * @param multiSelect - флаг для перевода InputSelectTree в мульти режим
 * @param onClose - callback функция вызываемая при закрытии popup
 * @param onToggle - callback функция вызываемая при открытии/закрытии popup
 * @param disabled - флаг неактивности
 * @param _handleItemOpen - callback функция вызываемая ajax true. Передает value открывающего элемента дерева
 * @param ajax - флаг динамичексой подгрузки данных. В данных обязательно указывать параметр hasChildrens
 * @param notFoundContent - текст если поиск не выдал результатов
 * @returns {*}
 * @constructor
 */
//TODO переделать в класс
function InputSelectTree({
  onOpen,
  onFocus,
  value,
  onBlur,
  searchPlaceholder,
  dropdownExpanded,
  setDropdownExpanded,
  placeholder,
  setTreeExpandedKeys,
  notFoundContent,
  treeExpandedKeys,
  closePopupOnSelect,
  loading,
  isLoading,
  parentFieldId,
  valueFieldId,
  labelFieldId,
  iconFieldId,
  imageFieldId,
  badgeFieldId,
  badgeColorFieldId,
  hasChildrenFieldId,
  format,
  data,
  onSearch,
  onSelect,
  onChange,
  hasCheckboxes,
  filter,
  multiSelect,
  children,
  onClose,
  onToggle,
  handleItemOpen,
  ajax,
  className,
  intl,
  dropdownPopupAlign,
  ref,
  showCheckedStrategy,
  ...rest
}) {
  const popupProps = {
    prefixCls: 'n2o-select-tree',
    iconFieldId,
    imageFieldId,
    labelFieldId,
    badgeFieldId,
    badgeColorFieldId,
  };

  /**
   * Функуия для создания дерева.
   * Преобразует коллекцию вида [..., { ... }] в [ ..., {..., children: [...]}]
   * Вложение происходит при совпадении valueFieldId и parentFieldId.
   * @param items
   * @returns {*}
   */
  const createTree = memoize(items => {
    let itemsByID = [...items].reduce(
      (acc, item) => ({
        ...acc,
        [item[valueFieldId]]: {
          ...item,
          key: item[valueFieldId],
          value: item[valueFieldId],
          title: format
            ? propsResolver({ format }, item).format
            : visiblePartPopup(item, popupProps),
          ...(ajax && { isLeaf: !item[hasChildrenFieldId] }),
          children: [],
        },
      }),
      {}
    );

    keys(itemsByID).forEach(key => {
      if (
        itemsByID[key][parentFieldId] &&
        itemsByID[itemsByID[key][parentFieldId]] &&
        itemsByID[itemsByID[key][parentFieldId]].children
      ) {
        itemsByID[itemsByID[key][parentFieldId]].children.push({
          ...itemsByID[key],
        });
      }
    });

    return keys(itemsByID)
      .filter(key => !itemsByID[key][parentFieldId])
      .reduce((acc, key) => [...acc, { ...itemsByID[key] }], []);
  });

  /**
   * Функция для поиска.
   * При поиске вызов функции происходит для каждого элемента дерева.
   * @param input
   * @param node
   * @returns {*}
   */
  const handlerFilter = (input, node) => {
    const mode = ['includes', 'startsWith', 'endsWith'];

    if (mode.includes(filter)) {
      return String.prototype[filter].call(node.props[labelFieldId], input);
    }
    return true;
  };

  /**
   * Взять данные по ids.
   * ['id', 'id'] => [{ id: 'id', ... },{ id: 'id', ... }]
   * @param ids
   */
  const getDataByIds = ids =>
    filterF(data, node => some(ids, v => v === node[valueFieldId]));

  /**
   * Берет всех потомков у родителей
   * @param ids
   * @param arrData
   */
  const getChildWithParenId = (ids, arrData) => {
    let buff = getDataByIds(ids);

    // рекурсивно спускаемся вниз ко всем потомкам
    // и добавляем потомков в буфер
    const recursionFn = ids =>
      forEach(ids, id => {
        const childs = filterF(arrData, [parentFieldId, id]);
        buff = buff.concat(childs);
        recursionFn(map(childs, valueFieldId));
      });

    recursionFn(ids, arrData);
    return buff;
  };

  /**
   * Берет всех родителей у потомков
   * если все потомки выделены
   * @param ids
   * @param arrData
   */
  const getParentsWithChildId = (ids, arrData) => {
    let buff = getDataByIds(ids);

    // Берем только те id потомков у которых выделены
    // родители
    const recursionFn = ids => {
      let parentBuff = [];

      forEach(ids, id => {
        const node = find(arrData, { id });
        const parentIdNode = node[parentFieldId];
        if (!parentIdNode) {
          return false;
        }

        const allParendChilds = filterF(arrData, [parentFieldId, parentIdNode]);
        const hasParentAllChildsCheck = every(allParendChilds, child =>
          ids.includes(child[valueFieldId])
        );

        if (hasParentAllChildsCheck) {
          parentBuff.push(parentIdNode);
          const buffHasParent = find(buff, { id: parentIdNode });
          if (!buffHasParent) {
            buff.push(find(arrData, { id: parentIdNode }));
          }
        }
      });

      if (!isEmpty(parentBuff)) {
        recursionFn(parentBuff);
      }
    };

    recursionFn(ids, arrData);
    return buff;
  };

  const getSingleValue = value => find(data, [valueFieldId, value]);
  const getMultiValue = value => {
    // if (isArray(value) && eq(showCheckedStrategy, SHOW_PARENT)) {
    //   return getChildWithParenId(value, data);
    // } else if (isArray(value) && eq(showCheckedStrategy, SHOW_CHILD)) {
    //   return getParentsWithChildId(value, data);
    // } else {
    // стратегия SHOW_ALL
    return getDataByIds(value);
  };
  /**
   * Функция преобразования value rcTreeSelect в формат n2o
   * Производит поиск по родителям и потомкам.
   * rcTreeSelect не дает информации о выделенных потомках при моде 'SHOW_PARENT'
   * и о выделенных родителях при 'SHOW_CHILD'
   * ['id', 'id'] => [{ id: 'id', ... },{ id: 'id', ... }]
   * @param value
   * @returns {*}
   */
  const getItemByValue = value => {
    if (!value) return null;
    if (!multiSelect) {
      return getSingleValue(value);
    }
    return getMultiValue(value);
  };

  /**
   * Функция для обратного преобразования value n2o в формат rcTreeSelect
   * ['id', 'id'] => [{ id: 'id', ... },{ id: 'id', ... }]
   * @param value
   * @returns {*}
   */
  const setValue = value => {
    if (!value) return null;
    if (isArray(value)) {
      return map(value, v => v[valueFieldId]);
    }
    return value[valueFieldId];
  };

  /**
   * Функция для переопределения onChange
   * @param value
   */
  const handleChange = value => {
    onChange(getItemByValue(value));
  };

  /**
   * Функция для переопределения onSelect
   * @param value
   */
  const handleSelect = value => {
    onSelect(getItemByValue(value));
  };

  /**
   * Функция для переопределения onSearch
   * @param value
   */
  const handleSearch = value => {
    onSearch(value);
    return true;
  };
  /**
   * Функция для контроля открытия/закрытия popup
   * @param visible
   * @returns {boolean}
   */
  const handleDropdownVisibleChange = visible => {
    if (visible) {
      onFocus();
    } else {
      onBlur();
    }
    onToggle(visible);
    setDropdownExpanded(visible);
    visible ? onOpen() : onClose();
    if (ajax) setTreeExpandedKeys([]);
    return false;
  };

  /**
   * Функция для контроля открытия/закрытия элемента дерева
   * @param keys
   */
  const onTreeExpand = async keys => {
    const currentKey = difference(keys, treeExpandedKeys);
    if (ajax) {
      await handleItemOpen(currentKey[0]);
    }
    setTreeExpandedKeys(keys);
  };

  const renderSwitcherIcon = ({ isLeaf }) =>
    isLeaf ? null : <Icon name="fa fa-chevron-right" />;

  const clearIcon = <Icon name="fa fa-times" />;

  const inputIcon = loading ? (
    <InlineSpinner />
  ) : (
    <Icon name="fa fa-chevron-down" />
  );

  const getPopupContainer = container => container;

  const open = !loading ? dropdownExpanded : false;

  return (
    <TreeSelect
      tabIndex={1}
      {...value && { value: setValue(value) }}
      open={open}
      onDropdownVisibleChange={handleDropdownVisibleChange}
      className={cx('n2o form-control', className, { loading })}
      switcherIcon={renderSwitcherIcon}
      inputIcon={inputIcon}
      multiple={multiSelect}
      treeCheckable={hasCheckboxes && <CheckboxN2O inline />}
      treeData={createTree(data)}
      filterTreeNode={handlerFilter}
      treeNodeFilterProp={labelFieldId}
      removeIcon={clearIcon}
      clearIcon={clearIcon}
      onChange={handleChange}
      onSelect={handleSelect}
      onSearch={handleSearch}
      treeExpandedKeys={treeExpandedKeys}
      onTreeExpand={onTreeExpand}
      dropdownPopupAlign={dropdownPopupAlign}
      prefixCls="n2o-select-tree"
      showCheckedStrategy={getCheckedStrategy(showCheckedStrategy)}
      getPopupContainer={getPopupContainer}
      notFoundContent={intl.formatMessage({
        id: 'inputSelectTree.notFoundContent',
        defaultMessage: notFoundContent || ' ',
      })}
      placeholder={intl.formatMessage({
        id: 'inputSelectTree.placeholder',
        defaultMessage: placeholder || ' ',
      })}
      searchPlaceholder={intl.formatMessage({
        id: 'inputSelectTree.searchPlaceholder',
        defaultMessage: searchPlaceholder || ' ',
      })}
      {...rest}
    >
      {children}
    </TreeSelect>
  );
}

InputSelectTree.defaultProps = defaultProps;
InputSelectTree.propTypes = propTypes;

export { TreeNode };

export default compose(
  withState('treeExpandedKeys', 'setTreeExpandedKeys', []),
  withState('dropdownExpanded', 'setDropdownExpanded', false),
  injectIntl
)(InputSelectTree);