import * as React from 'react'; import * as _ from 'lodash'; import { InputDropDownDrawer } from '../InputDropDownDrawer'; export namespace TokenSuggest { export interface Props { /** * Custom input component */ customInput?: any; customInputProps?: any; /** * Custom component to show when no options */ customEmptyStateMessage?: any; /** * String to be added automatically after the token closer * By default it is a space */ defaultAfterToken?: string; /** * Message to show when no options */ emptyStateMessage?: string; /** * List of options to be displayed when the token opener is typed (the state will manage them) */ initialSuggestOptions: { toString: () => string }[]; /** * Value to display inside the input */ inputValue: string; /** * Function called when the user hits Enter and the dropdown is closed * This function is not executed when the Enter is used to select an option */ onEnterKeyPressed?: () => void; /** * Function called when the value of the input changes */ onValueChange: (value: string, isValid: boolean) => void; /** * Function called when rendering the options, being responsible of saying whether the value matches the option or not * By default function includes is used (case insensitive) */ optionMatches?: (option: { toString: () => string }, currentValue: string) => boolean; /** * Placeholder of the input */ placeholder?: string; /** * Indicates if the input is read only */ readOnly?: boolean; /** * Indicates if the dropdown is displayed when there are no options matching the value * It must be true to display emptyStateMessage or customEmptyStateMessage */ showDropDownOnEmpty?: boolean; /** * Prop to customize styles */ styles?: Styles; /** * Value of the token closer * By default it is > */ tokenCloser?: string; /** * Value of the token opener * By default it is < */ tokenOpener?: string; id?: string; name?: string; } export interface State { currentToken: string; suggestOptions: { toString: () => string }[]; showDropDown: boolean; } export interface Styles { /** * Styles for the component that renders the input and the dropdown */ InputDropDownDrawer?: InputDropDownDrawer.Styles; } } /** * Component that displays an input that drops down a list of tokens * The list matches with what the user is typing, and it is showed only after the user typed the token opener * * @version 1.0.0 * */ export default class TokenSuggest extends React.Component { private reservedCharsInRegEx = '.!@#$%^&|()[]{}_+*='; private escapedTokenCloser; private escapedTokenOpener; private currentTokenOpenerIndex; static defaultProps = { defaultAfterToken: ' ', tokenCloser: '>', tokenOpener: '<' }; constructor(props: TokenSuggest.Props) { super(props); this.state = { currentToken: '', showDropDown: false, suggestOptions: [] }; this.escapedTokenCloser = this.escapeCharacterForRegEx(props.tokenCloser); this.escapedTokenOpener = this.escapeCharacterForRegEx(props.tokenOpener); } componentWillReceiveProps(nextProps: TokenSuggest.Props) { this.escapedTokenCloser = this.escapeCharacterForRegEx(nextProps.tokenCloser); this.escapedTokenOpener = this.escapeCharacterForRegEx(nextProps.tokenOpener); } /** * Function called when rendering the options, being responsible of saying whether the value matches the option or not * It calls prop optionsMatches is passed * By default function startsWith is used (case insensitive) */ filterOptions = (options: { toString: () => string }[], value: string) => ( options.filter((option) => ( this.props.optionMatches ? this.props.optionMatches(option, value) : _.startsWith(option.toString().toUpperCase(), value.toUpperCase()) )) ); handleDropDownEvent = (event, show: boolean): void => { this.setState((prevState: TokenSuggest.State, props: TokenSuggest.Props) => ({ showDropDown: show && (prevState.suggestOptions.length > 0 || Boolean(props.showDropDownOnEmpty)) })); }; handleSelectOption = (event, option: string): void => { let replaceable; const { inputValue, tokenCloser } = this.props; let defaultAfterToken = this.props.defaultAfterToken; const tokenOpener = this.props.tokenOpener || ''; const currentPosition = event.target.selectionStart let indexOfTokenBegin = inputValue.lastIndexOf(tokenOpener); if (currentPosition < indexOfTokenBegin) { const fromIndex = this.currentTokenOpenerIndex; const toIndex = currentPosition; const subs = inputValue.substring(fromIndex, toIndex) replaceable = subs; indexOfTokenBegin = fromIndex; defaultAfterToken = inputValue.substring(currentPosition) } else { replaceable = inputValue.substring(indexOfTokenBegin); } const tokenName = inputValue.substring(indexOfTokenBegin + 1, currentPosition); const replaceToken = replaceable.replace(tokenOpener + tokenName, tokenOpener + option + tokenCloser + defaultAfterToken); const replacedTokenPrefix = inputValue.substring(0, indexOfTokenBegin); const replacedToken = replacedTokenPrefix + replaceToken; this.setState({ currentToken: '', suggestOptions: [], showDropDown: false }); this.props.onValueChange(replacedToken, this.valueIsValid(replacedToken, this.props.initialSuggestOptions)); }; indexesOfCharInString = (char, string) => { let a: number[] = []; let i = -1; while ((i = string.indexOf(char, i + 1)) >= 0) { a.push(i); } return a; } findNextTokenOpenerIndex = (array, index) => { let i = 0 while (array[i] < index) { i = i + 1; } return i; } handleChangeValue = (event): void => { const { tokenCloser, tokenOpener } = this.props; const newValue = event.target.value; const numberOfUnonpenedTokens = Math.abs(_.split(newValue, tokenCloser).length - _.split(newValue, tokenOpener).length); if (numberOfUnonpenedTokens !== 0) { const currentPosition = event.target.selectionStart; const indexOfTokenBegin = newValue.lastIndexOf(tokenOpener); let currentToken = newValue.substring(indexOfTokenBegin + 1, currentPosition); const tokenOpenerArray = this.indexesOfCharInString(tokenOpener, newValue); const indexOfCurrentPos = tokenOpenerArray.indexOf(currentPosition); if (currentPosition <= indexOfTokenBegin) { if (newValue[currentPosition - 1] === tokenOpener) { this.currentTokenOpenerIndex = currentPosition - 1; } const indexOfNextTokenOpener = this.findNextTokenOpenerIndex(tokenOpenerArray, currentPosition) const fromIndex = this.currentTokenOpenerIndex; const toIndex = tokenOpenerArray[indexOfNextTokenOpener]; const subs = newValue.substring(fromIndex, toIndex); currentToken = subs.split(tokenOpener).join(''); } const newOptions = this.filterOptions(this.props.initialSuggestOptions, currentToken.trim()); this.setState((prevState: TokenSuggest.State, props: TokenSuggest.Props) => ({ currentToken: currentToken, showDropDown: newOptions.length > 0 || Boolean(props.showDropDownOnEmpty), suggestOptions: newOptions })); } else { this.setState({ suggestOptions: [], showDropDown: false }); } this.props.onValueChange(newValue, this.valueIsValid(newValue, this.props.initialSuggestOptions)); }; /** * If the character is a reserved one for regular expressions, then it is added \\ as prefix, to escape it */ escapeCharacterForRegEx = (character) => ( this.reservedCharsInRegEx.indexOf(character) < 0 ? character : '\\' + character ); /** * Indicates if there is a token closer that was not opened */ closerWithoutOpener = (value: string, tokenOpener, tokenCloser) => ( value.match(`(^|${tokenCloser})[^${tokenOpener}]*${tokenCloser}`) ); /** * Indicates if there is a token opener that was not closed */ openerWithoutCloser = (value: string, tokenOpener, tokenCloser) => ( value.match(`${tokenOpener}[^${tokenCloser}]*(${tokenOpener}|$)`) ); /** * Indicates if the value is valid * To be valid, the value has to contain tokens that were correctly opened and closed * To be valid, all the tokens must be in the options */ valueIsValid = (value, options) => { if (this.closerWithoutOpener(value, this.escapedTokenOpener, this.escapedTokenCloser) || this.openerWithoutCloser(value, this.escapedTokenOpener, this.escapedTokenCloser)) { return false; } const tokensRegEx = new RegExp(`${this.escapedTokenOpener}([^${this.escapedTokenCloser}]*)${this.escapedTokenCloser}`, 'g'); const stringOptions = options.map((option) => option.toString()); let currentToken; while (currentToken = tokensRegEx.exec(value)) { if (stringOptions.indexOf(currentToken[1]) < 0) { return false; } } return true; }; render(): JSX.Element { const Styles: TokenSuggest.Styles = _.merge({}, this.props.styles); return ( ); } }