Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
Size: Mime:
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import uniqBy from 'unique-by';
import isEqualWith from 'fast-deep-equal';
import CreatableSelect from 'react-select/creatable';
import classnames from 'classnames';

import InputFeedback from '../InputFeedback';
import LimitIndicator from './LimitIndicator';
import Option from './Option';
import Menu from './Menu';
import ColoredMultiValueContainer from './ColoredMultiValueContainer';
import Button from '../../Button/Button';
import ButtonLabel from '../../Button/ButtonLabel';
import LoadingButton from '../../Button/LoadingButton';

import { isEmailValidForWhitelist, TEXT_WITH_NAME_REGEX, STRICT_EMAIL_REGEX } from '../../../utils/validation';

let valueContainer;
const KEYCODE_ENTER = 13;
const KEYCODE_SPACE = 32;
const INPUT_BLUR = 'input-blur';
const SET_VALUE = 'set-value';

class MultiEmailSelect extends Component {
  static propTypes = {
    /** Provides the options for the searchable select component as an array of objects containing a value and label key  */
    options: PropTypes.arrayOf(
      PropTypes.shape({
        value: PropTypes.string.isRequired,
        label: PropTypes.string,
      })
    ),
    /** Provides groups of invitees from a poll as initial options */
    initialOptions: PropTypes.shape({
      participated: PropTypes.shape({
        label: PropTypes.string,
        icon: PropTypes.any,
        options: PropTypes.arrayOf(
          PropTypes.shape({
            value: PropTypes.string,
            label: PropTypes.string,
          })
        ),
      }),
      notParticipated: PropTypes.shape({
        label: PropTypes.string,
        icon: PropTypes.any,
        options: PropTypes.arrayOf(
          PropTypes.shape({
            value: PropTypes.string,
            label: PropTypes.string,
          })
        ),
      }),
      everyone: PropTypes.shape({
        label: PropTypes.string,
        icon: PropTypes.any,
        options: PropTypes.arrayOf(
          PropTypes.shape({
            value: PropTypes.string,
            label: PropTypes.string,
          })
        ),
      }),
    }),
    /** Provide this object if the user should see the activateSuggestions */
    activateSuggestions: PropTypes.shape({
      headline: PropTypes.string,
      text: PropTypes.string,
      buttonText: PropTypes.string,
      buttonLink: PropTypes.string,
      silentButtonText: PropTypes.string,
      onSilentButtonClick: PropTypes.func,
    }),
    placeholderText: PropTypes.string,
    noteText: PropTypes.string,
    buttonText: PropTypes.string,
    isButtonDisabled: PropTypes.bool,
    alreadyInListText: PropTypes.string,
    newOptionText: PropTypes.string,
    addDomainText: PropTypes.string,
    /** Limit of emails the select component can hold */
    emailsLimit: PropTypes.number,
    /** A function that takes all selected options as an argument */
    onAddButtonClick: PropTypes.func,
    hideAddButton: PropTypes.bool,
    /** A function that takes current values, each with an indicator of whether it's valid or not */
    onValueChange: PropTypes.func,
    onInputChange: PropTypes.func,
    styles: PropTypes.object,
    initialValues: PropTypes.arrayOf(
      PropTypes.shape({
        value: PropTypes.string.isRequired,
        label: PropTypes.string,
      })
    ),
    /**
     * Optional prop that decorates the value object with additional properties.
     * Used for example to render values with a different colour.
     * Note: only return the additional properties. No need to return the original object.
     * You can pass a `style` object or a `className` string to customise the style
     * of the contact chips.
     * @param {{ label: string, value: string }} valueEntry The value to decorate
     * @returns {Object} The additional properties to enrich the value entry with.
     * @see {ColoredMultiValueContainer}
     */
    decorateValue: PropTypes.func,
    invalidFormatText: PropTypes.string,
    limitExceededText: PropTypes.string,
    isLoading: PropTypes.bool,
    isDisabled: PropTypes.bool,
    /**
     * (Inherited from CreatableSelect) Formats the option text. This can affect both
     * options in the dropdown menu and selected values.
     * @param {{label: string, value: string, data: Object}} option
     * @param {{context: 'menu'|'value', inputValue: string, selectValue: Object[]}} parameters
     * @returns {ReactNode}
     * @see https://react-select.com/props
     */
    formatOptionLabel: PropTypes.func,
  };
  static defaultProps = {
    onAddButtonClick: () => {},
    placeholderText: null,
    activateSuggestions: null,
    initialOptions: null,
    alreadyInListText: null,
    addDomainText: null,
    options: null,
    noteText: null,
    buttonText: null,
    isButtonDisabled: false,
    emailsLimit: 9999,
    newOptionText: null,
    hideAddButton: false,
    onValueChange: () => {},
    onInputChange: () => {},
    styles: {},
    initialValues: [],
    decorateValue: () => {},
    invalidFormatText: '',
    limitExceededText: '',
    isLoading: false,
    isDisabled: false,
    formatOptionLabel: null,
  };

  constructor(props) {
    super(props);
    const initValues = props.initialValues.length ? props.initialValues : [];
    this.state = {
      menuIsOpen: false,
      value: initValues,
      inputValue: '',
      mobile: false,
      currentOptions: this.props.options,
    };
  }

  componentDidMount() {
    this.multiEmailSelectContainer.querySelector('input').onpaste = this.onPasteHandler;
    [valueContainer] = document.getElementsByClassName('MultiEmailSelect__value-container');
    this.extraSmallDevices = window.innerWidth < 480;
  }

  componentDidUpdate(prevProps, prevState) {
    if (prevState.value !== this.state.value) {
      if (this.extraSmallDevices) {
        valueContainer.scrollTop = valueContainer.scrollHeight - valueContainer.offsetHeight;
        this.multiEmailSelect.focus();
      }
      if (this.props.initialOptions !== null) {
        /* eslint-disable */
        // this is fine per react docs, hance the es lint disable
        // https://reactjs.org/docs/react-component.html#componentdidupdate
        this.setState({
          currentOptions: this.transformInitialOptions(this.props.initialOptions, this.state.value),
        });
      }
    }
    if (prevProps.options !== this.props.options) {
      this.setState({
        currentOptions: this.props.options,
      });
    }
    /* eslint-enable */
  }

  /**
   * Calls the function provided by the onAddButtonClick prop and can be used to work with the selected options outside of this component.
   */
  onAddButtonClickHandler = () => {
    this.props.onAddButtonClick(this.state.value);
  };

  /**
   * Is called after adding or removing options to the select field and after the onPasteHandler.
   */
  onChangeHandler = value => {
    /**
     * Null value returned when trigger removal of last item
     */
    let existingValue = [];
    if (value !== null) existingValue = value;

    const lastOption = existingValue.slice(-1)[0];
    /**
     * Add indicator to each value according to validation rules
     */
    const validatedValues = existingValue.map(valueEntry => ({
      ...valueEntry,
      valid: !this.isInvalidValue(valueEntry),
    }));

    const valuesWithAdditionalData = this.props.onValueChange(validatedValues);
    let finalProvidedValues = existingValue;
    if (valuesWithAdditionalData) {
      finalProvidedValues = valuesWithAdditionalData;
    }

    if (lastOption && lastOption.initalOptions) {
      value.pop();
      this.setState({
        value: this.selectInitialOptions(finalProvidedValues.concat(lastOption.initalOptions), this.state.value),
      });
    } else {
      this.setState({ value: finalProvidedValues });
    }
    this.setState({ inputValue: '' });
  };

  // We lose inputValue passed as param completely on blur.
  // Use local state to re-set the state if inputValue is lost.
  // Reset local state if this.state.inputValue length is 1
  // as this will be 1 step ahead of inputValue param string.
  onInputChangeHandler = (inputValue, event) => {
    const { onInputChange } = this.props;
    if (inputValue || this.state.inputValue.length === 1) {
      onInputChange(inputValue);
      this.setState({ inputValue });
    } else if (event.action === INPUT_BLUR) {
      // When a user clicks outside the text field,
      // the text should be kept and set in state rather than being removed
      onInputChange(this.state.inputValue);
      this.setState({ inputValue: this.state.inputValue });
    } else if (event.action === SET_VALUE) {
      // This acts as a patch for the input-blur behaviour above
      // because input-blur gets called on mobile when a selection
      // is made so the inputValue should be set back to an empty string.
      // Previously, this was causing a duplicate email bug on mobile
      // - SE-308
      this.setState({ inputValue: '' });
    }
    this.toggleMenu(inputValue, false, event.action);
  };

  onFocusHandler = () => {
    if (this.extraSmallDevices) {
      this.setState({ mobile: true });
    }
    this.toggleMenu(this.state.inputValue, true, false);
  };

  /**
   * Is triggerd on the onpaste event of the input field and adds all extracted unique ,valid emails to the selected options.
   */
  onPasteHandler = event => {
    const clipboardContents = event && event.clipboardData && event.clipboardData.getData('text/plain');
    const clipboardContentsForIECompatibility = window && window.clipboardData && window.clipboardData.getData('Text');
    const pastedText = clipboardContents || clipboardContentsForIECompatibility;
    if (pastedText) {
      event.preventDefault();
      const pastedEmails = this.extractEmailfromText(pastedText);
      const alreadySelectedOptions = this.state.value.map(entry => entry.value);
      const uniqueEmails = this.getUniqEmails(pastedEmails, alreadySelectedOptions);
      const newValue = [...this.state.value, ...uniqueEmails];

      this.onChangeHandler(newValue);
    }
  };

  onKeyDownInput = e => {
    // Select value on space press
    /**
     * Add check for keycode on enter see onInputChangeHandler
     */
    if (
      (e.keyCode === KEYCODE_ENTER || e.keyCode === KEYCODE_SPACE) &&
      this.multiEmailSelect.select.select.state.focusedOption
    ) {
      e.preventDefault();
      // Create new option
      const option = this.multiEmailSelect.select.select.state.focusedOption;

      const newValue = {
        label: option.label || option.value.trim(),
        value: option.value.trim(),
        __isNew__: true,
      };
      // Check if options exists
      const exists = this.state.value.filter(item => item.value === option.value);
      if (!exists.length) {
        // Add option to selection
        const selectedValue = [...this.state.value, newValue];
        this.multiEmailSelect.select.select.setValue(selectedValue);
      }
    }
  };

  /**
   * Filters out duplicate pasted emails and compares what is left to already added emails from the select field.
   */
  getUniqEmails = (emails, alreadySelectedOptions) => {
    if (emails.length) {
      return uniqBy(emails, 'emailAddress')
        .filter(newEmail => {
          const duplicate = alreadySelectedOptions.find(email => email === newEmail.emailAddress);
          return !duplicate;
        })
        .map(newContact => ({
          label: newContact.name || newContact.emailAddress,
          value: newContact.emailAddress,
          __isNew__: true,
        }));
    }
    return [];
  };

  /**
   * Returns the array of value entries, where each object is decorated by
   * the `decorateValue` function.
   */
  getDecoratedValue = () => {
    const { decorateValue } = this.props;

    return this.state.value.map(valueEntry => ({
      ...valueEntry,
      ...decorateValue(valueEntry),
    }));
  };

  /**
   * Extracts emails from pasted plain text and is almost unchanged taken from the monolith.
   */
  extractEmailfromText = text => {
    const emailAddresses = [];

    let currentMatch;
    // eslint-disable-next-line no-cond-assign
    while ((currentMatch = TEXT_WITH_NAME_REGEX.exec(text)) && !this.isEmailsLimit()) {
      const { options } = this.props;
      const email = currentMatch[2];

      let name = null;

      if (options) {
        const existingOption = options.filter(option => option.value === email);
        if (existingOption.length >= 1) {
          name = existingOption[0].label;
        }
      }

      emailAddresses.push({
        name,
        emailAddress: email,
      });
    }

    return emailAddresses;
  };

  /**
   * All the logic to decide if we want to show the menu or not. Is called after each input change and once after onFocus.
   */
  toggleMenu = (inputValue, onFocus, action) => {
    if (
      this.props.initialOptions !== null &&
      (onFocus || (action !== 'menu-close' && action !== 'input-blur' && inputValue.length < 1))
    ) {
      this.setState({
        menuIsOpen: true,
        currentOptions: this.transformInitialOptions(this.props.initialOptions, this.state.value),
      });
    } else if (!this.isEmailsLimit() && (inputValue.length >= 3 || this.props.activateSuggestions !== null)) {
      // When activateSuggestions params are given, the handlers of its buttons will call the closeMenu method
      this.setState({ menuIsOpen: true, currentOptions: this.props.options });
    } else if (this.state.menuIsOpen && inputValue.length < 3) {
      this.setState({ menuIsOpen: false });
    } else {
      this.setState({ menuIsOpen: false });
    }
  };

  closeMenu = () => {
    this.setState({ menuIsOpen: false });
  };

  isEmailsLimit = () => {
    const emailsLength = this.state.value.length;
    return emailsLength >= this.props.emailsLimit;
  };

  isOptionDisabled = (option, selectValue) => {
    const found = selectValue.find(value => value.value === option.value);
    return found !== undefined;
  };

  /**
   * Transforms and returns the initial options provided via porps to the same format as the general options, so that we can show them in the menu.
   * Also checks if all emails from a group of initial options are already selected.
   * If so, those groups of inital options will not be shown in the menu anymore.
   */
  transformInitialOptions = (initialOptions, selectValue) => {
    if (initialOptions !== null) {
      const transformedInitialOptions = [];
      Object.entries(initialOptions).forEach(entry => {
        let count = 0;
        selectValue.forEach(obj => {
          entry[1].options.forEach(obj2 => {
            if (isEqualWith(obj.value, obj2.value)) {
              count += 1;
            }
          });
        });
        const optionsAlreadyAdded = count === entry[1].options.length;
        if (!optionsAlreadyAdded) {
          const emailLabels = [];
          entry[1].options.forEach(item => {
            emailLabels.push(item.label);
          });
          transformedInitialOptions.push({
            value: emailLabels.join(', '),
            label: entry[1].label,
            initalOptions: entry[1].options,
            icon: entry[1].icon,
          });
        }
      });
      return transformedInitialOptions;
    }
  };

  /**
   * Returns the actuall values from a group of initial options, so that they can be added to the seletc field
   */
  selectInitialOptions = (initialOptions, alreadySelectedOptions) => {
    const filteredOptions = initialOptions.filter(initialOption => {
      const duplicate = alreadySelectedOptions.find(option => option.value === initialOption.value);
      return !duplicate;
    });
    return [...alreadySelectedOptions, ...filteredOptions];
  };

  /**
   * Determine whether Button is disabled by checking for invalid email addresses	   * Determine whether Button is disabled by checking for invalid email addresses or if isButtonDisabled prop is true
   */
  disableButton = () =>
    this.state.value.length === 0 ||
    this.hasInvalidValues() ||
    this.exceededEmailsLimit() ||
    this.props.isButtonDisabled;

  isInvalidValue = valueEntry =>
    !isEmailValidForWhitelist(valueEntry.value) || !STRICT_EMAIL_REGEX.test(valueEntry.value);

  /**
   * Check for invalid values
   */
  hasInvalidValues = () => {
    const invalidValues = this.state.value.filter(valueEntry => this.isInvalidValue(valueEntry));
    return invalidValues.length >= 1;
  };

  /**
   * Returns invalidFormatText (and will be classified as true) if there's an invalid email or limit error has occured and relevant strings have been passed
   * to determine type for InputFeedback
   */
  isError = () => {
    const { limitExceededText, invalidFormatText } = this.props;
    return (this.hasInvalidValues() && invalidFormatText) || (this.exceededEmailsLimit() && limitExceededText);
  };

  /**
   * Check if the emails limit has been exceeded
   * and only take valid emails into account
   */
  exceededEmailsLimit = () => {
    const { value } = this.state;
    const { emailsLimit } = this.props;

    const invalidValues = value.filter(valueEntry => this.isInvalidValue(valueEntry));
    const validEmailsLength = value.length - invalidValues.length;
    return validEmailsLength > emailsLimit;
  };

  limitExceededError = () => this.exceededEmailsLimit() && this.props.limitExceededText;

  invalidFormatError = () => this.hasInvalidValues() && this.props.invalidFormatText;

  render() {
    const {
      placeholderText,
      buttonText,
      emailsLimit,
      alreadyInListText,
      activateSuggestions,
      newOptionText,
      addDomainText,
      hideAddButton,
      styles,
      noteText,
      limitExceededText,
      invalidFormatText,
      isLoading,
      isDisabled,
      formatOptionLabel,
    } = this.props;

    const createableSelectClassNames = classnames('MultiEmailSelect', {
      'MultiEmailSelect--no-input': hideAddButton,
      'MultiEmailSelect--error': this.isError(),
    });

    return (
      <div
        className={`MultiEmailSelect__container${this.state.mobile ? ' extraSmallDevices' : ''}`}
        ref={ref => {
          this.multiEmailSelectContainer = ref;
        }}
      >
        <CreatableSelect
          ref={ref => {
            this.multiEmailSelect = ref;
          }}
          className={createableSelectClassNames}
          classNamePrefix="MultiEmailSelect"
          onChange={this.onChangeHandler}
          onInputChange={this.onInputChangeHandler}
          onFocus={this.onFocusHandler}
          onPaste={this.onPaste}
          value={this.getDecoratedValue()}
          inputValue={this.state.inputValue}
          options={this.state.currentOptions}
          activateSuggestions={activateSuggestions}
          menuIsOpen={this.state.menuIsOpen}
          closeMenu={this.closeMenu}
          closeMenuOnSelect={false}
          components={{
            IndicatorSeparator: LimitIndicator,
            Option,
            Menu,
            MultiValueContainer: ColoredMultiValueContainer,
          }}
          styles={styles}
          isOptionDisabled={this.isOptionDisabled}
          isMulti
          isSearchable
          isDisabled={isDisabled}
          isClearable={false}
          hideSelectedOptions={false}
          createOptionPosition="first"
          noOptionsMessage={() => null}
          formatCreateLabel={() => null}
          emailsLimit={emailsLimit}
          alreadyInListText={alreadyInListText}
          placeholder={placeholderText}
          newOptionText={newOptionText}
          addDomainText={addDomainText}
          onKeyDown={this.onKeyDownInput}
          formatOptionLabel={formatOptionLabel}
        />
        {!hideAddButton && (
          <Button
            variant="blue"
            onClick={this.onAddButtonClickHandler}
            type="submit"
            disabled={this.disableButton()}
            inputButtonClass={isLoading ? 'loading' : null}
          >
            <ButtonLabel>{buttonText}</ButtonLabel>
            <LoadingButton />
          </Button>
        )}
        {noteText && <InputFeedback type="note">{noteText}</InputFeedback>}
        {this.limitExceededError() && <InputFeedback type="error">{limitExceededText}</InputFeedback>}
        {this.invalidFormatError() && <InputFeedback type="error">{invalidFormatText}</InputFeedback>}
      </div>
    );
  }
}

export default MultiEmailSelect;