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 6 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
2 changes: 2 additions & 0 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,8 @@ const CONST = {
EMAIL: 'email',
},

MAGIC_CODE_NUMBERS: 6,
BeeMargarida marked this conversation as resolved.
Show resolved Hide resolved

KEYBOARD_TYPE: {
PHONE_PAD: 'phone-pad',
NUMBER_PAD: 'number-pad',
Expand Down
277 changes: 277 additions & 0 deletions src/components/MagicCodeInput.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
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 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,

/** A ref to forward the current input */
forwardedRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({current: PropTypes.instanceOf(React.Component)}),
]),

/** 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 */
submitOnComplete: PropTypes.bool,
BeeMargarida marked this conversation as resolved.
Show resolved Hide resolved

/** Id to use for this button */
nativeID: PropTypes.string,

/** Function to call when the input is changed */
onChange: PropTypes.func,
BeeMargarida marked this conversation as resolved.
Show resolved Hide resolved

/** Function to call when the input is submitted or fully complete */
onSubmit: PropTypes.func,
BeeMargarida marked this conversation as resolved.
Show resolved Hide resolved
};

const defaultProps = {
value: undefined,
name: '',
autoFocus: true,
shouldDelayFocus: false,
forwardedRef: undefined,
errorText: '',
submitOnComplete: true,
nativeID: '',
onChange: () => {},
onSubmit: () => {},
};

class MagicCodeInput extends React.PureComponent {
constructor(props) {
super(props);

this.inputNrArray = Array.from(Array(CONST.MAGIC_CODE_NUMBERS).keys());
this.inputRef = null;

this.state = {
input: '',
focusedIndex: 0,
editIndex: 0,
numbers: props.value ? this.decomposeString(props.value) : Array(CONST.MAGIC_CODE_NUMBERS).fill(''),
};

this.onChangeText = this.onChangeText.bind(this);
this.onKeyPress = this.onKeyPress.bind(this);
}

componentDidMount() {
if (!this.props.forwardedRef) {
return;
}
this.props.forwardedRef(this.inputRef);

if (!this.props.autoFocus) {
return;
}

if (this.props.shouldDelayFocus) {
this.focusTimeout = setTimeout(() => this.inputRef.focus(), CONST.ANIMATED_TRANSITION);
BeeMargarida marked this conversation as resolved.
Show resolved Hide resolved
return;
}
this.inputRef.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 The event passed by the input.
* @param {number} index The index of the input.
*/
onFocus(event, index) {
event.preventDefault();
this.setState({
input: '',
focusedIndex: index,
editIndex: index,
});
this.inputRef.focus();
}

/**
* 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 The contents of the input text.
*/
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;
}

const numbersArr = value.trim().split('');
this.setState((prevState) => {
const numbers = [
...prevState.numbers.slice(0, prevState.editIndex),
...numbersArr.slice(0, CONST.MAGIC_CODE_NUMBERS - prevState.editIndex),
];

// 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), CONST.MAGIC_CODE_NUMBERS - 1);

return {
numbers,
focusedIndex,
input: value,
editIndex: prevState.editIndex,
};
}, () => {
const finalInput = this.composeToString(this.state.numbers);
this.props.onChange(finalInput);

if (this.props.submitOnComplete && finalInput.length === CONST.MAGIC_CODE_NUMBERS) {
this.props.onSubmit(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 The event passed by the key press.
*/
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.

// Handles the delete character logic if the current input is less than 2 characters,
// meaning that it's the last character to be deleted or it's a character being
// deleted in the middle of the input, which should delete all the characters after it.
if (keyValue === 'Backspace' && this.state.input.length < 2) {
this.setState(({numbers, focusedIndex, editIndex}) => ({
input: '',
numbers: focusedIndex === 0 ? [] : [...numbers.slice(0, focusedIndex), ''],
focusedIndex: Math.max(0, focusedIndex - 1),
editIndex: Math.max(0, editIndex - 1),
}));
} else if (keyValue === 'ArrowLeft') {
this.setState(prevState => ({
input: '',
focusedIndex: prevState.focusedIndex - 1,
editIndex: prevState.focusedIndex - 1,
}));
} else if (keyValue === 'ArrowRight') {
this.setState(prevState => ({
input: '',
focusedIndex: prevState.focusedIndex + 1,
editIndex: prevState.focusedIndex + 1,
}));
} else if (keyValue === 'Enter') {
this.setState({input: ''});
this.props.onSubmit(this.composeToString(this.state.numbers));
}
}

/**
* 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 The string to be converted into an array.
* @returns {array} The array of numbers.
BeeMargarida marked this conversation as resolved.
Show resolved Hide resolved
*/
decomposeString(value) {
let arr = _.map(value.trim().split('').slice(0, CONST.MAGIC_CODE_NUMBERS), v => (ValidationUtils.isNumeric(v) ? v : ''));
if (arr.length < CONST.MAGIC_CODE_NUMBERS) {
arr = arr.concat(Array(CONST.MAGIC_CODE_NUMBERS - arr.length).fill(''));
}
return arr;
}

composeToString(value) {
return _.filter(value, v => v !== undefined).join('');
}

render() {
return (
<>
<View style={[styles.flexRow, styles.justifyContentBetween]}>
{_.map(this.inputNrArray, index => (
<View key={index} style={[styles.w15]}>
<TextInput
editable={false}
focused={this.state.focusedIndex === index}
value={this.state.numbers[index] || ''}
maxLength={1}
blurOnSubmit={false}
onPress={event => this.onFocus(event, index)}
inputStyle={[styles.iouAmountTextInput, styles.textAlignCenter]}
/>
</View>
))}
</View>
<View style={[StyleSheet.absoluteFillObject, styles.w15, styles.opacity0]}>
<TextInput
ref={el => this.inputRef = el}
autoFocus={this.props.autoFocus}
inputMode="numeric"
textContentType="oneTimeCode"
name={this.props.name}
maxLength={CONST.MAGIC_CODE_NUMBERS}
value={this.state.input}
autoComplete={this.props.autoComplete}
nativeID={this.props.nativeID}
keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
onPress={event => this.onFocus(event, 0)}
onChangeText={this.onChangeText}
onKeyPress={this.onKeyPress}
onBlur={() => this.setState({focusedIndex: undefined})}
/>
</View>
{!_.isEmpty(this.props.errorText) && (
<FormHelpMessage isError message={this.props.errorText} />
)}
</>
);
}
}

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

export default React.forwardRef((props, ref) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<MagicCodeInput {...props} forwardedRef={ref} />
));
6 changes: 5 additions & 1 deletion src/components/TextInput/BaseTextInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class BaseTextInput extends Component {
const activeLabel = props.forceActiveLabel || value.length > 0 || props.prefixCharacter;

this.state = {
isFocused: false,
isFocused: props.focused,
labelTranslateY: new Animated.Value(activeLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y),
labelScale: new Animated.Value(activeLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE),
passwordHidden: props.secureTextEntry,
Expand Down Expand Up @@ -73,6 +73,10 @@ class BaseTextInput extends Component {
}

componentDidUpdate(prevProps) {
if (prevProps.focused !== this.props.focused) {
this.setState({isFocused: this.props.focused});
}

// Activate or deactivate the label when value is changed programmatically from outside
const inputValue = _.isUndefined(this.props.value) ? this.input.value : this.props.value;
if ((_.isUndefined(inputValue) || this.state.value === inputValue) && _.isEqual(prevProps.selection, this.props.selection)) {
Expand Down
4 changes: 4 additions & 0 deletions src/components/TextInput/baseTextInputPropTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ const propTypes = {
/** Should the input auto focus? */
autoFocus: PropTypes.bool,

/** Is the input focused */
focused: PropTypes.bool,

/** Disable the virtual keyboard */
disableKeyboard: PropTypes.bool,

Expand Down Expand Up @@ -86,6 +89,7 @@ const defaultProps = {
inputStyle: [],
autoFocus: false,
autoCorrect: true,
focused: false,

/**
* To be able to function as either controlled or uncontrolled component we should not
Expand Down
12 changes: 12 additions & 0 deletions src/libs/ValidationUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,17 @@ function isValidTaxID(taxID) {
return taxID && CONST.REGEX.TAX_ID.test(taxID.replace(CONST.REGEX.NON_NUMERIC, ''));
}

/**
* Verifies if a string is a number.
*
* @param {string} value The string to check if it's numeric.
* @returns {boolean} True if the string is numeric, false otherwise.
*/
BeeMargarida marked this conversation as resolved.
Show resolved Hide resolved
function isNumeric(value) {
if (typeof value !== 'string') { return false; }
return !Number.isNaN(value) && !Number.isNaN(parseFloat(value));
}

export {
meetsAgeRequirements,
getAgeRequirementError,
Expand Down Expand Up @@ -450,4 +461,5 @@ export {
isValidValidateCode,
isValidDisplayName,
doesContainReservedWord,
isNumeric,
};
9 changes: 4 additions & 5 deletions src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -206,18 +207,16 @@ class BaseValidateCodeForm extends React.Component {
</View>
) : (
<View style={[styles.mv3]}>
<TextInput
<MagicCodeInput
autoComplete={this.props.autoComplete}
textContentType="oneTimeCode"
ref={el => this.inputValidateCode = el}
label={this.props.translate('common.magicCode')}
nativeID="validateCode"
name="validateCode"
value={this.state.validateCode}
onChangeText={text => this.onTextInput(text, 'validateCode')}
onSubmitEditing={this.validateAndSubmitForm}
blurOnSubmit={false}
keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
onChange={text => this.onTextInput(text, 'validateCode')}
onSubmit={this.validateAndSubmitForm}
errorText={this.state.formError.validateCode ? this.props.translate(this.state.formError.validateCode) : ''}
neil-marcellini marked this conversation as resolved.
Show resolved Hide resolved
autoFocus
/>
Expand Down
Loading