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)} /> -
- (nameOnCardRef.current = ref)} + ref={nameOnCardRef} spellCheck={false} /> - - - - - - + - ( {`${translate('common.iAcceptThe')}`} @@ -189,7 +201,7 @@ function DebitCardPage(props) { )} style={[styles.mt4]} /> - + ); }