diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js
index 73472beeb48d..a401300e920d 100644
--- a/src/components/AddressSearch/index.js
+++ b/src/components/AddressSearch/index.js
@@ -1,6 +1,6 @@
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
-import React, {useEffect, useMemo, useRef, useState} from 'react';
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {ActivityIndicator, Keyboard, LogBox, ScrollView, Text, View} from 'react-native';
import {GooglePlacesAutocomplete} from 'react-native-google-places-autocomplete';
import _ from 'underscore';
@@ -140,29 +140,48 @@ const defaultProps = {
resultTypes: 'address',
};
-// Do not convert to class component! It's been tried before and presents more challenges than it's worth.
-// Relevant thread: https://expensify.slack.com/archives/C03TQ48KC/p1634088400387400
-// Reference: https://github.com/FaridSafi/react-native-google-places-autocomplete/issues/609#issuecomment-886133839
-function AddressSearch(props) {
+function AddressSearch({
+ canUseCurrentLocation,
+ containerStyles,
+ defaultValue,
+ errorText,
+ hint,
+ innerRef,
+ inputID,
+ isLimitedToUSA,
+ label,
+ maxInputLength,
+ network,
+ onBlur,
+ onInputChange,
+ onPress,
+ predefinedPlaces,
+ preferredLocale,
+ renamedInputKeys,
+ resultTypes,
+ shouldSaveDraft,
+ translate,
+ value,
+}) {
const theme = useTheme();
const styles = useThemeStyles();
const [displayListViewBorder, setDisplayListViewBorder] = useState(false);
const [isTyping, setIsTyping] = useState(false);
const [isFocused, setIsFocused] = useState(false);
- const [searchValue, setSearchValue] = useState(props.value || props.defaultValue || '');
+ const [searchValue, setSearchValue] = useState(value || defaultValue || '');
const [locationErrorCode, setLocationErrorCode] = useState(null);
const [isFetchingCurrentLocation, setIsFetchingCurrentLocation] = useState(false);
const shouldTriggerGeolocationCallbacks = useRef(true);
const containerRef = useRef();
const query = useMemo(
() => ({
- language: props.preferredLocale,
- types: props.resultTypes,
- components: props.isLimitedToUSA ? 'country:us' : undefined,
+ language: preferredLocale,
+ types: resultTypes,
+ components: isLimitedToUSA ? 'country:us' : undefined,
}),
- [props.preferredLocale, props.resultTypes, props.isLimitedToUSA],
+ [preferredLocale, resultTypes, isLimitedToUSA],
);
- const shouldShowCurrentLocationButton = props.canUseCurrentLocation && searchValue.trim().length === 0 && isFocused;
+ const shouldShowCurrentLocationButton = canUseCurrentLocation && searchValue.trim().length === 0 && isFocused;
const saveLocationDetails = (autocompleteData, details) => {
const addressComponents = details.address_components;
@@ -171,7 +190,7 @@ function AddressSearch(props) {
// to this component which don't match the usual properties coming from auto-complete. In that case, only a limited
// amount of data massaging needs to happen for what the parent expects to get from this function.
if (_.size(details)) {
- props.onPress({
+ onPress({
address: lodashGet(details, 'description'),
lat: lodashGet(details, 'geometry.location.lat', 0),
lng: lodashGet(details, 'geometry.location.lng', 0),
@@ -269,7 +288,7 @@ function AddressSearch(props) {
// Not all pages define the Address Line 2 field, so in that case we append any additional address details
// (e.g. Apt #) to Address Line 1
- if (subpremise && typeof props.renamedInputKeys.street2 === 'undefined') {
+ if (subpremise && typeof renamedInputKeys.street2 === 'undefined') {
values.street += `, ${subpremise}`;
}
@@ -278,19 +297,19 @@ function AddressSearch(props) {
values.country = country;
}
- if (props.inputID) {
- _.each(values, (value, key) => {
- const inputKey = lodashGet(props.renamedInputKeys, key, key);
+ if (inputID) {
+ _.each(values, (inputValue, key) => {
+ const inputKey = lodashGet(renamedInputKeys, key, key);
if (!inputKey) {
return;
}
- props.onInputChange(value, inputKey);
+ onInputChange(inputValue, inputKey);
});
} else {
- props.onInputChange(values);
+ onInputChange(values);
}
- props.onPress(values);
+ onPress(values);
};
/** Gets the user's current location and registers success/error callbacks */
@@ -320,7 +339,7 @@ function AddressSearch(props) {
lng: successData.coords.longitude,
address: CONST.YOUR_LOCATION_TEXT,
};
- props.onPress(location);
+ onPress(location);
},
(errorData) => {
if (!shouldTriggerGeolocationCallbacks.current) {
@@ -338,16 +357,16 @@ function AddressSearch(props) {
};
const renderHeaderComponent = () =>
- props.predefinedPlaces.length > 0 && (
+ predefinedPlaces.length > 0 && (
<>
{/* This will show current location button in list if there are some recent destinations */}
{shouldShowCurrentLocationButton && (
)}
- {!props.value && {props.translate('common.recentDestinations')}}
+ {!value && {translate('common.recentDestinations')}}
>
);
@@ -359,6 +378,26 @@ function AddressSearch(props) {
};
}, []);
+ const listEmptyComponent = useCallback(
+ () =>
+ network.isOffline || !isTyping ? null : (
+ {translate('common.noResultsFound')}
+ ),
+ [network.isOffline, isTyping, styles, translate],
+ );
+
+ const listLoader = useCallback(
+ () => (
+
+
+
+ ),
+ [styles.pv4, theme.spinner],
+ );
+
return (
/*
* The GooglePlacesAutocomplete component uses a VirtualizedList internally,
@@ -385,20 +424,10 @@ function AddressSearch(props) {
fetchDetails
suppressDefaultStyles
enablePoweredByContainer={false}
- predefinedPlaces={props.predefinedPlaces}
- listEmptyComponent={
- props.network.isOffline || !isTyping ? null : (
- {props.translate('common.noResultsFound')}
- )
- }
- listLoaderComponent={
-
-
-
- }
+ predefinedPlaces={predefinedPlaces}
+ listEmptyComponent={listEmptyComponent}
+ listLoaderComponent={listLoader}
+ renderHeaderComponent={renderHeaderComponent}
renderRow={(data) => {
const title = data.isPredefinedPlace ? data.name : data.structured_formatting.main_text;
const subtitle = data.isPredefinedPlace ? data.description : data.structured_formatting.secondary_text;
@@ -409,7 +438,6 @@ function AddressSearch(props) {
);
}}
- renderHeaderComponent={renderHeaderComponent}
onPress={(data, details) => {
saveLocationDetails(data, details);
setIsTyping(false);
@@ -424,34 +452,31 @@ function AddressSearch(props) {
query={query}
requestUrl={{
useOnPlatform: 'all',
- url: props.network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}),
+ url: network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}),
}}
textInputProps={{
InputComp: TextInput,
ref: (node) => {
- if (!props.innerRef) {
+ if (!innerRef) {
return;
}
- if (_.isFunction(props.innerRef)) {
- props.innerRef(node);
+ if (_.isFunction(innerRef)) {
+ innerRef(node);
return;
}
// eslint-disable-next-line no-param-reassign
- props.innerRef.current = node;
+ innerRef.current = node;
},
- label: props.label,
- containerStyles: props.containerStyles,
- errorText: props.errorText,
- hint:
- displayListViewBorder || (props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (props.canUseCurrentLocation && isTyping)
- ? undefined
- : props.hint,
- value: props.value,
- defaultValue: props.defaultValue,
- inputID: props.inputID,
- shouldSaveDraft: props.shouldSaveDraft,
+ label,
+ containerStyles,
+ errorText,
+ hint: displayListViewBorder || (predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (canUseCurrentLocation && isTyping) ? undefined : hint,
+ value,
+ defaultValue,
+ inputID,
+ shouldSaveDraft,
onFocus: () => {
setIsFocused(true);
},
@@ -461,24 +486,24 @@ function AddressSearch(props) {
setIsFocused(false);
setIsTyping(false);
}
- props.onBlur();
+ onBlur();
},
autoComplete: 'off',
onInputChange: (text) => {
setSearchValue(text);
setIsTyping(true);
- if (props.inputID) {
- props.onInputChange(text);
+ if (inputID) {
+ onInputChange(text);
} else {
- props.onInputChange({street: text});
+ onInputChange({street: text});
}
// If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering
- if (_.isEmpty(text) && _.isEmpty(props.predefinedPlaces)) {
+ if (_.isEmpty(text) && _.isEmpty(predefinedPlaces)) {
setDisplayListViewBorder(false);
}
},
- maxLength: props.maxInputLength,
+ maxLength: maxInputLength,
spellCheck: false,
selectTextOnFocus: true,
}}
@@ -500,17 +525,18 @@ function AddressSearch(props) {
}}
inbetweenCompo={
// We want to show the current location button even if there are no recent destinations
- props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? (
+ predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? (
) : (
<>>
)
}
+ placeholder=""
/>
setLocationErrorCode(null)}
diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js
index 776aaae688ed..af2511fc9f74 100644
--- a/src/components/Form/FormProvider.js
+++ b/src/components/Form/FormProvider.js
@@ -56,9 +56,7 @@ const propTypes = {
/** Whether the form submit action is dangerous */
isSubmitActionDangerous: PropTypes.bool,
- /** Whether ScrollWithContext should be used instead of regular ScrollView.
- * Set to true when there's a nested Picker component in Form.
- */
+ /** Whether ScrollWithContext should be used instead of regular ScrollView. Set to true when there's a nested Picker component in Form. */
scrollContextEnabled: PropTypes.bool,
/** Container styles */
@@ -70,11 +68,18 @@ const propTypes = {
/** Information about the network */
network: networkPropTypes.isRequired,
+ /** Should validate function be called when input loose focus */
shouldValidateOnBlur: PropTypes.bool,
+ /** Should validate function be called when the value of the input is changed */
shouldValidateOnChange: PropTypes.bool,
};
+// In order to prevent Checkbox focus loss when the user are focusing a TextInput and proceeds to toggle a CheckBox in web and mobile web.
+// 200ms delay was chosen as a result of empirical testing.
+// More details: https://github.com/Expensify/App/pull/16444#issuecomment-1482983426
+const VALIDATE_DELAY = 200;
+
const defaultProps = {
isSubmitButtonVisible: true,
formState: {
@@ -249,19 +254,34 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
// as this is already happening by the value prop.
defaultValue: undefined,
onTouched: (event) => {
- setTouchedInput(inputID);
+ if (!propsToParse.shouldSetTouchedOnBlurOnly) {
+ setTimeout(() => {
+ setTouchedInput(inputID);
+ }, VALIDATE_DELAY);
+ }
if (_.isFunction(propsToParse.onTouched)) {
propsToParse.onTouched(event);
}
},
onPress: (event) => {
- setTouchedInput(inputID);
+ if (!propsToParse.shouldSetTouchedOnBlurOnly) {
+ setTimeout(() => {
+ setTouchedInput(inputID);
+ }, VALIDATE_DELAY);
+ }
if (_.isFunction(propsToParse.onPress)) {
propsToParse.onPress(event);
}
},
- onPressIn: (event) => {
- setTouchedInput(inputID);
+ onPressOut: (event) => {
+ // To prevent validating just pressed inputs, we need to set the touched input right after
+ // onValidate and to do so, we need to delays setTouchedInput of the same amount of time
+ // as the onValidate is delayed
+ if (!propsToParse.shouldSetTouchedOnBlurOnly) {
+ setTimeout(() => {
+ setTouchedInput(inputID);
+ }, VALIDATE_DELAY);
+ }
if (_.isFunction(propsToParse.onPressIn)) {
propsToParse.onPressIn(event);
}
@@ -282,7 +302,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
if (shouldValidateOnBlur) {
onValidate(inputValues, !hasServerError);
}
- }, 200);
+ }, VALIDATE_DELAY);
}
if (_.isFunction(propsToParse.onBlur)) {
diff --git a/src/components/Form/InputWrapper.js b/src/components/Form/InputWrapper.js
index 99237fd8db43..9a31210195c4 100644
--- a/src/components/Form/InputWrapper.js
+++ b/src/components/Form/InputWrapper.js
@@ -1,12 +1,14 @@
import PropTypes from 'prop-types';
import React, {forwardRef, useContext} from 'react';
+import refPropTypes from '@components/refPropTypes';
+import TextInput from '@components/TextInput';
import FormContext from './FormContext';
const propTypes = {
InputComponent: PropTypes.oneOfType([PropTypes.func, PropTypes.elementType]).isRequired,
inputID: PropTypes.string.isRequired,
valueType: PropTypes.string,
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]),
+ forwardedRef: refPropTypes,
};
const defaultProps = {
@@ -17,8 +19,13 @@ const defaultProps = {
function InputWrapper(props) {
const {InputComponent, inputID, forwardedRef, ...rest} = props;
const {registerInput} = useContext(FormContext);
+ // There are inputs that dont have onBlur methods, to simulate the behavior of onBlur in e.g. checkbox, we had to
+ // use different methods like onPress. This introduced a problem that inputs that have the onBlur method were
+ // calling some methods too early or twice, so we had to add this check to prevent that side effect.
+ // For now this side effect happened only in `TextInput` components.
+ const shouldSetTouchedOnBlurOnly = InputComponent === TextInput;
// eslint-disable-next-line react/jsx-props-no-spreading
- return ;
+ return ;
}
InputWrapper.propTypes = propTypes;
diff --git a/src/pages/settings/Wallet/AddDebitCardPage.js b/src/pages/settings/Wallet/AddDebitCardPage.js
index ed4a545ff208..872052067213 100644
--- a/src/pages/settings/Wallet/AddDebitCardPage.js
+++ b/src/pages/settings/Wallet/AddDebitCardPage.js
@@ -4,7 +4,8 @@ import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import AddressSearch from '@components/AddressSearch';
import CheckboxWithLabel from '@components/CheckboxWithLabel';
-import Form from '@components/Form';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import StatePicker from '@components/StatePicker';
@@ -108,7 +109,7 @@ function DebitCardPage(props) {
title={translate('addDebitCardPage.addADebitCard')}
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WALLET)}
/>
-
+
);
}