Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Form Provider Refactor] AddDebitCardPage fixes #31133

150 changes: 88 additions & 62 deletions src/components/AddressSearch/index.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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),
Expand Down Expand Up @@ -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}`;
}

Expand All @@ -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 */
Expand Down Expand Up @@ -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) {
Expand All @@ -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 && (
<CurrentLocationButton
onPress={getCurrentLocation}
isDisabled={props.network.isOffline}
isDisabled={network.isOffline}
/>
)}
{!props.value && <Text style={[styles.textLabel, styles.colorMuted, styles.pv2, styles.ph3, styles.overflowAuto]}>{props.translate('common.recentDestinations')}</Text>}
{!value && <Text style={[styles.textLabel, styles.colorMuted, styles.pv2, styles.ph3, styles.overflowAuto]}>{translate('common.recentDestinations')}</Text>}
</>
);

Expand All @@ -359,6 +378,26 @@ function AddressSearch(props) {
};
}, []);

const listEmptyComponent = useCallback(
() =>
network.isOffline || !isTyping ? null : (
<Text style={[styles.textLabel, styles.colorMuted, styles.pv4, styles.ph3, styles.overflowAuto]}>{translate('common.noResultsFound')}</Text>
),
[network.isOffline, isTyping, styles, translate],
);

const listLoader = useCallback(
() => (
<View style={[styles.pv4]}>
<ActivityIndicator
color={theme.spinner}
size="small"
/>
</View>
),
[styles.pv4, theme.spinner],
);

return (
/*
* The GooglePlacesAutocomplete component uses a VirtualizedList internally,
Expand All @@ -385,20 +424,10 @@ function AddressSearch(props) {
fetchDetails
suppressDefaultStyles
enablePoweredByContainer={false}
predefinedPlaces={props.predefinedPlaces}
listEmptyComponent={
props.network.isOffline || !isTyping ? null : (
<Text style={[styles.textLabel, styles.colorMuted, styles.pv4, styles.ph3, styles.overflowAuto]}>{props.translate('common.noResultsFound')}</Text>
)
}
listLoaderComponent={
<View style={[styles.pv4]}>
<ActivityIndicator
color={theme.spinner}
size="small"
/>
</View>
}
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;
Expand All @@ -409,7 +438,6 @@ function AddressSearch(props) {
</View>
);
}}
renderHeaderComponent={renderHeaderComponent}
onPress={(data, details) => {
saveLocationDetails(data, details);
setIsTyping(false);
Expand All @@ -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);
},
Expand All @@ -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,
}}
Expand All @@ -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 ? (
<View style={[StyleUtils.getGoogleListViewStyle(true), styles.overflowAuto, styles.borderLeft, styles.borderRight]}>
<CurrentLocationButton
onPress={getCurrentLocation}
isDisabled={props.network.isOffline}
isDisabled={network.isOffline}
/>
</View>
) : (
<></>
)
}
placeholder=""
/>
<LocationErrorMessage
onClose={() => setLocationErrorCode(null)}
Expand Down
36 changes: 28 additions & 8 deletions src/components/Form/FormProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,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 */
Expand All @@ -67,11 +65,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;
kowczarz marked this conversation as resolved.
Show resolved Hide resolved

const defaultProps = {
isSubmitButtonVisible: true,
formState: {
Expand Down Expand Up @@ -243,19 +248,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);
}
Expand All @@ -271,7 +291,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
if (shouldValidateOnBlur) {
onValidate(inputValues, !hasServerError);
}
}, 200);
}, VALIDATE_DELAY);
}

if (_.isFunction(propsToParse.onBlur)) {
Expand Down
Loading
Loading