Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: magic code input (passwordless login) #16076

Merged
merged 34 commits into from
Apr 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
29bf8b9
feat: magic code input component
BeeMargarida Mar 14, 2023
0bafd04
feat: improve logic and performance for android
BeeMargarida Mar 16, 2023
20fe253
fix: linting errors
BeeMargarida Mar 17, 2023
80483a2
fix: remove unnecessary code and apply review fixes
BeeMargarida Mar 17, 2023
94ce197
refactor: simplify backspace logic
BeeMargarida Mar 20, 2023
ef05927
feat: set submit on complete to true by default
BeeMargarida Mar 21, 2023
4911091
fix: rename const and fix function documentation
BeeMargarida Mar 22, 2023
968f54b
refactor: rename submitOnComplete in magic code input
BeeMargarida Mar 23, 2023
5471423
Merge branch 'main' into feat/15403-magic_code_input
BeeMargarida Mar 28, 2023
42ac6c8
Merge branch 'main' into feat/15403-magic_code_input
BeeMargarida Mar 30, 2023
387cd96
fix: edit on focused index and submit on complete timeout fix
BeeMargarida Mar 30, 2023
67710ca
fix: submit when not complete and refactor/rename variables
BeeMargarida Mar 30, 2023
f0f749c
refactor: update story prop after rename
BeeMargarida Mar 30, 2023
ce9d29f
Merge branch 'main' into feat/15403-magic_code_input
BeeMargarida Mar 31, 2023
5ccde1c
fix: make logic similar to Slack and Github examples
BeeMargarida Mar 31, 2023
423393a
fix: small fix for Android
BeeMargarida Mar 31, 2023
592e1eb
fix: rename prop and fix autofocus
BeeMargarida Apr 3, 2023
469f5e4
fix: autoComplete only for first input
BeeMargarida Apr 3, 2023
501507c
Merge branch 'main' into feat/15403-magic_code_input
BeeMargarida Apr 11, 2023
a1f6cfd
Merge branch 'main' into feat/15403-magic_code_input
BeeMargarida Apr 12, 2023
85a3cdf
Merge branch 'main' into feat/15403-magic_code_input
BeeMargarida Apr 17, 2023
7c06175
fix: delete on first input when not empty
BeeMargarida Apr 20, 2023
b24e9e9
fix: workaround for callback on input change on mobile
BeeMargarida Apr 20, 2023
f1e58c2
fix: magic code input story console error
BeeMargarida Apr 20, 2023
f29e6e1
Merge branch 'main' into feat/15403-magic_code_input
BeeMargarida Apr 20, 2023
7d353c3
fix: another approach to fix keyboard navigation
BeeMargarida Apr 21, 2023
d8e6314
fix: close mobile keyboard on input complete and fix number validation
BeeMargarida Apr 25, 2023
83971d2
fix: reset validate code when resending new code
BeeMargarida Apr 25, 2023
3e562f8
Merge branch 'main' into feat/15403-magic_code_input
BeeMargarida Apr 25, 2023
9c0749a
feat: reset input after resending code
BeeMargarida Apr 25, 2023
4e4e220
fix: reset link sent on input
BeeMargarida Apr 26, 2023
3ddb740
Merge branch 'main' into feat/15403-magic_code_input
BeeMargarida Apr 28, 2023
b1a39eb
fix: minHeight for iPad fix
BeeMargarida Apr 28, 2023
01be1c4
fix: use same variable for input minHeight
BeeMargarida Apr 28, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
347 changes: 347 additions & 0 deletions src/components/MagicCodeInput.js
Original file line number Diff line number Diff line change
@@ -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,
cristipaval marked this conversation as resolved.
Show resolved Hide resolved

/** 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: '',
});
Comment on lines +108 to +110
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clearing input has caused this regression

}

/**
* 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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment here about naming

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}}) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Methods should never be named for what they handle, they should only be named for what they do. This is part of the checklist:

I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad, I must have skipped it when I read it. There's an issue regarding MagicCodeInput yet - #18325 - where this can be fixed, if I end up assigned, I can do it there.

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 (
<>
<View style={[styles.magicCodeInputContainer]}>
{_.map(this.inputPlaceholderSlots, index => (
<View key={index} style={[styles.w15]}>
<View
style={[
styles.textInputContainer,
this.state.focusedIndex === index ? styles.borderColorFocus : {},
]}
>
<Text style={[styles.magicCodeInput, styles.textAlignCenter]}>
{this.state.numbers[index] || ''}
</Text>
</View>
<View style={[StyleSheet.absoluteFillObject, styles.w100, styles.opacity0]}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this style definition

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually why can't we use TextInput instead of manual Text?

Copy link
Contributor Author

@BeeMargarida BeeMargarida Apr 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this style definition

This is necessary to hide the input above the text, so that we can keep that behaviour of copy paste in each input as well as focus on tap.
What exactly don't you like abou the style definition? Do you want me to move it to a style object?

Actually why can't we use TextInput instead of manual Text?

This is something I had previously, but it was too problematic. You would need to change the focus to the input above and force it to never focus, pass a focused prop (which was another prop needed to add to the BaseTextInput only to show the focused state), be non editable. With Text it becomes simpler, it's only a wrapper for a number with some style.

<TextInput
ref={ref => 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}
/>
</View>
</View>
))}
</View>
{!_.isEmpty(this.props.errorText) && (
<FormHelpMessage isError message={this.props.errorText} />
)}
</>
);
}
}

MagicCodeInput.propTypes = propTypes;
MagicCodeInput.defaultProps = defaultProps;

export default MagicCodeInput;

12 changes: 12 additions & 0 deletions src/libs/ValidationUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -474,4 +485,5 @@ export {
isValidDisplayName,
isValidLegalName,
doesContainReservedWord,
isNumeric,
};
Loading