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%',
},