diff --git a/src/components/Form.js b/src/components/Form.js new file mode 100644 index 000000000000..c169113f8097 --- /dev/null +++ b/src/components/Form.js @@ -0,0 +1,209 @@ +import React from 'react'; +import {ScrollView, View} from 'react-native'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import {withOnyx} from 'react-native-onyx'; +import compose from '../libs/compose'; +import withLocalize, {withLocalizePropTypes} from './withLocalize'; +import * as FormActions from '../libs/actions/FormActions'; +import styles from '../styles/styles'; +import FormAlertWithSubmitButton from './FormAlertWithSubmitButton'; + +const propTypes = { + /** A unique Onyx key identifying the form */ + formID: PropTypes.string.isRequired, + + /** Text to be displayed in the submit button */ + submitButtonText: PropTypes.string.isRequired, + + /** Callback to validate the form */ + validate: PropTypes.func.isRequired, + + /** Callback to submit the form */ + onSubmit: PropTypes.func.isRequired, + + children: PropTypes.node.isRequired, + + /* Onyx Props */ + + /** Contains the form state that must be accessed outside of the component */ + formState: PropTypes.shape({ + + /** Controls the loading state of the form */ + isSubmitting: PropTypes.bool, + + /** Server side error message */ + serverErrorMessage: PropTypes.string, + }), + + /** Contains draft values for each input in the form */ + // eslint-disable-next-line react/forbid-prop-types + draftValues: PropTypes.object, + + ...withLocalizePropTypes, +}; + +const defaultProps = { + formState: { + isSubmitting: false, + serverErrorMessage: '', + }, + draftValues: {}, +}; + +class Form extends React.Component { + constructor(props) { + super(props); + + this.state = { + errors: {}, + }; + + this.inputRefs = {}; + this.inputValues = {}; + this.touchedInputs = {}; + + this.setTouchedInput = this.setTouchedInput.bind(this); + this.validate = this.validate.bind(this); + this.submit = this.submit.bind(this); + } + + /** + * @param {String} inputID - The inputID of the input being touched + */ + setTouchedInput(inputID) { + this.touchedInputs[inputID] = true; + } + + submit() { + // Return early if the form is already submitting to avoid duplicate submission + if (this.props.formState.isSubmitting) { + return; + } + + // Touches all form inputs so we can validate the entire form + _.each(this.inputRefs, (inputRef, inputID) => ( + this.touchedInputs[inputID] = true + )); + + // Validate form and return early if any errors are found + if (!_.isEmpty(this.validate(this.inputValues))) { + return; + } + + // Set loading state and call submit handler + FormActions.setIsSubmitting(this.props.formID, true); + this.props.onSubmit(this.inputValues); + } + + /** + * @param {Object} values - An object containing the value of each inputID, e.g. {inputID1: value1, inputID2: value2} + * @returns {Object} - An object containing the errors for each inputID, e.g. {inputID1: error1, inputID2: error2} + */ + validate(values) { + FormActions.setServerErrorMessage(this.props.formID, ''); + const validationErrors = this.props.validate(values); + + if (!_.isObject(validationErrors)) { + throw new Error('Validate callback must return an empty object or an object with shape {inputID: error}'); + } + + const errors = _.pick(validationErrors, (inputValue, inputID) => ( + Boolean(this.touchedInputs[inputID]) + )); + this.setState({errors}); + return errors; + } + + /** + * Loops over Form's children and automatically supplies Form props to them + * + * @param {Array} children - An array containing all Form children + * @returns {React.Component} + */ + childrenWrapperWithProps(children) { + return React.Children.map(children, (child) => { + // Just render the child if it is not a valid React element, e.g. text within a component + if (!React.isValidElement(child)) { + return child; + } + + // Depth first traversal of the render tree as the input element is likely to be the last node + if (child.props.children) { + return React.cloneElement(child, { + children: this.childrenWrapperWithProps(child.props.children), + }); + } + + // We check if the child has the isFormInput prop. + // We don't want to pass form props to non form components, e.g. View, Text, etc + if (!child.props.isFormInput) { + return child; + } + + // We clone the child passing down all form props + const inputID = child.props.inputID; + const defaultValue = this.props.draftValues[inputID] || child.props.defaultValue; + this.inputValues[inputID] = defaultValue; + + return React.cloneElement(child, { + ref: node => this.inputRefs[inputID] = node, + defaultValue, + errorText: this.state.errors[inputID] || '', + onBlur: () => { + this.setTouchedInput(inputID); + this.validate(this.inputValues); + }, + onChange: (value) => { + this.inputValues[inputID] = value; + if (child.props.shouldSaveDraft) { + FormActions.setDraftValues(this.props.formID, {[inputID]: value}); + } + this.validate(this.inputValues); + }, + }); + }); + } + + render() { + return ( + <> + + + {this.childrenWrapperWithProps(this.props.children)} + 0 || Boolean(this.props.formState.serverErrorMessage)} + isLoading={this.props.formState.isSubmitting} + message={this.props.formState.serverErrorMessage} + onSubmit={this.submit} + onFixTheErrorsLinkPressed={() => { + this.inputRefs[_.first(_.keys(this.state.errors))].focus(); + }} + containerStyles={[styles.mh0, styles.mt5]} + /> + + + + ); + } +} + +Form.propTypes = propTypes; +Form.defaultProps = defaultProps; + +export default compose( + withLocalize, + withOnyx({ + formState: { + key: props => props.formID, + }, + draftValues: { + key: props => `${props.formID}DraftValues`, + }, + }), +)(Form); diff --git a/src/components/Form/BaseForm.js b/src/components/SignInPageForm/BaseForm.js similarity index 100% rename from src/components/Form/BaseForm.js rename to src/components/SignInPageForm/BaseForm.js diff --git a/src/components/Form/index.js b/src/components/SignInPageForm/index.js similarity index 100% rename from src/components/Form/index.js rename to src/components/SignInPageForm/index.js diff --git a/src/components/Form/index.native.js b/src/components/SignInPageForm/index.native.js similarity index 100% rename from src/components/Form/index.native.js rename to src/components/SignInPageForm/index.native.js diff --git a/src/components/TextInput/BaseTextInput.js b/src/components/TextInput/BaseTextInput.js index c95856b0bc75..c4a2693b5d44 100644 --- a/src/components/TextInput/BaseTextInput.js +++ b/src/components/TextInput/BaseTextInput.js @@ -94,6 +94,9 @@ class BaseTextInput extends Component { * @memberof BaseTextInput */ setValue(value) { + if (this.props.onChange) { + this.props.onChange(value); + } this.value = value; Str.result(this.props.onChangeText, value); this.activateLabel(); @@ -156,7 +159,7 @@ class BaseTextInput extends Component { style={[ styles.textInputContainer, this.state.isFocused && styles.borderColorFocus, - (this.props.hasError || this.props.errorText) && styles.borderColorDanger, + this.props.errorText && styles.borderColorDanger, ]} > {hasLabel ? ( @@ -180,7 +183,8 @@ class BaseTextInput extends Component { }} // eslint-disable-next-line {...inputProps} - value={this.value} + value={this.props.isFormInput ? undefined : this.value} + defaultValue={this.props.defaultValue} placeholder={(this.state.isFocused || !this.props.label) ? this.props.placeholder : null} placeholderTextColor={themeColors.placeholderText} underlineColorAndroid="transparent" diff --git a/src/components/TextInput/baseTextInputPropTypes.js b/src/components/TextInput/baseTextInputPropTypes.js index e37163e923ec..aa4c9156e4ab 100644 --- a/src/components/TextInput/baseTextInputPropTypes.js +++ b/src/components/TextInput/baseTextInputPropTypes.js @@ -1,4 +1,5 @@ import PropTypes from 'prop-types'; +import * as FormUtils from '../../libs/FormUtils'; const propTypes = { /** Input label */ @@ -19,9 +20,6 @@ const propTypes = { /** Error text to display */ errorText: PropTypes.string, - /** Should the input be styled for errors */ - hasError: PropTypes.bool, - /** Customize the TextInput container */ containerStyles: PropTypes.arrayOf(PropTypes.object), @@ -33,9 +31,30 @@ const propTypes = { /** Should the input auto focus? */ autoFocus: PropTypes.bool, + + /** Indicates that the input is being used with the Form component */ + isFormInput: PropTypes.bool, + + /** + * The ID used to uniquely identify the input + * + * @param {Object} props - props passed to the input + * @returns {Object} - returns an Error object if isFormInput is supplied but inputID is falsey or not a string + */ + inputID: props => FormUtils.getInputIDPropTypes(props), + + /** Saves a draft of the input value when used in a form */ + shouldSaveDraft: PropTypes.bool, + + /** Character limit for the input */ + maxLength: PropTypes.number, + + /** Hint microcopy to be displayed under the input */ + hint: PropTypes.string, }; const defaultProps = { + isFormInput: false, label: '', name: '', errorText: '', @@ -52,6 +71,9 @@ const defaultProps = { value: undefined, defaultValue: undefined, forceActiveLabel: false, + shouldSaveDraft: false, + maxLength: undefined, + hint: '', }; export {propTypes, defaultProps}; diff --git a/src/libs/FormUtils.js b/src/libs/FormUtils.js new file mode 100644 index 000000000000..c9a3ec49546b --- /dev/null +++ b/src/libs/FormUtils.js @@ -0,0 +1,22 @@ +/** + * Gets the prop type for inputID + * + * @param {Object} props - props passed to the input component + * @returns {Object} returns an Error object if isFormInput is supplied but inputID is falsey or not a string + */ +function getInputIDPropTypes(props) { + if (!props.isFormInput) { + return; + } + if (!props.inputID) { + return new Error('InputID is required if isFormInput prop is supplied.'); + } + if (typeof props.inputID !== 'string') { + return new Error(`Invalid prop type ${typeof props.inputID} supplied to inputID. Expecting string.`); + } +} + +export { + // eslint-disable-next-line import/prefer-default-export + getInputIDPropTypes, +}; diff --git a/src/libs/actions/FormActions.js b/src/libs/actions/FormActions.js new file mode 100644 index 000000000000..1ae5b95a803a --- /dev/null +++ b/src/libs/actions/FormActions.js @@ -0,0 +1,31 @@ +import Onyx from 'react-native-onyx'; + +/** + * @param {String} formID + * @param {Boolean} isSubmitting + */ +function setIsSubmitting(formID, isSubmitting) { + Onyx.merge(formID, {isSubmitting}); +} + +/** + * @param {String} formID + * @param {Boolean} serverErrorMessage + */ +function setServerErrorMessage(formID, serverErrorMessage) { + Onyx.merge(formID, {serverErrorMessage}); +} + +/** + * @param {String} formID + * @param {Object} draftValues + */ +function setDraftValues(formID, draftValues) { + Onyx.merge(`${formID}DraftValues`, draftValues); +} + +export { + setIsSubmitting, + setServerErrorMessage, + setDraftValues, +}; diff --git a/src/pages/signin/SignInPageLayout/SignInPageContent.js b/src/pages/signin/SignInPageLayout/SignInPageContent.js index 6d87740e637c..f787d197fe7d 100755 --- a/src/pages/signin/SignInPageLayout/SignInPageContent.js +++ b/src/pages/signin/SignInPageLayout/SignInPageContent.js @@ -9,7 +9,7 @@ import ExpensifyCashLogo from '../../../components/ExpensifyCashLogo'; import Text from '../../../components/Text'; import TermsAndLicenses from '../TermsAndLicenses'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; -import Form from '../../../components/Form'; +import SignInPageForm from '../../../components/SignInPageForm'; import compose from '../../../libs/compose'; import scrollViewContentContainerStyles from './signInPageStyles'; import LoginKeyboardAvoidingView from './LoginKeyboardAvoidingView'; @@ -45,7 +45,7 @@ const SignInPageContent = props => ( !props.isSmallScreenWidth && styles.ph6, ]} > -
( )} {props.children} -
+ diff --git a/src/stories/Form.stories.js b/src/stories/Form.stories.js new file mode 100644 index 000000000000..f8b0a9b8cbe3 --- /dev/null +++ b/src/stories/Form.stories.js @@ -0,0 +1,93 @@ +import React from 'react'; +import {View} from 'react-native'; +import TextInput from '../components/TextInput'; +import Form from '../components/Form'; +import * as FormActions from '../libs/actions/FormActions'; +import styles from '../styles/styles'; + +/** + * We use the Component Story Format for writing stories. Follow the docs here: + * + * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format + */ +const story = { + title: 'Components/Form', + component: Form, + subcomponents: {TextInput}, +}; + +const Template = (args) => { + // Form consumes data from Onyx, so we initialize Onyx with the necessary data here + FormActions.setIsSubmitting(args.formID, args.formState.isSubmitting); + FormActions.setServerErrorMessage(args.formID, args.formState.serverErrorMessage); + FormActions.setDraftValues(args.formID, args.draftValues); + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading +
+ + + + + + ); +}; + +// Arguments can be passed to the component by binding +// See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args +const Default = Template.bind({}); +const Loading = Template.bind({}); +const ServerError = Template.bind({}); +const InputError = Template.bind({}); + +const defaultArgs = { + formID: 'TestForm', + submitButtonText: 'Submit', + validate: (values) => { + const errors = {}; + if (!values.routingNumber) { + errors.routingNumber = 'Please enter a routing number'; + } + if (!values.accountNumber) { + errors.accountNumber = 'Please enter an account number'; + } + return errors; + }, + onSubmit: (values) => { + setTimeout(() => { + alert(`Form submitted!\n\nInput values: ${JSON.stringify(values, null, 4)}`); + FormActions.setIsSubmitting('TestForm', false); + }, 1000); + }, + formState: { + isSubmitting: false, + serverErrorMessage: '', + }, + draftValues: { + routingNumber: '00001', + accountNumber: '1111222233331111', + }, +}; + +Default.args = defaultArgs; +Loading.args = {...defaultArgs, formState: {isSubmitting: true}}; +ServerError.args = {...defaultArgs, formState: {isSubmitting: false, serverErrorMessage: 'There was an unexpected error. Please try again later.'}}; +InputError.args = {...defaultArgs, draftValues: {routingNumber: '', accountNumber: ''}}; + +export default story; +export { + Default, + Loading, + ServerError, + InputError, +}; diff --git a/src/styles/utilities/spacing.js b/src/styles/utilities/spacing.js index db54697f16b0..bb3ada935d3c 100644 --- a/src/styles/utilities/spacing.js +++ b/src/styles/utilities/spacing.js @@ -21,6 +21,10 @@ export default { margin: 20, }, + mh0: { + marginHorizontal: 0, + }, + mh1: { marginHorizontal: 4, },