diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md index cfcb76743312..53f2d87603a2 100644 --- a/contributingGuides/FORMS.md +++ b/contributingGuides/FORMS.md @@ -250,6 +250,15 @@ Form.js will automatically provide the following props to any input with the inp - onBlur: An onBlur handler that calls validate. - onInputChange: An onChange handler that saves draft values and calls validate for that input (inputA). Passing an inputID as a second param allows inputA to manipulate the input value of the provided inputID (inputB). +## Dynamic Form Inputs + +It's possible to conditionally render inputs (or more complex components with multiple inputs) inside a form. For example, an IdentityForm might be nested as input for a Form component. +In order for Form to track the nested values properly, each field must have a unique identifier. It's not safe to use an index because adding or removing fields from the child Form component will not update these internal keys. Therefore, we will need to define keys and dynamically access the correlating child form data for validation/submission. + +To generate these unique keys, use `Str.guid()`. + +An example of this can be seen in the [ACHContractStep](https://github.com/Expensify/App/blob/f2973f88cfc0d36c0dbe285201d3ed5e12f29d87/src/pages/ReimbursementAccount/ACHContractStep.js), where each key is stored in an array in state, and IdentityForms are dynamically rendered based on which keys are present in the array. + ### Safe Area Padding Any `Form.js` that has a button will also add safe area padding by default. If the `
` is inside a `` we will want to disable the default safe area padding applied there e.g. diff --git a/src/components/Form.js b/src/components/Form.js index 99270029f565..174011cbdc4f 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -31,7 +31,11 @@ const propTypes = { /** Callback to submit the form */ onSubmit: PropTypes.func.isRequired, - children: PropTypes.node.isRequired, + /** Children to render. */ + children: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.node, + ]).isRequired, /* Onyx Props */ @@ -95,7 +99,7 @@ class Form extends React.Component { this.state = { errors: {}, - inputValues: {}, + inputValues: props.draftValues, }; this.formRef = React.createRef(null); @@ -186,7 +190,7 @@ class Form extends React.Component { /** * Loops over Form's children and automatically supplies Form props to them * - * @param {Array} children - An array containing all Form children + * @param {Array | Function | Node} children - An array containing all Form children * @returns {React.Component} */ childrenWrapperWithProps(children) { @@ -280,7 +284,7 @@ class Form extends React.Component { render() { const scrollViewContent = safeAreaPaddingBottomStyle => ( - {this.childrenWrapperWithProps(this.props.children)} + {this.childrenWrapperWithProps(_.isFunction(this.props.children) ? this.props.children({inputValues: this.state.inputValues}) : this.props.children)} {this.props.isSubmitButtonVisible && ( formHelper.getErrors(props); -const clearError = (props, path) => formHelper.clearError(props, path); -const clearErrors = (props, paths) => formHelper.clearErrors(props, paths); /** * Get the default state for input fields in the VBA flow @@ -26,21 +15,7 @@ function getDefaultStateForField(reimbursementAccountDraft, reimbursementAccount || lodashGet(reimbursementAccount, ['achData', fieldName], defaultValue); } -/** - * @param {Object} props - * @param {Object} errorTranslationKeys - * @param {String} inputKey - * @returns {String} - */ -function getErrorText(props, errorTranslationKeys, inputKey) { - const errors = getErrors(props) || {}; - return errors[inputKey] ? props.translate(errorTranslationKeys[inputKey]) : ''; -} - export { + // eslint-disable-next-line import/prefer-default-export getDefaultStateForField, - getErrors, - clearError, - clearErrors, - getErrorText, }; diff --git a/src/pages/ReimbursementAccount/ACHContractStep.js b/src/pages/ReimbursementAccount/ACHContractStep.js index d86115e0a386..cda27a84ed04 100644 --- a/src/pages/ReimbursementAccount/ACHContractStep.js +++ b/src/pages/ReimbursementAccount/ACHContractStep.js @@ -3,6 +3,7 @@ import lodashGet from 'lodash/get'; import React from 'react'; import PropTypes from 'prop-types'; import {View} from 'react-native'; +import Str from 'expensify-common/lib/str'; import Text from '../../components/Text'; import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; import styles from '../../styles/styles'; @@ -14,8 +15,9 @@ import * as BankAccounts from '../../libs/actions/BankAccounts'; import Navigation from '../../libs/Navigation/Navigation'; import CONST from '../../CONST'; import * as ValidationUtils from '../../libs/ValidationUtils'; -import * as ReimbursementAccountUtils from '../../libs/ReimbursementAccountUtils'; -import ReimbursementAccountForm from './ReimbursementAccountForm'; +import ONYXKEYS from '../../ONYXKEYS'; +import Form from '../../components/Form'; +import * as FormActions from '../../libs/actions/FormActions'; import ScreenWrapper from '../../components/ScreenWrapper'; import StepPropTypes from './StepPropTypes'; @@ -29,135 +31,129 @@ const propTypes = { class ACHContractStep extends React.Component { constructor(props) { super(props); + this.validate = this.validate.bind(this); this.addBeneficialOwner = this.addBeneficialOwner.bind(this); this.submit = this.submit.bind(this); this.state = { - ownsMoreThan25Percent: props.getDefaultStateForField('ownsMoreThan25Percent', false), - hasOtherBeneficialOwners: props.getDefaultStateForField('hasOtherBeneficialOwners', false), - acceptTermsAndConditions: props.getDefaultStateForField('acceptTermsAndConditions', false), - certifyTrueInformation: props.getDefaultStateForField('certifyTrueInformation', false), + + // Array of strings containing the keys to render associated Identity Forms beneficialOwners: props.getDefaultStateForField('beneficialOwners', []), }; + } - // These fields need to be filled out in order to submit the form (doesn't include IdentityForm fields) - this.requiredFields = [ - 'acceptTermsAndConditions', - 'certifyTrueInformation', - ]; + /** + * @param {Object} values - input values passed by the Form component + * @returns {Object} + */ + validate(values) { + const errors = {}; - // Map a field to the key of the error's translation - this.errorTranslationKeys = { - acceptTermsAndConditions: 'common.error.acceptedTerms', - certifyTrueInformation: 'beneficialOwnersStep.error.certify', + const errorKeys = { + street: 'address', + city: 'addressCity', + state: 'addressState', }; + const requiredFields = ['firstName', 'lastName', 'dob', 'ssnLast4', 'street', 'city', 'zipCode', 'state']; + if (values.hasOtherBeneficialOwners) { + _.each(this.state.beneficialOwners, (ownerKey) => { + // eslint-disable-next-line rulesdir/prefer-early-return + _.each(requiredFields, (inputKey) => { + if (!ValidationUtils.isRequiredFulfilled(values[`beneficialOwner_${ownerKey}_${inputKey}`])) { + const errorKey = errorKeys[inputKey] || inputKey; + errors[`beneficialOwner_${ownerKey}_${inputKey}`] = this.props.translate(`bankAccount.error.${errorKey}`); + } + }); - 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); - } + if (values[`beneficialOwner_${ownerKey}_dob`] && !ValidationUtils.meetsAgeRequirements(values[`beneficialOwner_${ownerKey}_dob`])) { + errors[`beneficialOwner_${ownerKey}_dob`] = this.props.translate('bankAccount.error.age'); + } - /** - * @returns {Boolean} - */ - validate() { - let beneficialOwnersErrors = []; - if (this.state.hasOtherBeneficialOwners) { - beneficialOwnersErrors = _.map(this.state.beneficialOwners, ValidationUtils.validateIdentity); + if (values[`beneficialOwner_${ownerKey}_ssnLast4`] && !ValidationUtils.isValidSSNLastFour(values[`beneficialOwner_${ownerKey}_ssnLast4`])) { + errors[`beneficialOwner_${ownerKey}_ssnLast4`] = this.props.translate('bankAccount.error.ssnLast4'); + } + + if (values[`beneficialOwner_${ownerKey}_street`] && !ValidationUtils.isValidAddress(values[`beneficialOwner_${ownerKey}_street`])) { + errors[`beneficialOwner_${ownerKey}_street`] = this.props.translate('bankAccount.error.addressStreet'); + } + + if (values[`beneficialOwner_${ownerKey}_zipCode`] && !ValidationUtils.isValidZipCode(values[`beneficialOwner_${ownerKey}_zipCode`])) { + errors[`beneficialOwner_${ownerKey}_zipCode`] = this.props.translate('bankAccount.error.zipCode'); + } + }); } - const errors = {}; - _.each(this.requiredFields, (inputKey) => { - if (ValidationUtils.isRequiredFulfilled(this.state[inputKey])) { - return; - } + if (!ValidationUtils.isRequiredFulfilled(values.acceptTermsAndConditions)) { + errors.acceptTermsAndConditions = this.props.translate('common.error.acceptedTerms'); + } - errors[inputKey] = true; - }); - BankAccounts.setBankAccountFormValidationErrors({...errors, beneficialOwnersErrors}); - return _.every(beneficialOwnersErrors, _.isEmpty) && _.isEmpty(errors); + if (!ValidationUtils.isRequiredFulfilled(values.certifyTrueInformation)) { + errors.certifyTrueInformation = this.props.translate('beneficialOwnersStep.error.certify'); + } + + return errors; } - removeBeneficialOwner(beneficialOwner) { + /** + * @param {Number} ownerKey - ID connected to the beneficial owner identity form + */ + removeBeneficialOwner(ownerKey) { this.setState((prevState) => { - const beneficialOwners = _.without(prevState.beneficialOwners, beneficialOwner); + const beneficialOwners = _.without(prevState.beneficialOwners, ownerKey); - // We set 'beneficialOwners' to null first because we don't have a way yet to replace a specific property without merging it. - // We don't use the debounced function because we want to make both function calls. - BankAccounts.updateReimbursementAccountDraft({beneficialOwners: null}); - BankAccounts.updateReimbursementAccountDraft({beneficialOwners}); + FormActions.setDraftValues(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {beneficialOwners}); - // Clear errors - BankAccounts.setBankAccountFormValidationErrors({}); return {beneficialOwners}; }); } addBeneficialOwner() { - this.setState(prevState => ({beneficialOwners: [...prevState.beneficialOwners, {}]})); + this.setState((prevState) => { + // Each beneficial owner is assigned a unique key that will connect it to an Identity Form. + // That way we can dynamically render each Identity Form based on which keys are present in the beneficial owners array. + const beneficialOwners = [...prevState.beneficialOwners, Str.guid()]; + + FormActions.setDraftValues(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {beneficialOwners}); + return {beneficialOwners}; + }); } /** + * @param {Boolean} ownsMoreThan25Percent * @returns {Boolean} */ - canAddMoreBeneficialOwners() { + canAddMoreBeneficialOwners(ownsMoreThan25Percent) { return _.size(this.state.beneficialOwners) < 3 - || (_.size(this.state.beneficialOwners) === 3 && !this.state.ownsMoreThan25Percent); + || (_.size(this.state.beneficialOwners) === 3 && !ownsMoreThan25Percent); } /** - * Clear the error associated to inputKey if found and store the inputKey new value in the state. - * - * @param {Integer} ownerIndex - * @param {Object} values + * @param {Object} values - object containing form input values */ - clearErrorAndSetBeneficialOwnerValues(ownerIndex, values) { - this.setState((prevState) => { - const beneficialOwners = [...prevState.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 (_.contains(inputKeys, 'dob')) { - inputKeys.push('dobAge'); - } - this.clearErrors(_.map(inputKeys, inputKey => `beneficialOwnersErrors.${ownerIndex}.${inputKey}`)); - } - - submit() { - if (!this.validate()) { - return; - } - + submit(values) { const bankAccountID = lodashGet(this.props.reimbursementAccount, 'achData.bankAccountID') || 0; - // If they did not select that there are other beneficial owners, then we need to clear out the array here. The - // reason we do it here is that if they filled out several beneficial owners, but then toggled the checkbox, we - // want the data to remain in the form so we don't lose the user input until they submit the form. This will - // prevent the data from being sent to the API - this.setState(prevState => ({ - beneficialOwners: !prevState.hasOtherBeneficialOwners ? [] : prevState.beneficialOwners, - }), - () => BankAccounts.updateBeneficialOwnersForBankAccount({...this.state, beneficialOwners: JSON.stringify(this.state.beneficialOwners), bankAccountID})); - } + const beneficialOwners = !values.hasOtherBeneficialOwners ? [] + : _.map(this.state.beneficialOwners, ownerKey => ({ + firstName: lodashGet(values, `beneficialOwner_${ownerKey}_firstName`), + lastName: lodashGet(values, `beneficialOwner_${ownerKey}_lastName`), + dob: lodashGet(values, `beneficialOwner_${ownerKey}_dob`), + ssnLast4: lodashGet(values, `beneficialOwner_${ownerKey}_ssnLast4`), + street: lodashGet(values, `beneficialOwner_${ownerKey}_street`), + city: lodashGet(values, `beneficialOwner_${ownerKey}_city`), + state: lodashGet(values, `beneficialOwner_${ownerKey}_state`), + zipCode: lodashGet(values, `beneficialOwner_${ownerKey}_zipCode`), + })); - /** - * @param {Object} fieldName - */ - toggleCheckbox(fieldName) { - this.setState((prevState) => { - const newState = {[fieldName]: !prevState[fieldName]}; - BankAccounts.updateReimbursementAccountDraft(newState); - return newState; + BankAccounts.updateBeneficialOwnersForBankAccount({ + ownsMoreThan25Percent: values.ownsMoreThan25Percent, + hasOtherBeneficialOwners: values.hasOtherBeneficialOwners, + acceptTermsAndConditions: values.acceptTermsAndConditions, + certifyTrueInformation: values.certifyTrueInformation, + beneficialOwners: JSON.stringify(beneficialOwners), + bankAccountID, }); - this.clearError(fieldName); } render() { @@ -172,113 +168,132 @@ class ACHContractStep extends React.Component { guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_BANK_ACCOUNT} shouldShowBackButton /> - - - {this.props.translate('beneficialOwnersStep.checkAllThatApply')} - - this.toggleCheckbox('ownsMoreThan25Percent')} - LabelComponent={() => ( - - {this.props.translate('beneficialOwnersStep.iOwnMoreThan25Percent')} - {this.props.companyName} - - )} - /> - { - this.setState((prevState) => { - const hasOtherBeneficialOwners = !prevState.hasOtherBeneficialOwners; - const newState = { - hasOtherBeneficialOwners, - beneficialOwners: hasOtherBeneficialOwners && _.isEmpty(prevState.beneficialOwners) - ? [{}] - : prevState.beneficialOwners, - }; - BankAccounts.updateReimbursementAccountDraft(newState); - return newState; - }); - }} - LabelComponent={() => ( - - {this.props.translate('beneficialOwnersStep.someoneOwnsMoreThan25Percent')} - {this.props.companyName} + {({inputValues}) => ( + <> + + {this.props.translate('beneficialOwnersStep.checkAllThatApply')} - )} - /> - {this.state.hasOtherBeneficialOwners && ( - - {_.map(this.state.beneficialOwners, (owner, index) => ( - - - {this.props.translate('beneficialOwnersStep.additionalOwner')} + ( + + {this.props.translate('beneficialOwnersStep.iOwnMoreThan25Percent')} + {this.props.companyName} - this.clearErrorAndSetBeneficialOwnerValues(index, values)} - values={{ - firstName: owner.firstName || '', - lastName: owner.lastName || '', - street: owner.street || '', - city: owner.city || '', - state: owner.state || '', - zipCode: owner.zipCode || '', - dob: owner.dob || '', - ssnLast4: owner.ssnLast4 || '', - }} - errors={lodashGet(this.getErrors(), `beneficialOwnersErrors[${index}]`, {})} - /> - {this.state.beneficialOwners.length > 1 && ( - this.removeBeneficialOwner(owner)}> - {this.props.translate('beneficialOwnersStep.removeOwner')} + )} + // eslint-disable-next-line rulesdir/prefer-early-return + onValueChange={(ownsMoreThan25Percent) => { + if (ownsMoreThan25Percent && this.state.beneficialOwners.length > 3) { + // If the user owns more than 25% of the company, then there can only be a maximum of 3 other beneficial owners who owns more than 25%. + // We have to remove the 4th beneficial owner if the checkbox is checked. + this.setState(prevState => ({beneficialOwners: prevState.beneficialOwners.slice(0, -1)})); + } + }} + defaultValue={this.props.getDefaultStateForField('ownsMoreThan25Percent', false)} + shouldSaveDraft + /> + ( + + {this.props.translate('beneficialOwnersStep.someoneOwnsMoreThan25Percent')} + {this.props.companyName} + + )} + // eslint-disable-next-line rulesdir/prefer-early-return + onValueChange={(hasOtherBeneficialOwners) => { + if (hasOtherBeneficialOwners && this.state.beneficialOwners.length === 0) { + this.addBeneficialOwner(); + } + }} + defaultValue={this.props.getDefaultStateForField('hasOtherBeneficialOwners', false)} + shouldSaveDraft + /> + {inputValues.hasOtherBeneficialOwners && ( + + {_.map(this.state.beneficialOwners, (ownerKey, index) => ( + + + {this.props.translate('beneficialOwnersStep.additionalOwner')} + + + {this.state.beneficialOwners.length > 1 && ( + this.removeBeneficialOwner(ownerKey)}> + {this.props.translate('beneficialOwnersStep.removeOwner')} + + )} + + ))} + {this.canAddMoreBeneficialOwners(inputValues.ownsMoreThan25Percent) && ( + + {this.props.translate('beneficialOwnersStep.addAnotherIndividual')} + {this.props.companyName} )} - ))} - {this.canAddMoreBeneficialOwners() && ( - - {this.props.translate('beneficialOwnersStep.addAnotherIndividual')} - {this.props.companyName} - )} - - )} - - {this.props.translate('beneficialOwnersStep.agreement')} - - this.toggleCheckbox('acceptTermsAndConditions')} - LabelComponent={() => ( - - {this.props.translate('common.iAcceptThe')} - - {`${this.props.translate('beneficialOwnersStep.termsAndConditions')}`} - + + {this.props.translate('beneficialOwnersStep.agreement')} - )} - errorText={this.getErrorText('acceptTermsAndConditions')} - hasError={this.getErrors().acceptTermsAndConditions} - /> - this.toggleCheckbox('certifyTrueInformation')} - LabelComponent={() => ( - {this.props.translate('beneficialOwnersStep.certifyTrueAndAccurate')} - )} - errorText={this.getErrorText('certifyTrueInformation')} - /> - + ( + + {this.props.translate('common.iAcceptThe')} + + {`${this.props.translate('beneficialOwnersStep.termsAndConditions')}`} + + + )} + defaultValue={this.props.getDefaultStateForField('acceptTermsAndConditions', false)} + shouldSaveDraft + /> + ( + {this.props.translate('beneficialOwnersStep.certifyTrueAndAccurate')} + )} + defaultValue={this.props.getDefaultStateForField('certifyTrueInformation', false)} + shouldSaveDraft + /> + + )} +
); } diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes.js b/src/pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes.js index b37186e8c136..04cf3b3b761e 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes.js +++ b/src/pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes.js @@ -41,16 +41,5 @@ export default PropTypes.shape({ hasOtherBeneficialOwners: PropTypes.bool, acceptTermsAndConditions: PropTypes.bool, certifyTrueInformation: PropTypes.bool, - beneficialOwners: PropTypes.arrayOf( - PropTypes.shape({ - firstName: PropTypes.string, - lastName: PropTypes.string, - street: PropTypes.string, - city: PropTypes.string, - state: PropTypes.string, - zipCode: PropTypes.string, - dob: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), - ssnLast4: PropTypes.string, - }), - ), + beneficialOwners: PropTypes.arrayOf(PropTypes.string), });