diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js index 6b036923539e..b33e686001db 100644 --- a/src/components/MagicCodeInput.js +++ b/src/components/MagicCodeInput.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState, forwardRef} from 'react'; import {StyleSheet, View} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; @@ -31,6 +31,8 @@ const propTypes = { /* Should submit when the input is complete */ shouldSubmitOnComplete: PropTypes.bool, + innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + /** Function to call when the input is changed */ onChangeText: PropTypes.func, @@ -45,57 +47,91 @@ const defaultProps = { shouldDelayFocus: false, errorText: '', shouldSubmitOnComplete: true, + innerRef: null, onChangeText: () => {}, onFulfill: () => {}, }; -class MagicCodeInput extends React.PureComponent { - constructor(props) { - super(props); - - this.inputPlaceholderSlots = Array.from(Array(CONST.MAGIC_CODE_LENGTH).keys()); - this.inputRefs = {}; - - this.state = { - input: '', - focusedIndex: 0, - editIndex: 0, - numbers: props.value ? this.decomposeString(props.value) : Array(CONST.MAGIC_CODE_LENGTH).fill(CONST.MAGIC_CODE_EMPTY_CHAR), - }; - - this.onFocus = this.onFocus.bind(this); - this.onChangeText = this.onChangeText.bind(this); - this.onKeyPress = this.onKeyPress.bind(this); +/** + * Converts a given string into an array of numbers that must have the same + * number of elements as the number of inputs. + * + * @param {String} value + * @returns {Array} + */ +const decomposeString = (value) => { + let arr = _.map(value.split('').slice(0, CONST.MAGIC_CODE_LENGTH), (v) => (ValidationUtils.isNumeric(v) ? v : CONST.MAGIC_CODE_EMPTY_CHAR)); + if (arr.length < CONST.MAGIC_CODE_LENGTH) { + arr = arr.concat(Array(CONST.MAGIC_CODE_LENGTH - arr.length).fill(CONST.MAGIC_CODE_EMPTY_CHAR)); } + return arr; +}; - componentDidMount() { - if (!this.props.autoFocus) { +/** + * Converts an array of strings into a single string. If there are undefined or + * empty values, it will replace them with a space. + * + * @param {Array} value + * @returns {String} + */ +const composeToString = (value) => _.map(value, (v) => (v === undefined || v === '' ? CONST.MAGIC_CODE_EMPTY_CHAR : v)).join(''); + +const inputPlaceholderSlots = Array.from(Array(CONST.MAGIC_CODE_LENGTH).keys()); + +function MagicCodeInput(props) { + const inputRefs = useRef([]); + const [input, setInput] = useState(''); + const [focusedIndex, setFocusedIndex] = useState(0); + const [editIndex, setEditIndex] = useState(0); + + useImperativeHandle(props.innerRef, () => ({ + focus() { + setFocusedIndex(0); + inputRefs.current[0].focus(); + }, + clear() { + setInput(''); + setFocusedIndex(0); + setEditIndex(0); + inputRefs.current[0].focus(); + props.onChangeText(''); + }, + })); + + useEffect(() => { + // Blurs the input and removes focus from the last input and, if it should submit + // on complete, it will call the onFulfill callback. + const numbers = decomposeString(props.value); + if (!props.shouldSubmitOnComplete || _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== CONST.MAGIC_CODE_LENGTH) { return; } + inputRefs.current[editIndex].blur(); + setFocusedIndex(undefined); + props.onFulfill(props.value); - if (this.props.shouldDelayFocus) { - this.focusTimeout = setTimeout(() => this.inputRefs[0].focus(), CONST.ANIMATED_TRANSITION); - } - - this.inputRefs[0].focus(); - } + // We have not added the editIndex as the dependency because we don't want to run this logic after focusing on an input to edit it after the user has completed the code. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.value, props.shouldSubmitOnComplete, props.onFulfill]); - componentDidUpdate(prevProps) { - if (prevProps.value === this.props.value) { + useEffect(() => { + if (!props.autoFocus) { return; } - this.setState({ - numbers: this.decomposeString(this.props.value), - }); - } - - componentWillUnmount() { - if (!this.focusTimeout) { - return; + let focusTimeout = null; + if (props.shouldDelayFocus) { + focusTimeout = setTimeout(() => inputRefs.current[0].focus(), CONST.ANIMATED_TRANSITION); } - clearTimeout(this.focusTimeout); - } + + inputRefs.current[0].focus(); + + return () => { + if (!focusTimeout) { + return; + } + clearTimeout(focusTimeout); + }; + }, [props.autoFocus, props.shouldDelayFocus]); /** * Focuses on the input when it is pressed. @@ -103,12 +139,10 @@ class MagicCodeInput extends React.PureComponent { * @param {Object} event * @param {Number} index */ - onFocus(event) { + const onFocus = (event) => { event.preventDefault(); - this.setState({ - input: '', - }); - } + setInput(''); + }; /** * Callback for the onPress event, updates the indexes @@ -117,14 +151,12 @@ class MagicCodeInput extends React.PureComponent { * @param {Object} event * @param {Number} index */ - onPress(event, index) { + const onPress = (event, index) => { event.preventDefault(); - this.setState({ - input: '', - focusedIndex: index, - editIndex: index, - }); - } + setInput(''); + setFocusedIndex(index); + setEditIndex(index); + }; /** * Updates the magic inputs with the contents written in the @@ -135,46 +167,28 @@ class MagicCodeInput extends React.PureComponent { * * @param {String} value */ - onChangeText(value) { + const onChangeText = (value) => { if (_.isUndefined(value) || _.isEmpty(value) || !ValidationUtils.isNumeric(value)) { return; } - this.setState( - (prevState) => { - const numbersArr = value - .trim() - .split('') - .slice(0, CONST.MAGIC_CODE_LENGTH - prevState.editIndex); - const numbers = [ - ...prevState.numbers.slice(0, prevState.editIndex), - ...numbersArr, - ...prevState.numbers.slice(numbersArr.length + prevState.editIndex, CONST.MAGIC_CODE_LENGTH), - ]; - - // Updates the focused input taking into consideration the last input - // edited and the number of digits added by the user. - const focusedIndex = Math.min(prevState.editIndex + (numbersArr.length - 1) + 1, CONST.MAGIC_CODE_LENGTH - 1); - - return { - numbers, - focusedIndex, - input: value, - }; - }, - () => { - const finalInput = this.composeToString(this.state.numbers); - this.props.onChangeText(finalInput); - - // Blurs the input and removes focus from the last input and, if it should submit - // on complete, it will call the onFulfill callback. - if (this.props.shouldSubmitOnComplete && _.filter(this.state.numbers, (n) => ValidationUtils.isNumeric(n)).length === CONST.MAGIC_CODE_LENGTH) { - this.inputRefs[this.state.editIndex].blur(); - this.setState({focusedIndex: undefined}, () => this.props.onFulfill(finalInput)); - } - }, - ); - } + // Updates the focused input taking into consideration the last input + // edited and the number of digits added by the user. + const numbersArr = value + .trim() + .split('') + .slice(0, CONST.MAGIC_CODE_LENGTH - editIndex); + const updatedFocusedIndex = Math.min(editIndex + (numbersArr.length - 1) + 1, CONST.MAGIC_CODE_LENGTH - 1); + + let numbers = decomposeString(props.value); + numbers = [...numbers.slice(0, editIndex), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex, CONST.MAGIC_CODE_LENGTH)]; + + setFocusedIndex(updatedFocusedIndex); + setInput(value); + + const finalInput = composeToString(numbers); + props.onChangeText(finalInput); + }; /** * Handles logic related to certain key presses. @@ -184,169 +198,121 @@ class MagicCodeInput extends React.PureComponent { * * @param {Object} event */ - onKeyPress({nativeEvent: {key: keyValue}}) { + const onKeyPress = ({nativeEvent: {key: keyValue}}) => { if (keyValue === 'Backspace') { - this.setState( - ({numbers, focusedIndex}) => { - // If the currently focused index already has a value, it will delete - // that value but maintain the focus on the same input. - if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) { - const newNumbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, CONST.MAGIC_CODE_LENGTH)]; - return { - input: '', - numbers: newNumbers, - editIndex: focusedIndex, - }; - } - - const hasInputs = _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== 0; - let newNumbers = numbers; - - // Fill the array with empty characters if there are no inputs. - if (focusedIndex === 0 && !hasInputs) { - newNumbers = Array(CONST.MAGIC_CODE_LENGTH).fill(CONST.MAGIC_CODE_EMPTY_CHAR); - - // Deletes the value of the previous input and focuses on it. - } else if (focusedIndex !== 0) { - newNumbers = [...numbers.slice(0, Math.max(0, focusedIndex - 1)), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex, CONST.MAGIC_CODE_LENGTH)]; - } - - // Saves the input string so that it can compare to the change text - // event that will be triggered, this is a workaround for mobile that - // triggers the change text on the event after the key press. - return { - input: '', - numbers: newNumbers, - focusedIndex: Math.max(0, focusedIndex - 1), - editIndex: Math.max(0, focusedIndex - 1), - }; - }, - () => { - if (_.isUndefined(this.state.focusedIndex)) { - return; - } - this.inputRefs[this.state.focusedIndex].focus(); - }, - ); - } else if (keyValue === 'ArrowLeft' && !_.isUndefined(this.state.focusedIndex)) { - this.setState( - (prevState) => ({ - input: '', - focusedIndex: Math.max(0, prevState.focusedIndex - 1), - editIndex: Math.max(0, prevState.focusedIndex - 1), - }), - () => this.inputRefs[this.state.focusedIndex].focus(), - ); - } else if (keyValue === 'ArrowRight' && !_.isUndefined(this.state.focusedIndex)) { - this.setState( - (prevState) => ({ - input: '', - focusedIndex: Math.min(prevState.focusedIndex + 1, CONST.MAGIC_CODE_LENGTH - 1), - editIndex: Math.min(prevState.focusedIndex + 1, CONST.MAGIC_CODE_LENGTH - 1), - }), - () => this.inputRefs[this.state.focusedIndex].focus(), - ); - } else if (keyValue === 'Enter') { - this.setState({input: ''}); - this.props.onFulfill(this.composeToString(this.state.numbers)); + let numbers = decomposeString(props.value); + + // If the currently focused index already has a value, it will delete + // that value but maintain the focus on the same input. + if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) { + setInput(''); + numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, CONST.MAGIC_CODE_LENGTH)]; + setEditIndex(focusedIndex); + props.onChangeText(composeToString(numbers)); + return; + } + + const hasInputs = _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== 0; + + // Fill the array with empty characters if there are no inputs. + if (focusedIndex === 0 && !hasInputs) { + numbers = Array(CONST.MAGIC_CODE_LENGTH).fill(CONST.MAGIC_CODE_EMPTY_CHAR); + + // Deletes the value of the previous input and focuses on it. + } else if (focusedIndex !== 0) { + numbers = [...numbers.slice(0, Math.max(0, focusedIndex - 1)), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex, CONST.MAGIC_CODE_LENGTH)]; + } + + const newFocusedIndex = Math.max(0, focusedIndex - 1); + + // Saves the input string so that it can compare to the change text + // event that will be triggered, this is a workaround for mobile that + // triggers the change text on the event after the key press. + setInput(''); + setFocusedIndex(newFocusedIndex); + setEditIndex(newFocusedIndex); + props.onChangeText(composeToString(numbers)); + + if (!_.isUndefined(newFocusedIndex)) { + inputRefs.current[newFocusedIndex].focus(); + } } - } - - focus() { - this.setState({focusedIndex: 0}); - this.inputRefs[0].focus(); - } - - clear() { - this.setState({ - input: '', - focusedIndex: 0, - editIndex: 0, - numbers: Array(CONST.MAGIC_CODE_LENGTH).fill(CONST.MAGIC_CODE_EMPTY_CHAR), - }); - this.inputRefs[0].focus(); - } - - /** - * Converts a given string into an array of numbers that must have the same - * number of elements as the number of inputs. - * - * @param {String} value - * @returns {Array} - */ - decomposeString(value) { - let arr = _.map(value.split('').slice(0, CONST.MAGIC_CODE_LENGTH), (v) => (ValidationUtils.isNumeric(v) ? v : CONST.MAGIC_CODE_EMPTY_CHAR)); - if (arr.length < CONST.MAGIC_CODE_LENGTH) { - arr = arr.concat(Array(CONST.MAGIC_CODE_LENGTH - arr.length).fill(CONST.MAGIC_CODE_EMPTY_CHAR)); + if (keyValue === 'ArrowLeft' && !_.isUndefined(focusedIndex)) { + const newFocusedIndex = Math.max(0, focusedIndex - 1); + setInput(''); + setFocusedIndex(newFocusedIndex); + setEditIndex(newFocusedIndex); + inputRefs.current[newFocusedIndex].focus(); + } else if (keyValue === 'ArrowRight' && !_.isUndefined(focusedIndex)) { + const newFocusedIndex = Math.min(focusedIndex + 1, CONST.MAGIC_CODE_LENGTH - 1); + setInput(''); + setFocusedIndex(newFocusedIndex); + setEditIndex(newFocusedIndex); + inputRefs.current[newFocusedIndex].focus(); + } else if (keyValue === 'Enter') { + setInput(''); + props.onFulfill(props.value); } - return arr; - } - - /** - * Converts an array of strings into a single string. If there are undefined or - * empty values, it will replace them with a space. - * - * @param {Array} value - * @returns {String} - */ - composeToString(value) { - return _.map(value, (v) => (v === undefined || v === '' ? CONST.MAGIC_CODE_EMPTY_CHAR : v)).join(''); - } - - render() { - return ( - <> - - {_.map(this.inputPlaceholderSlots, (index) => ( - - - {this.state.numbers[index] || ''} - - - (this.inputRefs[index] = ref)} - autoFocus={index === 0 && this.props.autoFocus} - inputMode="numeric" - textContentType="oneTimeCode" - name={this.props.name} - maxLength={CONST.MAGIC_CODE_LENGTH} - value={this.state.input} - hideFocusedState - autoComplete={index === 0 ? this.props.autoComplete : 'off'} - keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} - onChangeText={(value) => { - // Do not run when the event comes from an input that is - // not currently being responsible for the input, this is - // necessary to avoid calls when the input changes due to - // deleted characters. Only happens in mobile. - if (index !== this.state.editIndex) { - return; - } - this.onChangeText(value); - }} - onKeyPress={this.onKeyPress} - onPress={(event) => this.onPress(event, index)} - onFocus={this.onFocus} - /> - + }; + + return ( + <> + + {_.map(inputPlaceholderSlots, (index) => ( + + + {decomposeString(props.value)[index] || ''} - ))} - - {!_.isEmpty(this.props.errorText) && ( - - )} - - ); - } + + (inputRefs.current[index] = ref)} + autoFocus={index === 0 && props.autoFocus} + inputMode="numeric" + textContentType="oneTimeCode" + name={props.name} + maxLength={CONST.MAGIC_CODE_LENGTH} + value={input} + hideFocusedState + autoComplete={index === 0 ? props.autoComplete : 'off'} + keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} + onChangeText={(value) => { + // Do not run when the event comes from an input that is + // not currently being responsible for the input, this is + // necessary to avoid calls when the input changes due to + // deleted characters. Only happens in mobile. + if (index !== editIndex) { + return; + } + onChangeText(value); + }} + onKeyPress={onKeyPress} + onPress={(event) => onPress(event, index)} + onFocus={onFocus} + /> + + + ))} + + {!_.isEmpty(props.errorText) && ( + + )} + + ); } MagicCodeInput.propTypes = propTypes; MagicCodeInput.defaultProps = defaultProps; -export default MagicCodeInput; +export default forwardRef((props, ref) => ( + +));