Skip to content

Commit

Permalink
Merge pull request Expensify#48628 from hungvu193/feat/validate-code-…
Browse files Browse the repository at this point in the history
…modal

add validate code modal
  • Loading branch information
marcaaron authored Sep 8, 2024
2 parents 9affb13 + 5296333 commit aaa60b9
Show file tree
Hide file tree
Showing 15 changed files with 559 additions and 24 deletions.
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ const ONYXKEYS = {
/** Object containing contact method that's going to be added */
PENDING_CONTACT_ACTION: 'pendingContactAction',

/** Store the information of magic code */
VALIDATE_ACTION_CODE: 'validate_action_code',

/** Information about the current session (authToken, accountID, email, loading, error) */
SESSION: 'session',
STASHED_SESSION: 'stashedSession',
Expand Down Expand Up @@ -840,6 +843,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.USER_LOCATION]: OnyxTypes.UserLocation;
[ONYXKEYS.LOGIN_LIST]: OnyxTypes.LoginList;
[ONYXKEYS.PENDING_CONTACT_ACTION]: OnyxTypes.PendingContactAction;
[ONYXKEYS.VALIDATE_ACTION_CODE]: OnyxTypes.ValidateMagicCodeAction;
[ONYXKEYS.SESSION]: OnyxTypes.Session;
[ONYXKEYS.USER_METADATA]: OnyxTypes.UserMetadata;
[ONYXKEYS.STASHED_SESSION]: OnyxTypes.Session;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import {useFocusEffect} from '@react-navigation/native';
import type {ForwardedRef} from 'react';
import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import DotIndicatorMessage from '@components/DotIndicatorMessage';
import MagicCodeInput from '@components/MagicCodeInput';
import type {AutoCompleteVariant, MagicCodeInputHandle} from '@components/MagicCodeInput';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as ErrorUtils from '@libs/ErrorUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
import * as User from '@userActions/User';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Account, ValidateMagicCodeAction} from '@src/types/onyx';
import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

type ValidateCodeFormHandle = {
focus: () => void;
focusLastSelected: () => void;
};

type ValidateCodeFormError = {
validateCode?: TranslationPaths;
};

type BaseValidateCodeFormOnyxProps = {
/** The details about the account that the user is signing in with */
account: OnyxEntry<Account>;
};

type ValidateCodeFormProps = {
/** If the magic code has been resent previously */
hasMagicCodeBeenSent?: boolean;

/** Specifies autocomplete hints for the system, so it can provide autofill */
autoComplete?: AutoCompleteVariant;

/** Forwarded inner ref */
innerRef?: ForwardedRef<ValidateCodeFormHandle>;

/** The state of magic code that being sent */
validateCodeAction?: ValidateMagicCodeAction;

/** The pending action for submitting form */
validatePendingAction?: PendingAction | null;

/** The error of submitting */
validateError?: Errors;

/** Function is called when submitting form */
handleSubmitForm: (validateCode: string) => void;

/** Function to clear error of the form */
clearError: () => void;
};

type BaseValidateCodeFormProps = BaseValidateCodeFormOnyxProps & ValidateCodeFormProps;

function BaseValidateCodeForm({
account = {},
hasMagicCodeBeenSent,
autoComplete = 'one-time-code',
innerRef = () => {},
validateCodeAction,
validatePendingAction,
validateError,
handleSubmitForm,
clearError,
}: BaseValidateCodeFormProps) {
const {translate} = useLocalize();
const {isOffline} = useNetwork();
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const [formError, setFormError] = useState<ValidateCodeFormError>({});
const [validateCode, setValidateCode] = useState('');
const inputValidateCodeRef = useRef<MagicCodeInputHandle>(null);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing doesn't achieve the same result in this case
const shouldDisableResendValidateCode = !!isOffline || account?.isLoading;
const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null);

useImperativeHandle(innerRef, () => ({
focus() {
inputValidateCodeRef.current?.focus();
},
focusLastSelected() {
if (!inputValidateCodeRef.current) {
return;
}
if (focusTimeoutRef.current) {
clearTimeout(focusTimeoutRef.current);
}
focusTimeoutRef.current = setTimeout(() => {
inputValidateCodeRef.current?.focusLastSelected();
}, CONST.ANIMATED_TRANSITION);
},
}));

useFocusEffect(
useCallback(() => {
if (!inputValidateCodeRef.current) {
return;
}
if (focusTimeoutRef.current) {
clearTimeout(focusTimeoutRef.current);
}
focusTimeoutRef.current = setTimeout(() => {
inputValidateCodeRef.current?.focusLastSelected();
}, CONST.ANIMATED_TRANSITION);
return () => {
if (!focusTimeoutRef.current) {
return;
}
clearTimeout(focusTimeoutRef.current);
};
}, []),
);

useEffect(() => {
if (!validateError) {
return;
}
clearError();
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [clearError, validateError]);

useEffect(() => {
if (!hasMagicCodeBeenSent) {
return;
}
inputValidateCodeRef.current?.clear();
}, [hasMagicCodeBeenSent]);

/**
* Request a validate code / magic code be sent to verify this contact method
*/
const resendValidateCode = () => {
User.requestValidateCodeAction();
inputValidateCodeRef.current?.clear();
};

/**
* Handle text input and clear formError upon text change
*/
const onTextInput = useCallback(
(text: string) => {
setValidateCode(text);
setFormError({});

if (validateError) {
clearError();
User.clearValidateCodeActionError('actionVerified');
}
},
[validateError, clearError],
);

/**
* Check that all the form fields are valid, then trigger the submit callback
*/
const validateAndSubmitForm = useCallback(() => {
if (!validateCode.trim()) {
setFormError({validateCode: 'validateCodeForm.error.pleaseFillMagicCode'});
return;
}

if (!ValidationUtils.isValidValidateCode(validateCode)) {
setFormError({validateCode: 'validateCodeForm.error.incorrectMagicCode'});
return;
}

setFormError({});
handleSubmitForm(validateCode);
}, [validateCode, handleSubmitForm]);

return (
<>
<MagicCodeInput
autoComplete={autoComplete}
ref={inputValidateCodeRef}
name="validateCode"
value={validateCode}
onChangeText={onTextInput}
errorText={formError?.validateCode ? translate(formError?.validateCode) : ErrorUtils.getLatestErrorMessage(account ?? {})}
hasError={!isEmptyObject(validateError)}
onFulfill={validateAndSubmitForm}
autoFocus={false}
/>
<OfflineWithFeedback
pendingAction={validateCodeAction?.pendingFields?.validateCodeSent}
errors={ErrorUtils.getLatestErrorField(validateCodeAction, 'actionVerified')}
errorRowStyles={[styles.mt2]}
onClose={() => User.clearValidateCodeActionError('actionVerified')}
>
<View style={[styles.mt2, styles.dFlex, styles.flexColumn, styles.alignItemsStart]}>
<PressableWithFeedback
disabled={shouldDisableResendValidateCode}
style={[styles.mr1]}
onPress={resendValidateCode}
underlayColor={theme.componentBG}
hoverDimmingValue={1}
pressDimmingValue={0.2}
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('validateCodeForm.magicCodeNotReceived')}
>
<Text style={[StyleUtils.getDisabledLinkStyles(shouldDisableResendValidateCode)]}>{translate('validateCodeForm.magicCodeNotReceived')}</Text>
</PressableWithFeedback>
{hasMagicCodeBeenSent && (
<DotIndicatorMessage
type="success"
style={[styles.mt6, styles.flex0]}
// eslint-disable-next-line @typescript-eslint/naming-convention
messages={{0: translate('validateCodeModal.successfulNewCodeRequest')}}
/>
)}
</View>
</OfflineWithFeedback>
<OfflineWithFeedback
pendingAction={validatePendingAction}
errors={validateError}
errorRowStyles={[styles.mt2]}
onClose={() => clearError()}
>
<Button
isDisabled={isOffline}
text={translate('common.verify')}
onPress={validateAndSubmitForm}
style={[styles.mt4]}
success
pressOnEnter
large
isLoading={account?.isLoading}
/>
</OfflineWithFeedback>
</>
);
}

BaseValidateCodeForm.displayName = 'BaseValidateCodeForm';

export type {ValidateCodeFormProps, ValidateCodeFormHandle};

export default withOnyx<BaseValidateCodeFormProps, BaseValidateCodeFormOnyxProps>({
account: {key: ONYXKEYS.ACCOUNT},
})(BaseValidateCodeForm);
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React, {forwardRef} from 'react';
import BaseValidateCodeForm from './BaseValidateCodeForm';
import type {ValidateCodeFormHandle, ValidateCodeFormProps} from './BaseValidateCodeForm';

const ValidateCodeForm = forwardRef<ValidateCodeFormHandle, ValidateCodeFormProps>((props, ref) => (
<BaseValidateCodeForm
autoComplete="sms-otp"
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
innerRef={ref}
/>
));

export default ValidateCodeForm;
14 changes: 14 additions & 0 deletions src/components/ValidateCodeActionModal/ValidateCodeForm/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React, {forwardRef} from 'react';
import BaseValidateCodeForm from './BaseValidateCodeForm';
import type {ValidateCodeFormHandle, ValidateCodeFormProps} from './BaseValidateCodeForm';

const ValidateCodeForm = forwardRef<ValidateCodeFormHandle, ValidateCodeFormProps>((props, ref) => (
<BaseValidateCodeForm
autoComplete="one-time-code"
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
innerRef={ref}
/>
));

export default ValidateCodeForm;
75 changes: 75 additions & 0 deletions src/components/ValidateCodeActionModal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, {useCallback, useEffect, useRef} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import Modal from '@components/Modal';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
import useThemeStyles from '@hooks/useThemeStyles';
import * as User from '@libs/actions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {ValidateCodeActionModalProps} from './type';
import ValidateCodeForm from './ValidateCodeForm';
import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm';

function ValidateCodeActionModal({isVisible, title, description, onClose, validatePendingAction, validateError, handleSubmitForm, clearError}: ValidateCodeActionModalProps) {
const themeStyles = useThemeStyles();
const firstRenderRef = useRef(true);
const validateCodeFormRef = useRef<ValidateCodeFormHandle>(null);

const [validateCodeAction] = useOnyx(ONYXKEYS.VALIDATE_ACTION_CODE);

const hide = useCallback(() => {
clearError();
onClose();
}, [onClose, clearError]);

useEffect(() => {
if (!firstRenderRef.current || !isVisible) {
return;
}
firstRenderRef.current = false;
User.requestValidateCodeAction();
}, [isVisible]);

return (
<Modal
type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED}
isVisible={isVisible}
onClose={hide}
onModalHide={hide}
hideModalContentWhileAnimating
useNativeDriver
shouldUseModalPaddingStyle={false}
>
<ScreenWrapper
includeSafeAreaPaddingBottom={false}
shouldEnableMaxHeight
testID={ValidateCodeActionModal.displayName}
offlineIndicatorStyle={themeStyles.mtAuto}
>
<HeaderWithBackButton
title={title}
onBackButtonPress={hide}
/>

<View style={[themeStyles.ph5, themeStyles.mt3, themeStyles.mb7]}>
<Text style={[themeStyles.mb3]}>{description}</Text>
<ValidateCodeForm
validateCodeAction={validateCodeAction}
validatePendingAction={validatePendingAction}
validateError={validateError}
handleSubmitForm={handleSubmitForm}
clearError={clearError}
ref={validateCodeFormRef}
/>
</View>
</ScreenWrapper>
</Modal>
);
}

ValidateCodeActionModal.displayName = 'ValidateCodeActionModal';

export default ValidateCodeActionModal;
Loading

0 comments on commit aaa60b9

Please sign in to comment.