Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2865a78
Add clearErrors to clear multiple errors on one call
aldo-expensify Dec 2, 2021
8f8d148
Replace onChangeText with onChange.
aldo-expensify Dec 2, 2021
32a9eb9
Update CompanyStep with new AddressSearch
aldo-expensify Dec 2, 2021
91920d6
Add two-way binding for AddressSearch text input
aldo-expensify Dec 2, 2021
bf63468
Update requestor step with new address fields
aldo-expensify Dec 2, 2021
0ea709a
Update ACHContract step handling of IdentityForm
aldo-expensify Dec 2, 2021
9295c47
Update AddDebitCardPage with new AddressSearch
aldo-expensify Dec 2, 2021
27b0d84
Remove unused validation
aldo-expensify Dec 2, 2021
3f73b08
Don't replace street if user typed something longer
aldo-expensify Dec 2, 2021
5adf0c2
Update variable name
aldo-expensify Dec 2, 2021
7d44cd8
Fix CompanyStep not clearing errors
aldo-expensify Dec 3, 2021
d7ac198
Resolve conclifts
aldo-expensify Dec 3, 2021
93642a8
Resolve conflicts
aldo-expensify Dec 3, 2021
0865523
Move comment near state declaration
aldo-expensify Dec 3, 2021
b0fe744
Merge branch 'main' of github.com:Expensify/App into aldo_vba-address…
aldo-expensify Dec 3, 2021
f8377ad
Merge branch 'main' of github.com:Expensify/App into aldo_vba-address…
aldo-expensify Dec 23, 2021
aed7b8c
Use ref instead of state to skip first onChangeText('')
aldo-expensify Dec 23, 2021
e7ff88a
Remove unnecesary new line
aldo-expensify Dec 23, 2021
fd5316e
Remove unnecesary empty placeholder
aldo-expensify Dec 23, 2021
3fd626a
Remove "address" prefix from address related input
aldo-expensify Dec 23, 2021
c30f08a
IdentityForm calls onChange always passing an object
aldo-expensify Dec 23, 2021
ec758cc
Cleanup contract step
aldo-expensify Dec 23, 2021
8466121
Remove unused function
aldo-expensify Dec 23, 2021
3cddfcf
Refactored address related fields into new AddressForm component
aldo-expensify Dec 23, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 44 additions & 43 deletions src/components/AddressSearch.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import _ from 'underscore';
import React, {useEffect, useState, useRef} from 'react';
import React, {useRef, useState} from 'react';
import PropTypes from 'prop-types';
import {LogBox} from 'react-native';
import {GooglePlacesAutocomplete} from 'react-native-google-places-autocomplete';
Expand All @@ -23,7 +23,7 @@ const propTypes = {
value: PropTypes.string,

/** A callback function when the value of this field has changed */
onChangeText: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,

/** Customize the ExpensiTextInput container */
containerStyles: PropTypes.arrayOf(PropTypes.object),
Expand All @@ -40,52 +40,53 @@ const defaultProps = {
// Relevant thread: https://expensify.slack.com/archives/C03TQ48KC/p1634088400387400
// Reference: https://github.com/FaridSafi/react-native-google-places-autocomplete/issues/609#issuecomment-886133839
const AddressSearch = (props) => {
const googlePlacesRef = useRef();
const [displayListViewBorder, setDisplayListViewBorder] = useState(false);
useEffect(() => {
if (!googlePlacesRef.current) {
return;
}

googlePlacesRef.current.setAddressText(props.value);
}, []);
// We use `skippedFirstOnChangeTextRef` to work around a feature of the library:
// The library is calling onChangeText with '' at the start and we don't need this
// https://github.com/FaridSafi/react-native-google-places-autocomplete/blob/47d7223dd48f85da97e80a0729a985bbbcee353f/GooglePlacesAutocomplete.js#L148
const skippedFirstOnChangeTextRef = useRef(false);

const saveLocationDetails = (details) => {
const addressComponents = details.address_components;
if (GooglePlacesUtils.isAddressValidForVBA(addressComponents)) {
// Gather the values from the Google details
const streetNumber = GooglePlacesUtils.getAddressComponent(addressComponents, 'street_number', 'long_name');
const streetName = GooglePlacesUtils.getAddressComponent(addressComponents, 'route', 'long_name');
let city = GooglePlacesUtils.getAddressComponent(addressComponents, 'locality', 'long_name');
if (!city) {
city = GooglePlacesUtils.getAddressComponent(addressComponents, 'sublocality', 'long_name');
Log.hmmm('[AddressSearch] Replacing missing locality with sublocality: ', {address: details.formatted_address, sublocality: city});
}
const state = GooglePlacesUtils.getAddressComponent(addressComponents, 'administrative_area_level_1', 'short_name');
const zipCode = GooglePlacesUtils.getAddressComponent(addressComponents, 'postal_code', 'long_name');

// Trigger text change events for each of the individual fields being saved on the server
props.onChangeText('addressStreet', `${streetNumber} ${streetName}`);
props.onChangeText('addressCity', city);
props.onChangeText('addressState', state);
props.onChangeText('addressZipCode', zipCode);
} else {
// Clear the values associated to the address, so our validations catch the problem
Log.hmmm('[AddressSearch] Search result failed validation: ', {
address: details.formatted_address,
address_components: addressComponents,
place_id: details.place_id,
});
props.onChangeText('addressStreet', null);
props.onChangeText('addressCity', null);
props.onChangeText('addressState', null);
props.onChangeText('addressZipCode', null);
if (!addressComponents) {
return;
}

// Gather the values from the Google details
const streetNumber = GooglePlacesUtils.getAddressComponent(addressComponents, 'street_number', 'long_name') || '';
const streetName = GooglePlacesUtils.getAddressComponent(addressComponents, 'route', 'long_name') || '';
const street = `${streetNumber} ${streetName}`.trim();
let city = GooglePlacesUtils.getAddressComponent(addressComponents, 'locality', 'long_name');
if (!city) {
city = GooglePlacesUtils.getAddressComponent(addressComponents, 'sublocality', 'long_name');
Log.hmmm('[AddressSearch] Replacing missing locality with sublocality: ', {address: details.formatted_address, sublocality: city});
}
const zipCode = GooglePlacesUtils.getAddressComponent(addressComponents, 'postal_code', 'long_name');
const state = GooglePlacesUtils.getAddressComponent(addressComponents, 'administrative_area_level_1', 'short_name');

const values = {};
if (street && street.length > props.value.length) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB, should we trim props.value.length in case there is whitespace that makes it longer but less accurate?

Also, I wonder if we need to worry about a case like this where someone enters some very long address into the street input. I feel like in trying to optimize for the Apartment # case we are maybe introducing the possibility of bad street info instead of trusting the correctness of what Google Places returns.

Here's an example of what I mean:

2021-12-23_10-35-56

2021-12-23_10-36-01

I think we can maybe wait and see if this is a problem...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB, should we trim props.value.length in case there is whitespace that makes it longer but less accurate?

Also, I wonder if we need to worry about a case like this where someone enters some very long address into the street input. I feel like in trying to optimize for the Apartment # case we are maybe introducing the possibility of bad street info instead of trusting the correctness of what Google Places returns.

I think we can maybe wait and see if this is a problem...

I honestly don't like it either, I think the ideal case is to have the Apt number in a separate field. As @luacmartins , this also causes the autocomplete to start showing results while the user may be only trying to input the apt number and it looks weird :P. Having said that, if we wanted to do a separate input for it, I think that should be done in a separate pr/issue.

// Don't replace if the user has typed something longer. I.e. maybe the user entered the Apt #

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the change!

NAB, this comment does help explain why we might not want to use the street number and street name provided by Google Places. I do have a lingering concern that we are not quite painting the exact picture of why we need this logic. Here's a suggestion that is more verbose, but explains the situation in greater detail

We are only passing the street number and name if the combined length is longer than the value that was initially passed to the autocomplete component. Google Places can truncate details like Apt # and this is the best way we have to tell that the new value it's giving us is less specific than the one the user entered manually.

values.street = street;
}
if (city) {
values.city = city;
}
if (zipCode) {
values.zipCode = zipCode;
}
if (state) {
values.state = state;
}
if (_.size(values) === 0) {
return;
}
props.onChange(values);
};

return (
<GooglePlacesAutocomplete
ref={googlePlacesRef}
fetchDetails
suppressDefaultStyles
enablePoweredByContainer={false}
Expand All @@ -110,12 +111,12 @@ const AddressSearch = (props) => {
label: props.label,
containerStyles: props.containerStyles,
errorText: props.errorText,
value: props.value,
Comment thread
marcaaron marked this conversation as resolved.
onChangeText: (text) => {
const isTextValid = !_.isEmpty(text) && _.isEqual(text, props.value);

// Ensure whether an address is selected already or has address value initialized.
if (!_.isEmpty(googlePlacesRef.current.getAddressText()) && !isTextValid) {
saveLocationDetails({});
if (skippedFirstOnChangeTextRef.current) {
props.onChange({street: text});
} else {
skippedFirstOnChangeTextRef.current = true;
}

// If the text is empty, we set displayListViewBorder to false to prevent UI flickering
Expand Down
36 changes: 1 addition & 35 deletions src/libs/GooglePlacesUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,41 +21,7 @@ function getAddressComponent(addressComponents, type, key) {
.value();
}

/**
* Validates this contains the minimum address components
*
* @param {Array} addressComponents
* @returns {Boolean}
*/
function isAddressValidForVBA(addressComponents) {
if (!addressComponents) {
return false;
}
if (!_.some(addressComponents, component => _.includes(component.types, 'street_number'))) {
// Missing Street number
return false;
}
if (!_.some(addressComponents, component => _.includes(component.types, 'postal_code'))) {
// Missing zip code
return false;
}
if (!_.some(addressComponents, component => _.includes(component.types, 'administrative_area_level_1'))) {
// Missing state
return false;
}
if (!_.some(addressComponents, component => _.includes(component.types, 'locality'))
&& !_.some(addressComponents, component => _.includes(component.types, 'sublocality'))) {
// Missing city
return false;
}
if (_.some(addressComponents, component => _.includes(component.types, 'post_box'))) {
// Reject PO box
return false;
}
return true;
}

export {
// eslint-disable-next-line import/prefer-default-export
getAddressComponent,
isAddressValidForVBA,
};
19 changes: 15 additions & 4 deletions src/libs/ReimbursementAccountUtils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import _ from 'underscore';
import lodashGet from 'lodash/get';
import lodashUnset from 'lodash/unset';
import lodashCloneDeep from 'lodash/cloneDeep';
Expand Down Expand Up @@ -27,21 +28,30 @@ function getErrors(props) {

/**
* @param {Object} props
* @param {String} path
* @param {String[]} paths
*/
function clearError(props, path) {
function clearErrors(props, paths) {
const errors = getErrors(props);
if (!lodashGet(errors, path, false)) {
const pathsWithErrors = _.filter(paths, path => lodashGet(errors, path, false));
if (_.size(pathsWithErrors) === 0) {
// No error found for this path
return;
}

// Clear the existing errors
const newErrors = lodashCloneDeep(errors);
lodashUnset(newErrors, path);
_.forEach(pathsWithErrors, path => lodashUnset(newErrors, path));
BankAccounts.setBankAccountFormValidationErrors(newErrors);
}

/**
* @param {Object} props
* @param {String} path
*/
function clearError(props, path) {
clearErrors(props, [path]);
}

/**
* @param {Object} props
* @param {Object} errorTranslationKeys
Expand All @@ -57,5 +67,6 @@ export {
getDefaultStateForField,
getErrors,
clearError,
clearErrors,
getErrorText,
};
39 changes: 13 additions & 26 deletions src/pages/ReimbursementAccount/ACHContractStep.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,12 @@ class ACHContractStep extends React.Component {
certifyTrueInformation: 'beneficialOwnersStep.error.certify',
};

this.getErrors = () => ReimbursementAccountUtils.getErrors(this.props);
this.clearError = inputKey => ReimbursementAccountUtils.clearError(this.props, inputKey);
this.clearErrors = inputKeys => ReimbursementAccountUtils.clearErrors(this.props, inputKeys);
this.getErrorText = inputKey => ReimbursementAccountUtils.getErrorText(this.props, this.errorTranslationKeys, inputKey);
}

/**
* @returns {Object}
*/
getErrors() {
return lodashGet(this.props, ['reimbursementAccount', 'errors'], {});
}

/**
* @returns {Boolean}
*/
Expand Down Expand Up @@ -122,31 +117,24 @@ class ACHContractStep extends React.Component {
* Clear the error associated to inputKey if found and store the inputKey new value in the state.
*
* @param {Integer} ownerIndex
* @param {String} inputKey
* @param {String} value
* @param {Object} values
*/
clearErrorAndSetBeneficialOwnerValue(ownerIndex, inputKey, value) {
clearErrorAndSetBeneficialOwnerValues(ownerIndex, values) {
this.setState((prevState) => {
const renamedFields = {
addressStreet: 'street',
addressCity: 'city',
addressState: 'state',
addressZipCode: 'zipCode',
};
const renamedInputKey = lodashGet(renamedFields, inputKey, inputKey);
const beneficialOwners = [...prevState.beneficialOwners];
beneficialOwners[ownerIndex] = {...beneficialOwners[ownerIndex], [renamedInputKey]: value};
if (inputKey !== 'manualAddress') {
BankAccounts.updateReimbursementAccountDraft({beneficialOwners});
}
beneficialOwners[ownerIndex] = {...beneficialOwners[ownerIndex], ...values};
BankAccounts.updateReimbursementAccountDraft({beneficialOwners});
return {beneficialOwners};
});

// Prepare inputKeys for clearing errors
const inputKeys = _.keys(values);

// dob field has multiple validations/errors, we are handling it temporarily like this.
if (inputKey === 'dob') {
this.clearError(`beneficialOwnersErrors.${ownerIndex}.dobAge`);
if (_.contains(inputKeys, 'dob')) {
inputKeys.push('dobAge');
}
this.clearError(`beneficialOwnersErrors.${ownerIndex}.${inputKey}`);
this.clearErrors(_.map(inputKeys, inputKey => `beneficialOwnersErrors.${ownerIndex}.${inputKey}`));
}

submit() {
Expand Down Expand Up @@ -235,7 +223,7 @@ class ACHContractStep extends React.Component {
</Text>
<IdentityForm
style={[styles.mb2]}
onFieldChange={(inputKey, value) => this.clearErrorAndSetBeneficialOwnerValue(index, inputKey, value)}
onFieldChange={values => this.clearErrorAndSetBeneficialOwnerValues(index, values)}
values={{
firstName: owner.firstName || '',
lastName: owner.lastName || '',
Expand All @@ -245,7 +233,6 @@ class ACHContractStep extends React.Component {
zipCode: owner.zipCode || '',
dob: owner.dob || '',
ssnLast4: owner.ssnLast4 || '',
manualAddress: owner.manualAddress,
}}
errors={lodashGet(this.getErrors(), `beneficialOwnersErrors[${index}]`, {})}
/>
Expand Down
90 changes: 90 additions & 0 deletions src/pages/ReimbursementAccount/AddressForm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import ExpensiTextInput from '../../components/ExpensiTextInput';
import AddressSearch from '../../components/AddressSearch';
import styles from '../../styles/styles';
import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
import CONST from '../../CONST';
import StatePicker from '../../components/StatePicker';
import Text from '../../components/Text';

const propTypes = {
/** Callback fired when a field changes. Passes args as {[fieldName]: val} */
onFieldChange: PropTypes.func.isRequired,

/** Form values */
values: PropTypes.shape({
/** Address street field */
street: PropTypes.string,

/** Address city field */
city: PropTypes.string,

/** Address state field */
state: PropTypes.string,

/** Address zip code field */
zipCode: PropTypes.string,
}),

/** Any errors that can arise from form validation */
errors: PropTypes.objectOf(PropTypes.bool),

...withLocalizePropTypes,
};

const defaultProps = {
values: {
street: '',
city: '',
state: '',
zipCode: '',
},
errors: {},
};

const AddressForm = props => (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for refactoring this!

<>
<AddressSearch
label={props.translate('common.personalAddress')}
containerStyles={[styles.mt4]}
value={props.values.street}
onChange={props.onFieldChange}
errorText={props.errors.street ? props.translate('bankAccount.error.addressStreet') : ''}
/>
<Text style={[styles.mutedTextLabel, styles.mt1]}>{props.translate('common.noPO')}</Text>
<View style={[styles.flexRow, styles.mt4]}>
<View style={[styles.flex2, styles.mr2]}>
<ExpensiTextInput
label={props.translate('common.city')}
value={props.values.city}
onChangeText={value => props.onFieldChange({city: value})}
errorText={props.errors.city ? props.translate('bankAccount.error.addressCity') : ''}
/>
</View>
<View style={[styles.flex1]}>
<StatePicker
value={props.values.state}
onChange={value => props.onFieldChange({state: value})}
errorText={props.errors.state ? props.translate('bankAccount.error.addressState') : ''}
hasError={Boolean(props.errors.state)}
/>
</View>
</View>
<ExpensiTextInput
label={props.translate('common.zip')}
containerStyles={[styles.mt4]}
keyboardType={CONST.KEYBOARD_TYPE.NUMERIC}
value={props.values.zipCode}
onChangeText={value => props.onFieldChange({zipCode: value})}
errorText={props.errors.zipCode ? props.translate('bankAccount.error.zipCode') : ''}
maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.ZIP_CODE}
/>
</>
);

AddressForm.propTypes = propTypes;
AddressForm.defaultProps = defaultProps;
AddressForm.displayName = 'AddressForm';
export default withLocalize(AddressForm);
Loading