Repository URL to install this package:
|
Version:
8.1.0-rc.5 ▾
|
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;