Skip to content

Commit aaa60b9

Browse files
authored
Merge pull request Expensify#48628 from hungvu193/feat/validate-code-modal
add validate code modal
2 parents 9affb13 + 5296333 commit aaa60b9

File tree

15 files changed

+559
-24
lines changed

15 files changed

+559
-24
lines changed

src/ONYXKEYS.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ const ONYXKEYS = {
106106
/** Object containing contact method that's going to be added */
107107
PENDING_CONTACT_ACTION: 'pendingContactAction',
108108

109+
/** Store the information of magic code */
110+
VALIDATE_ACTION_CODE: 'validate_action_code',
111+
109112
/** Information about the current session (authToken, accountID, email, loading, error) */
110113
SESSION: 'session',
111114
STASHED_SESSION: 'stashedSession',
@@ -840,6 +843,7 @@ type OnyxValuesMapping = {
840843
[ONYXKEYS.USER_LOCATION]: OnyxTypes.UserLocation;
841844
[ONYXKEYS.LOGIN_LIST]: OnyxTypes.LoginList;
842845
[ONYXKEYS.PENDING_CONTACT_ACTION]: OnyxTypes.PendingContactAction;
846+
[ONYXKEYS.VALIDATE_ACTION_CODE]: OnyxTypes.ValidateMagicCodeAction;
843847
[ONYXKEYS.SESSION]: OnyxTypes.Session;
844848
[ONYXKEYS.USER_METADATA]: OnyxTypes.UserMetadata;
845849
[ONYXKEYS.STASHED_SESSION]: OnyxTypes.Session;
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import {useFocusEffect} from '@react-navigation/native';
2+
import type {ForwardedRef} from 'react';
3+
import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
4+
import {View} from 'react-native';
5+
import type {OnyxEntry} from 'react-native-onyx';
6+
import {withOnyx} from 'react-native-onyx';
7+
import Button from '@components/Button';
8+
import DotIndicatorMessage from '@components/DotIndicatorMessage';
9+
import MagicCodeInput from '@components/MagicCodeInput';
10+
import type {AutoCompleteVariant, MagicCodeInputHandle} from '@components/MagicCodeInput';
11+
import OfflineWithFeedback from '@components/OfflineWithFeedback';
12+
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
13+
import Text from '@components/Text';
14+
import useLocalize from '@hooks/useLocalize';
15+
import useNetwork from '@hooks/useNetwork';
16+
import useStyleUtils from '@hooks/useStyleUtils';
17+
import useTheme from '@hooks/useTheme';
18+
import useThemeStyles from '@hooks/useThemeStyles';
19+
import * as ErrorUtils from '@libs/ErrorUtils';
20+
import * as ValidationUtils from '@libs/ValidationUtils';
21+
import * as User from '@userActions/User';
22+
import CONST from '@src/CONST';
23+
import type {TranslationPaths} from '@src/languages/types';
24+
import ONYXKEYS from '@src/ONYXKEYS';
25+
import type {Account, ValidateMagicCodeAction} from '@src/types/onyx';
26+
import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon';
27+
import {isEmptyObject} from '@src/types/utils/EmptyObject';
28+
29+
type ValidateCodeFormHandle = {
30+
focus: () => void;
31+
focusLastSelected: () => void;
32+
};
33+
34+
type ValidateCodeFormError = {
35+
validateCode?: TranslationPaths;
36+
};
37+
38+
type BaseValidateCodeFormOnyxProps = {
39+
/** The details about the account that the user is signing in with */
40+
account: OnyxEntry<Account>;
41+
};
42+
43+
type ValidateCodeFormProps = {
44+
/** If the magic code has been resent previously */
45+
hasMagicCodeBeenSent?: boolean;
46+
47+
/** Specifies autocomplete hints for the system, so it can provide autofill */
48+
autoComplete?: AutoCompleteVariant;
49+
50+
/** Forwarded inner ref */
51+
innerRef?: ForwardedRef<ValidateCodeFormHandle>;
52+
53+
/** The state of magic code that being sent */
54+
validateCodeAction?: ValidateMagicCodeAction;
55+
56+
/** The pending action for submitting form */
57+
validatePendingAction?: PendingAction | null;
58+
59+
/** The error of submitting */
60+
validateError?: Errors;
61+
62+
/** Function is called when submitting form */
63+
handleSubmitForm: (validateCode: string) => void;
64+
65+
/** Function to clear error of the form */
66+
clearError: () => void;
67+
};
68+
69+
type BaseValidateCodeFormProps = BaseValidateCodeFormOnyxProps & ValidateCodeFormProps;
70+
71+
function BaseValidateCodeForm({
72+
account = {},
73+
hasMagicCodeBeenSent,
74+
autoComplete = 'one-time-code',
75+
innerRef = () => {},
76+
validateCodeAction,
77+
validatePendingAction,
78+
validateError,
79+
handleSubmitForm,
80+
clearError,
81+
}: BaseValidateCodeFormProps) {
82+
const {translate} = useLocalize();
83+
const {isOffline} = useNetwork();
84+
const theme = useTheme();
85+
const styles = useThemeStyles();
86+
const StyleUtils = useStyleUtils();
87+
const [formError, setFormError] = useState<ValidateCodeFormError>({});
88+
const [validateCode, setValidateCode] = useState('');
89+
const inputValidateCodeRef = useRef<MagicCodeInputHandle>(null);
90+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing doesn't achieve the same result in this case
91+
const shouldDisableResendValidateCode = !!isOffline || account?.isLoading;
92+
const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
93+
94+
useImperativeHandle(innerRef, () => ({
95+
focus() {
96+
inputValidateCodeRef.current?.focus();
97+
},
98+
focusLastSelected() {
99+
if (!inputValidateCodeRef.current) {
100+
return;
101+
}
102+
if (focusTimeoutRef.current) {
103+
clearTimeout(focusTimeoutRef.current);
104+
}
105+
focusTimeoutRef.current = setTimeout(() => {
106+
inputValidateCodeRef.current?.focusLastSelected();
107+
}, CONST.ANIMATED_TRANSITION);
108+
},
109+
}));
110+
111+
useFocusEffect(
112+
useCallback(() => {
113+
if (!inputValidateCodeRef.current) {
114+
return;
115+
}
116+
if (focusTimeoutRef.current) {
117+
clearTimeout(focusTimeoutRef.current);
118+
}
119+
focusTimeoutRef.current = setTimeout(() => {
120+
inputValidateCodeRef.current?.focusLastSelected();
121+
}, CONST.ANIMATED_TRANSITION);
122+
return () => {
123+
if (!focusTimeoutRef.current) {
124+
return;
125+
}
126+
clearTimeout(focusTimeoutRef.current);
127+
};
128+
}, []),
129+
);
130+
131+
useEffect(() => {
132+
if (!validateError) {
133+
return;
134+
}
135+
clearError();
136+
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
137+
}, [clearError, validateError]);
138+
139+
useEffect(() => {
140+
if (!hasMagicCodeBeenSent) {
141+
return;
142+
}
143+
inputValidateCodeRef.current?.clear();
144+
}, [hasMagicCodeBeenSent]);
145+
146+
/**
147+
* Request a validate code / magic code be sent to verify this contact method
148+
*/
149+
const resendValidateCode = () => {
150+
User.requestValidateCodeAction();
151+
inputValidateCodeRef.current?.clear();
152+
};
153+
154+
/**
155+
* Handle text input and clear formError upon text change
156+
*/
157+
const onTextInput = useCallback(
158+
(text: string) => {
159+
setValidateCode(text);
160+
setFormError({});
161+
162+
if (validateError) {
163+
clearError();
164+
User.clearValidateCodeActionError('actionVerified');
165+
}
166+
},
167+
[validateError, clearError],
168+
);
169+
170+
/**
171+
* Check that all the form fields are valid, then trigger the submit callback
172+
*/
173+
const validateAndSubmitForm = useCallback(() => {
174+
if (!validateCode.trim()) {
175+
setFormError({validateCode: 'validateCodeForm.error.pleaseFillMagicCode'});
176+
return;
177+
}
178+
179+
if (!ValidationUtils.isValidValidateCode(validateCode)) {
180+
setFormError({validateCode: 'validateCodeForm.error.incorrectMagicCode'});
181+
return;
182+
}
183+
184+
setFormError({});
185+
handleSubmitForm(validateCode);
186+
}, [validateCode, handleSubmitForm]);
187+
188+
return (
189+
<>
190+
<MagicCodeInput
191+
autoComplete={autoComplete}
192+
ref={inputValidateCodeRef}
193+
name="validateCode"
194+
value={validateCode}
195+
onChangeText={onTextInput}
196+
errorText={formError?.validateCode ? translate(formError?.validateCode) : ErrorUtils.getLatestErrorMessage(account ?? {})}
197+
hasError={!isEmptyObject(validateError)}
198+
onFulfill={validateAndSubmitForm}
199+
autoFocus={false}
200+
/>
201+
<OfflineWithFeedback
202+
pendingAction={validateCodeAction?.pendingFields?.validateCodeSent}
203+
errors={ErrorUtils.getLatestErrorField(validateCodeAction, 'actionVerified')}
204+
errorRowStyles={[styles.mt2]}
205+
onClose={() => User.clearValidateCodeActionError('actionVerified')}
206+
>
207+
<View style={[styles.mt2, styles.dFlex, styles.flexColumn, styles.alignItemsStart]}>
208+
<PressableWithFeedback
209+
disabled={shouldDisableResendValidateCode}
210+
style={[styles.mr1]}
211+
onPress={resendValidateCode}
212+
underlayColor={theme.componentBG}
213+
hoverDimmingValue={1}
214+
pressDimmingValue={0.2}
215+
role={CONST.ROLE.BUTTON}
216+
accessibilityLabel={translate('validateCodeForm.magicCodeNotReceived')}
217+
>
218+
<Text style={[StyleUtils.getDisabledLinkStyles(shouldDisableResendValidateCode)]}>{translate('validateCodeForm.magicCodeNotReceived')}</Text>
219+
</PressableWithFeedback>
220+
{hasMagicCodeBeenSent && (
221+
<DotIndicatorMessage
222+
type="success"
223+
style={[styles.mt6, styles.flex0]}
224+
// eslint-disable-next-line @typescript-eslint/naming-convention
225+
messages={{0: translate('validateCodeModal.successfulNewCodeRequest')}}
226+
/>
227+
)}
228+
</View>
229+
</OfflineWithFeedback>
230+
<OfflineWithFeedback
231+
pendingAction={validatePendingAction}
232+
errors={validateError}
233+
errorRowStyles={[styles.mt2]}
234+
onClose={() => clearError()}
235+
>
236+
<Button
237+
isDisabled={isOffline}
238+
text={translate('common.verify')}
239+
onPress={validateAndSubmitForm}
240+
style={[styles.mt4]}
241+
success
242+
pressOnEnter
243+
large
244+
isLoading={account?.isLoading}
245+
/>
246+
</OfflineWithFeedback>
247+
</>
248+
);
249+
}
250+
251+
BaseValidateCodeForm.displayName = 'BaseValidateCodeForm';
252+
253+
export type {ValidateCodeFormProps, ValidateCodeFormHandle};
254+
255+
export default withOnyx<BaseValidateCodeFormProps, BaseValidateCodeFormOnyxProps>({
256+
account: {key: ONYXKEYS.ACCOUNT},
257+
})(BaseValidateCodeForm);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import React, {forwardRef} from 'react';
2+
import BaseValidateCodeForm from './BaseValidateCodeForm';
3+
import type {ValidateCodeFormHandle, ValidateCodeFormProps} from './BaseValidateCodeForm';
4+
5+
const ValidateCodeForm = forwardRef<ValidateCodeFormHandle, ValidateCodeFormProps>((props, ref) => (
6+
<BaseValidateCodeForm
7+
autoComplete="sms-otp"
8+
// eslint-disable-next-line react/jsx-props-no-spreading
9+
{...props}
10+
innerRef={ref}
11+
/>
12+
));
13+
14+
export default ValidateCodeForm;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import React, {forwardRef} from 'react';
2+
import BaseValidateCodeForm from './BaseValidateCodeForm';
3+
import type {ValidateCodeFormHandle, ValidateCodeFormProps} from './BaseValidateCodeForm';
4+
5+
const ValidateCodeForm = forwardRef<ValidateCodeFormHandle, ValidateCodeFormProps>((props, ref) => (
6+
<BaseValidateCodeForm
7+
autoComplete="one-time-code"
8+
// eslint-disable-next-line react/jsx-props-no-spreading
9+
{...props}
10+
innerRef={ref}
11+
/>
12+
));
13+
14+
export default ValidateCodeForm;
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import React, {useCallback, useEffect, useRef} from 'react';
2+
import {View} from 'react-native';
3+
import {useOnyx} from 'react-native-onyx';
4+
import HeaderWithBackButton from '@components/HeaderWithBackButton';
5+
import Modal from '@components/Modal';
6+
import ScreenWrapper from '@components/ScreenWrapper';
7+
import Text from '@components/Text';
8+
import useThemeStyles from '@hooks/useThemeStyles';
9+
import * as User from '@libs/actions/User';
10+
import CONST from '@src/CONST';
11+
import ONYXKEYS from '@src/ONYXKEYS';
12+
import type {ValidateCodeActionModalProps} from './type';
13+
import ValidateCodeForm from './ValidateCodeForm';
14+
import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm';
15+
16+
function ValidateCodeActionModal({isVisible, title, description, onClose, validatePendingAction, validateError, handleSubmitForm, clearError}: ValidateCodeActionModalProps) {
17+
const themeStyles = useThemeStyles();
18+
const firstRenderRef = useRef(true);
19+
const validateCodeFormRef = useRef<ValidateCodeFormHandle>(null);
20+
21+
const [validateCodeAction] = useOnyx(ONYXKEYS.VALIDATE_ACTION_CODE);
22+
23+
const hide = useCallback(() => {
24+
clearError();
25+
onClose();
26+
}, [onClose, clearError]);
27+
28+
useEffect(() => {
29+
if (!firstRenderRef.current || !isVisible) {
30+
return;
31+
}
32+
firstRenderRef.current = false;
33+
User.requestValidateCodeAction();
34+
}, [isVisible]);
35+
36+
return (
37+
<Modal
38+
type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED}
39+
isVisible={isVisible}
40+
onClose={hide}
41+
onModalHide={hide}
42+
hideModalContentWhileAnimating
43+
useNativeDriver
44+
shouldUseModalPaddingStyle={false}
45+
>
46+
<ScreenWrapper
47+
includeSafeAreaPaddingBottom={false}
48+
shouldEnableMaxHeight
49+
testID={ValidateCodeActionModal.displayName}
50+
offlineIndicatorStyle={themeStyles.mtAuto}
51+
>
52+
<HeaderWithBackButton
53+
title={title}
54+
onBackButtonPress={hide}
55+
/>
56+
57+
<View style={[themeStyles.ph5, themeStyles.mt3, themeStyles.mb7]}>
58+
<Text style={[themeStyles.mb3]}>{description}</Text>
59+
<ValidateCodeForm
60+
validateCodeAction={validateCodeAction}
61+
validatePendingAction={validatePendingAction}
62+
validateError={validateError}
63+
handleSubmitForm={handleSubmitForm}
64+
clearError={clearError}
65+
ref={validateCodeFormRef}
66+
/>
67+
</View>
68+
</ScreenWrapper>
69+
</Modal>
70+
);
71+
}
72+
73+
ValidateCodeActionModal.displayName = 'ValidateCodeActionModal';
74+
75+
export default ValidateCodeActionModal;

0 commit comments

Comments
 (0)