diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index c97e2e24b1ec..4e1b2f69c828 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -115,6 +115,9 @@ export default { // Stores information about additional details form entry WALLET_ADDITIONAL_DETAILS: 'walletAdditionalDetails', + // Stores values put into the additional details step of the wallet KYC flow + WALLET_ADDITIONAL_DETAILS_DRAFT: 'walletAdditionalDetailsDraft', + // Object containing Wallet terms step state WALLET_TERMS: 'walletTerms', diff --git a/src/components/AddressSearch.js b/src/components/AddressSearch.js index 855f7f7fe4d2..f8ef4e76c186 100644 --- a/src/components/AddressSearch.js +++ b/src/components/AddressSearch.js @@ -30,9 +30,10 @@ const propTypes = { ...withLocalizePropTypes, }; + const defaultProps = { value: '', - containerStyles: null, + containerStyles: [], }; // Do not convert to class component! It's been tried before and presents more challenges than it's worth. diff --git a/src/components/FormScrollView.js b/src/components/FormScrollView.js new file mode 100644 index 000000000000..aa84bfefcc2f --- /dev/null +++ b/src/components/FormScrollView.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {ScrollView} from 'react-native'; +import styles from '../styles/styles'; + +const propTypes = { + /** Form elements */ + children: PropTypes.node.isRequired, +}; + +const FormScrollView = React.forwardRef((props, ref) => ( + + {props.children} + +)); + +FormScrollView.propTypes = propTypes; +export default FormScrollView; diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.js index c782868a3435..59770385d4cd 100644 --- a/src/libs/ValidationUtils.js +++ b/src/libs/ValidationUtils.js @@ -316,4 +316,5 @@ export { isValidLengthForFirstOrLastName, isValidPaypalUsername, isValidRoutingNumber, + isValidSSNLastFour, }; diff --git a/src/libs/actions/Wallet.js b/src/libs/actions/Wallet.js index 3f2a4aa74a3a..fd441d8ed64e 100644 --- a/src/libs/actions/Wallet.js +++ b/src/libs/actions/Wallet.js @@ -28,15 +28,26 @@ function fetchOnfidoToken() { } /** - * Privately used to update the additionalDetails object in Onyx (which will have various effects on the UI) - * * @param {Boolean} loading whether we are making the API call to validate the user's provided personal details - * @param {String[]} [errorFields] an array of field names that should display errors in the UI - * @param {String} [additionalErrorMessage] an additional error message to display in the UI * @private */ -function setAdditionalDetailsStep(loading, errorFields = null, additionalErrorMessage = '') { - Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS, {loading, errorFields, additionalErrorMessage}); +function setAdditionalDetailsLoading(loading) { + Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS, {loading}); +} + +/** + * @param {Object} errorFields + */ +function setAdditionalDetailsErrors(errorFields) { + Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS, {errorFields: null}); + Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS, {errorFields}); +} + +/** + * @param {String} additionalErrorMessage + */ +function setAdditionalDetailsErrorMessage(additionalErrorMessage) { + Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS, {additionalErrorMessage}); } /** @@ -70,19 +81,9 @@ function activateWallet(currentStep, parameters) { onfidoData = parameters.onfidoData; Onyx.merge(ONYXKEYS.WALLET_ONFIDO, {error: '', loading: true}); } else if (currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS) { - setAdditionalDetailsStep(true); - - // Personal details are heavily validated on the API side. We will only do a quick check to ensure the values - // exist in some capacity and then stringify them. - const errorFields = _.reduce(CONST.WALLET.REQUIRED_ADDITIONAL_DETAILS_FIELDS, (missingFields, fieldName) => ( - !personalDetails[fieldName] ? [...missingFields, fieldName] : missingFields - ), []); - - if (!_.isEmpty(errorFields)) { - setAdditionalDetailsStep(false, errorFields); - return; - } - + setAdditionalDetailsLoading(true); + setAdditionalDetailsErrors(null); + setAdditionalDetailsErrorMessage(''); personalDetails = JSON.stringify(parameters.personalDetails); } else if (currentStep === CONST.WALLET.STEP.TERMS) { hasAcceptedTerms = parameters.hasAcceptedTerms; @@ -104,7 +105,15 @@ function activateWallet(currentStep, parameters) { if (currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS) { if (response.title === CONST.WALLET.ERROR.MISSING_FIELD) { - setAdditionalDetailsStep(false, response.data.fieldNames); + // Errors for missing fields are returned from the API as an array of strings so we are converting this to an + // object with field names as keys and boolean for values + const errorFields = _.reduce(response.data.fieldNames, (errors, fieldName) => ({ + ...errors, + [fieldName]: true, + }), {}); + setAdditionalDetailsLoading(false); + setAdditionalDetailsErrors(errorFields); + setAdditionalDetailsErrorMessage(''); return; } @@ -116,11 +125,15 @@ function activateWallet(currentStep, parameters) { ]; if (_.contains(errorTitles, response.title)) { - setAdditionalDetailsStep(false, null, response.message); + setAdditionalDetailsLoading(false); + setAdditionalDetailsErrorMessage(response.message); + setAdditionalDetailsErrors(null); return; } - setAdditionalDetailsStep(false); + setAdditionalDetailsLoading(false); + setAdditionalDetailsErrors(null); + setAdditionalDetailsErrorMessage(''); return; } @@ -132,7 +145,9 @@ function activateWallet(currentStep, parameters) { if (currentStep === CONST.WALLET.STEP.ONFIDO) { Onyx.merge(ONYXKEYS.WALLET_ONFIDO, {error: '', loading: true}); } else if (currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS) { - setAdditionalDetailsStep(false); + setAdditionalDetailsLoading(false); + setAdditionalDetailsErrors(null); + setAdditionalDetailsErrorMessage(''); } else if (currentStep === CONST.WALLET.STEP.TERMS) { Onyx.merge(ONYXKEYS.WALLET_TERMS, {loading: false}); } @@ -159,9 +174,18 @@ function fetchUserWallet() { }); } +/** + * @param {Object} keyValuePair + */ +function updateAdditionalDetailsDraft(keyValuePair) { + Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS_DRAFT, keyValuePair); +} + export { fetchOnfidoToken, - setAdditionalDetailsStep, activateWallet, fetchUserWallet, + setAdditionalDetailsErrors, + updateAdditionalDetailsDraft, + setAdditionalDetailsErrorMessage, }; diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.js index 46e1c772dde5..72e39de7e2ce 100644 --- a/src/pages/EnablePayments/AdditionalDetailsStep.js +++ b/src/pages/EnablePayments/AdditionalDetailsStep.js @@ -1,16 +1,18 @@ +import lodashGet from 'lodash/get'; +import lodashUnset from 'lodash/unset'; +import lodashCloneDeep from 'lodash/cloneDeep'; import _ from 'underscore'; import React from 'react'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import { - View, ScrollView, KeyboardAvoidingView, + View, KeyboardAvoidingView, } from 'react-native'; import ScreenWrapper from '../../components/ScreenWrapper'; import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; import Navigation from '../../libs/Navigation/Navigation'; import styles from '../../styles/styles'; -import ExpensifyButton from '../../components/ExpensifyButton'; import ExpensifyText from '../../components/ExpensifyText'; import * as BankAccounts from '../../libs/actions/BankAccounts'; import CONST from '../../CONST'; @@ -18,6 +20,12 @@ import compose from '../../libs/compose'; import ONYXKEYS from '../../ONYXKEYS'; import TextLink from '../../components/TextLink'; import ExpensiTextInput from '../../components/ExpensiTextInput'; +import FormScrollView from '../../components/FormScrollView'; +import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton'; +import * as Wallet from '../../libs/actions/Wallet'; +import * as ValidationUtils from '../../libs/ValidationUtils'; +import AddressSearch from '../../components/AddressSearch'; +import DatePicker from '../../components/DatePicker'; const propTypes = { ...withLocalizePropTypes, @@ -28,7 +36,7 @@ const propTypes = { loading: PropTypes.bool, /** Which field needs attention? */ - errorFields: PropTypes.arrayOf(PropTypes.string), + errorFields: PropTypes.objectOf(PropTypes.bool), /** Any additional error message to show */ additionalErrorMessage: PropTypes.string, @@ -47,68 +55,123 @@ class AdditionalDetailsStep extends React.Component { constructor(props) { super(props); - this.requiredText = `${props.translate('common.isRequiredField')}.`; - this.fields = [ - { - label: props.translate('additionalDetailsStep.legalFirstNameLabel'), - fieldName: 'legalFirstName', - }, - { - label: props.translate('additionalDetailsStep.legalMiddleNameLabel'), - fieldName: 'legalMiddleName', - }, - { - label: props.translate('additionalDetailsStep.legalLastNameLabel'), - fieldName: 'legalLastName', - }, - { - label: props.translate('common.personalAddress'), - fieldName: 'addressStreet', - }, - { - label: props.translate('common.city'), - fieldName: 'addressCity', - }, - { - label: props.translate('common.state'), - fieldName: 'addressState', - }, - { - label: props.translate('common.zip'), - fieldName: 'addressZip', - }, - { - label: props.translate('common.phoneNumber'), - fieldName: 'phoneNumber', - }, - { - label: props.translate('common.dob'), - fieldName: 'dob', - }, - { - label: props.translate('common.ssnLast4'), - fieldName: 'ssn', - maxLength: 4, - keyboardType: CONST.KEYBOARD_TYPE.NUMBER_PAD, - }, + this.activateWallet = this.activateWallet.bind(this); + + this.requiredFields = [ + 'legalFirstName', + 'legalLastName', + 'addressStreet', + 'addressCity', + 'addressState', + 'addressZip', + 'phoneNumber', + 'dob', + 'ssn', ]; + this.fieldNameTranslationKeys = { + legalFirstName: 'additionalDetailsStep.legalFirstNameLabel', + legalMiddleName: 'additionalDetailsStep.legalMiddleNameLabel', + legalLastName: 'additionalDetailsStep.legalLastNameLabel', + addressStreet: 'common.personalAddress', + addressCity: 'common.city', + addressState: 'common.state', + addressZip: 'common.zip', + phoneNumber: 'common.phoneNumber', + dob: 'common.dob', + ssn: 'common.ssnLast4', + }; + this.state = { - firstName: '', - legalMiddleName: '', - legalLastName: '', - address: '', - city: '', - state: '', - zipCode: '', - phoneNumber: '', - dob: '', - ssn: '', + legalFirstName: lodashGet(props.walletAdditionalDetailsDraft, 'legalFirstName', ''), + legalMiddleName: lodashGet(props.walletAdditionalDetailsDraft, 'legalMiddleName', ''), + legalLastName: lodashGet(props.walletAdditionalDetailsDraft, 'legalLastName', ''), + addressStreet: lodashGet(props.walletAdditionalDetailsDraft, 'addressStreet', ''), + addressCity: lodashGet(props.walletAdditionalDetailsDraft, 'addressCity', ''), + addressState: lodashGet(props.walletAdditionalDetailsDraft, 'addressState', ''), + addressZip: lodashGet(props.walletAdditionalDetailsDraft, 'addressZip', ''), + phoneNumber: lodashGet(props.walletAdditionalDetailsDraft, 'phoneNumber', ''), + dob: lodashGet(props.walletAdditionalDetailsDraft, 'dob', ''), + ssn: lodashGet(props.walletAdditionalDetailsDraft, 'ssn', ''), }; } + /** + * @param {String} fieldName + * @returns {String} + */ + getErrorText(fieldName) { + const errors = lodashGet(this.props.walletAdditionalDetails, 'errorFields', {}); + if (!errors[fieldName]) { + return ''; + } + + return `${this.props.translate(this.fieldNameTranslationKeys[fieldName])} ${this.props.translate('common.isRequiredField')}.`; + } + + /** + * @returns {Boolean} + */ + validate() { + // Reset server error messages when resubmitting form + Wallet.setAdditionalDetailsErrorMessage(''); + + const errors = {}; + + if (!ValidationUtils.isValidPastDate(this.state.dob)) { + errors.dob = true; + } + + if (!ValidationUtils.isValidAddress(this.state.addressStreet)) { + errors.addressStreet = true; + } + + if (!ValidationUtils.isValidSSNLastFour(this.state.ssn)) { + errors.ssn = true; + } + + _.each(this.requiredFields, (requiredField) => { + if (ValidationUtils.isRequiredFulfilled(this.state[requiredField])) { + return; + } + + errors[requiredField] = true; + }); + + Wallet.setAdditionalDetailsErrors(errors); + return _.size(errors) === 0; + } + + activateWallet() { + if (!this.validate()) { + return; + } + + BankAccounts.activateWallet(CONST.WALLET.STEP.ADDITIONAL_DETAILS, { + personalDetails: this.state, + }); + } + + /** + * @param {String} fieldName + * @param {String} value + */ + clearErrorAndSetValue(fieldName, value) { + this.setState({[fieldName]: value}); + Wallet.updateAdditionalDetailsDraft({[fieldName]: value}); + const errors = lodashGet(this.props, 'walletAdditionalDetails.errorFields', {}); + if (!lodashGet(errors, fieldName, false)) { + return; + } + + const newErrors = lodashCloneDeep(errors); + lodashUnset(newErrors, fieldName); + Wallet.setAdditionalDetailsErrors(newErrors); + } + render() { - const errorFields = this.props.walletAdditionalDetails.errorFields || []; + const isErrorVisible = _.size(lodashGet(this.props, 'walletAdditionalDetails.errorFields', {})) > 0 + || lodashGet(this.props, 'walletAdditionalDetails.additionalErrorMessage', '').length > 0; return ( @@ -126,41 +189,83 @@ class AdditionalDetailsStep extends React.Component { {this.props.translate('additionalDetailsStep.helpLink')} - - {_.map(this.fields, field => ( - <> - this.setState({[field.fieldName]: val})} - value={this.state[field.fieldName]} - errorText={_.contains(errorFields, field.fieldName) - ? `${field.label} ${this.requiredText}` - : ''} - // eslint-disable-next-line react/jsx-props-no-spreading - {..._.omit(field, ['label', 'fieldName'])} + this.form = el}> + + this.clearErrorAndSetValue('legalFirstName', val)} + value={this.state.legalFirstName} + errorText={this.getErrorText('legalFirstName')} + /> + this.clearErrorAndSetValue('legalMiddleName', val)} + value={this.state.legalMiddleName} + /> + this.clearErrorAndSetValue('legalLastName', val)} + value={this.state.legalLastName} + errorText={this.getErrorText('legalLastName')} + /> + + { + if (fieldName === 'addressZipCode') { + this.clearErrorAndSetValue('addressZip', value); + return; + } + + this.clearErrorAndSetValue(fieldName, value); + }} + errorText={this.getErrorText('addressStreet')} /> - {field.fieldName === 'addressStreet' && {this.props.translate('common.noPO')}} - - ))} - - - {this.props.walletAdditionalDetails.additionalErrorMessage.length > 0 && ( - - {this.props.walletAdditionalDetails.additionalErrorMessage} - - )} - { - BankAccounts.activateWallet(CONST.WALLET.STEP.ADDITIONAL_DETAILS, { - personalDetails: this.state, - }); + + {this.props.translate('common.noPO')} + + + this.clearErrorAndSetValue('phoneNumber', val)} + value={this.state.phoneNumber} + errorText={this.getErrorText('phoneNumber')} + /> + this.clearErrorAndSetValue('dob', val)} + value={this.state.dob} + placeholder={this.props.translate('common.dob')} + errorText={this.getErrorText('dob')} + maximumDate={new Date()} + /> + this.clearErrorAndSetValue('ssn', val)} + value={this.state.ssn} + errorText={this.getErrorText('ssn')} + maxLength={4} + keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} + /> + + { + this.form.scrollTo({y: 0, animated: true}); }} + message={this.props.walletAdditionalDetails.additionalErrorMessage} + isLoading={this.props.walletAdditionalDetails.loading} + buttonText={this.props.translate('common.saveAndContinue')} /> - + @@ -177,5 +282,8 @@ export default compose( key: ONYXKEYS.WALLET_ADDITIONAL_DETAILS, initWithStoredValues: false, }, + walletAdditionalDetailsDraft: { + key: ONYXKEYS.WALLET_ADDITIONAL_DETAILS_DRAFT, + }, }), )(AdditionalDetailsStep);