diff --git a/src/CONST.js b/src/CONST.js index 20e293011f1c..364878433a7c 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -675,6 +675,9 @@ const CONST = { EMAIL: 'email', }, + MAGIC_CODE_LENGTH: 6, + MAGIC_CODE_EMPTY_CHAR: ' ', + KEYBOARD_TYPE: { PHONE_PAD: 'phone-pad', NUMBER_PAD: 'number-pad', diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js new file mode 100644 index 000000000000..10c54e513f55 --- /dev/null +++ b/src/components/MagicCodeInput.js @@ -0,0 +1,347 @@ +import React from 'react'; +import {StyleSheet, View} from 'react-native'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import styles from '../styles/styles'; +import * as ValidationUtils from '../libs/ValidationUtils'; +import CONST from '../CONST'; +import Text from './Text'; +import TextInput from './TextInput'; +import FormHelpMessage from './FormHelpMessage'; + +const propTypes = { + /** Name attribute for the input */ + name: PropTypes.string, + + /** Input value */ + value: PropTypes.string, + + /** Should the input auto focus */ + autoFocus: PropTypes.bool, + + /** Whether we should wait before focusing the TextInput, useful when using transitions */ + shouldDelayFocus: PropTypes.bool, + + /** Error text to display */ + errorText: PropTypes.string, + + /** Specifies autocomplete hints for the system, so it can provide autofill */ + autoComplete: PropTypes.oneOf(['sms-otp', 'one-time-code']).isRequired, + + /* Should submit when the input is complete */ + shouldSubmitOnComplete: PropTypes.bool, + + /** Function to call when the input is changed */ + onChangeText: PropTypes.func, + + /** Function to call when the input is submitted or fully complete */ + onFulfill: PropTypes.func, +}; + +const defaultProps = { + value: undefined, + name: '', + autoFocus: true, + shouldDelayFocus: false, + errorText: '', + shouldSubmitOnComplete: true, + 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); + } + + componentDidMount() { + if (!this.props.autoFocus) { + return; + } + + if (this.props.shouldDelayFocus) { + this.focusTimeout = setTimeout(() => this.inputRefs[0].focus(), CONST.ANIMATED_TRANSITION); + } + + this.inputRefs[0].focus(); + } + + componentDidUpdate(prevProps) { + if (prevProps.value === this.props.value) { + return; + } + + this.setState({ + numbers: this.decomposeString(this.props.value), + }); + } + + componentWillUnmount() { + if (!this.focusTimeout) { + return; + } + clearTimeout(this.focusTimeout); + } + + /** + * Focuses on the input when it is pressed. + * + * @param {Object} event + * @param {Number} index + */ + onFocus(event) { + event.preventDefault(); + this.setState({ + input: '', + }); + } + + /** + * Callback for the onPress event, updates the indexes + * of the currently focused input. + * + * @param {Object} event + * @param {Number} index + */ + onPress(event, index) { + event.preventDefault(); + this.setState({ + input: '', + focusedIndex: index, + editIndex: index, + }); + } + + /** + * Updates the magic inputs with the contents written in the + * input. It spreads each number into each input and updates + * the focused input on the next empty one, if exists. + * It handles both fast typing and only one digit at a time + * in a specific position. + * + * @param {String} value + */ + 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)); + } + }); + } + + /** + * Handles logic related to certain key presses. + * + * NOTE: when using Android Emulator, this can only be tested using + * hardware keyboard inputs. + * + * @param {Object} event + */ + 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)); + } + } + + 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)); + } + 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} + /> + + + ))} + + {!_.isEmpty(this.props.errorText) && ( + + )} + + ); + } +} + +MagicCodeInput.propTypes = propTypes; +MagicCodeInput.defaultProps = defaultProps; + +export default MagicCodeInput; + diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.js index 90eccdd7e94b..50221f3659f4 100644 --- a/src/libs/ValidationUtils.js +++ b/src/libs/ValidationUtils.js @@ -442,6 +442,17 @@ function isValidTaxID(taxID) { return taxID && CONST.REGEX.TAX_ID.test(taxID.replace(CONST.REGEX.NON_NUMERIC, '')); } +/** + * Checks if a string value is a number. + * + * @param {String} value + * @returns {Boolean} + */ +function isNumeric(value) { + if (typeof value !== 'string') { return false; } + return /^\d*$/.test(value); +} + export { meetsMinimumAgeRequirement, meetsMaximumAgeRequirement, @@ -474,4 +485,5 @@ export { isValidDisplayName, isValidLegalName, doesContainReservedWord, + isNumeric, }; diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index 04f6c6f6c9d1..b8c9dc0a5bd9 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -25,6 +25,7 @@ import {withNetwork} from '../../../components/OnyxProvider'; import networkPropTypes from '../../../components/networkPropTypes'; import * as User from '../../../libs/actions/User'; import FormHelpMessage from '../../../components/FormHelpMessage'; +import MagicCodeInput from '../../../components/MagicCodeInput'; import Terms from '../Terms'; const propTypes = { @@ -93,6 +94,11 @@ class BaseValidateCodeForm extends React.Component { if (prevProps.isVisible && !this.props.isVisible && this.state.validateCode) { this.clearValidateCode(); } + + // Clear the code input if a new magic code was requested + if (this.props.isVisible && this.state.linkSent && this.props.account.message && this.state.validateCode) { + this.clearValidateCode(); + } if (!prevProps.credentials.validateCode && this.props.credentials.validateCode) { this.setState({validateCode: this.props.credentials.validateCode}); } @@ -114,6 +120,7 @@ class BaseValidateCodeForm extends React.Component { this.setState({ [key]: text, formError: {[key]: ''}, + linkSent: false, }); if (this.props.account.errors) { @@ -125,7 +132,7 @@ class BaseValidateCodeForm extends React.Component { * Clear Validate Code from the state */ clearValidateCode() { - this.setState({validateCode: ''}, this.inputValidateCode.clear); + this.setState({validateCode: ''}, () => this.inputValidateCode.clear()); } /** @@ -212,7 +219,7 @@ class BaseValidateCodeForm extends React.Component { ) : ( - this.inputValidateCode = el} @@ -221,9 +228,7 @@ class BaseValidateCodeForm extends React.Component { name="validateCode" value={this.state.validateCode} onChangeText={text => this.onTextInput(text, 'validateCode')} - onSubmitEditing={this.validateAndSubmitForm} - blurOnSubmit={false} - keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} + onFulfill={this.validateAndSubmitForm} errorText={this.state.formError.validateCode ? this.props.translate(this.state.formError.validateCode) : ''} hasError={hasError} autoFocus diff --git a/src/stories/MagicCodeInput.stories.js b/src/stories/MagicCodeInput.stories.js new file mode 100644 index 000000000000..eac52347d9c0 --- /dev/null +++ b/src/stories/MagicCodeInput.stories.js @@ -0,0 +1,41 @@ +import React from 'react'; +import MagicCodeInput from '../components/MagicCodeInput'; + +/** + * We use the Component Story Format for writing stories. Follow the docs here: + * + * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format + */ +const story = { + title: 'Components/MagicCodeInput', + component: MagicCodeInput, +}; + +// eslint-disable-next-line react/jsx-props-no-spreading +const Template = args => ; + +// Arguments can be passed to the component by binding +// See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args + +const AutoFocus = Template.bind({}); +AutoFocus.args = { + label: 'Auto-focused magic code input', + name: 'AutoFocus', + autoFocus: true, + autoComplete: 'one-time-code', +}; + +const SubmitOnComplete = Template.bind({}); +SubmitOnComplete.args = { + label: 'Submits when the magic code input is complete', + name: 'SubmitOnComplete', + autoComplete: 'one-time-code', + shouldSubmitOnComplete: true, + onFulfill: () => console.debug('Submitted!'), +}; + +export default story; +export { + AutoFocus, + SubmitOnComplete, +}; diff --git a/src/styles/styles.js b/src/styles/styles.js index 5f3a0a96b929..21c807ef04ba 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -2309,6 +2309,18 @@ const styles = { backgroundColor: themeColors.checkBox, }, + magicCodeInputContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + minHeight: variables.inputHeight, + }, + + magicCodeInput: { + fontSize: variables.fontSizeXLarge, + color: themeColors.heading, + lineHeight: variables.inputHeight, + }, + iouAmountText: { ...headlineFont, fontSize: variables.iouAmountTextSize, diff --git a/src/styles/utilities/sizing.js b/src/styles/utilities/sizing.js index 1051098fbe16..dd7765540d43 100644 --- a/src/styles/utilities/sizing.js +++ b/src/styles/utilities/sizing.js @@ -8,6 +8,10 @@ export default { height: '100%', }, + w15: { + width: '15%', + }, + w20: { width: '20%', },