Skip to content

Commit f9a20e3

Browse files
committed
add validate code modal
1 parent fd16fdd commit f9a20e3

File tree

10 files changed

+459
-1
lines changed

10 files changed

+459
-1
lines changed

src/ONYXKEYS.ts

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

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

0 commit comments

Comments
 (0)