Skip to content

Commit

Permalink
Merge pull request #31133 from software-mansion-labs/kowczarz/add-deb…
Browse files Browse the repository at this point in the history
…it-card-page-migration-fixes

[Form Provider Refactor] AddDebitCardPage fixes
  • Loading branch information
luacmartins authored Nov 27, 2023
2 parents 1d560c9 + c01db95 commit a35532b
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 84 deletions.
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 @@ -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 */
Expand All @@ -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: {
Expand Down Expand Up @@ -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);
}
Expand All @@ -282,7 +302,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
if (shouldValidateOnBlur) {
onValidate(inputValues, !hasServerError);
}
}, 200);
}, VALIDATE_DELAY);
}

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

0 comments on commit a35532b

Please sign in to comment.