Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
f5c7bef
create form and actions
luacmartins Dec 14, 2021
5d25971
refactor BankAccountStep and ExpensiTextInput
luacmartins Dec 15, 2021
5fdb65a
add onsubmit and other handlers
luacmartins Dec 16, 2021
6ac6d0e
save progress
luacmartins Dec 16, 2021
d89b170
fix props usage in ExpensifyButton and ExpensiTextInput
luacmartins Dec 16, 2021
5847a35
read static property for wrapped component
luacmartins Dec 16, 2021
561de29
add serverError scroll
luacmartins Dec 16, 2021
29dbaf0
merge main
luacmartins Dec 28, 2021
0abd923
move serverError to errors
luacmartins Dec 29, 2021
a29c1fb
Merge branch 'main' into cmartins-form
luacmartins Dec 29, 2021
bbc0d73
update usage of ExpensifyButton and ExpensifyText
luacmartins Dec 29, 2021
5f7c3cc
update alerts
luacmartins Dec 29, 2021
31a9087
refactor submit to set alerts
luacmartins Dec 29, 2021
c8269e2
update expensiformsubmit prop types
luacmartins Dec 29, 2021
0eb5cc4
remove default values from state in expensiform
luacmartins Dec 29, 2021
daeb792
remove createRef, rename static property, add server error handling
luacmartins Jan 3, 2022
e804028
rename submit component
luacmartins Jan 3, 2022
dd1c04a
add EXPENSIFORM_SUBMIT_INPUT static propery
luacmartins Jan 3, 2022
047a2c5
fix tests
luacmartins Jan 4, 2022
1615b8d
move alert and submit component
luacmartins Jan 5, 2022
d5ac517
revert changes
luacmartins Jan 10, 2022
9441dc1
finish reverting changes
luacmartins Jan 10, 2022
38f3279
rm navigation
luacmartins Jan 10, 2022
d66295f
create ExpensiFormActions
luacmartins Jan 10, 2022
6d643be
refactor ExpensiForm
luacmartins Jan 10, 2022
d409e29
remove ExpensiFormFormAlertWithSubmitButton
luacmartins Jan 10, 2022
4ac4b56
fix style
luacmartins Jan 10, 2022
bc78349
rename ExpensiForm
luacmartins Jan 11, 2022
489056b
rename submitButtonText and comments
luacmartins Jan 12, 2022
700e7de
Merge branch 'main' into cmartins-form
luacmartins Jan 24, 2022
c9a7c1d
rename formName to formID in FormActions
luacmartins Jan 24, 2022
a8074ef
rename onSubmit to submit, rename key to inputID and remove getErrorT…
luacmartins Jan 24, 2022
1f87b76
add class method docs
luacmartins Jan 24, 2022
58c278d
update comments and add child onBlur handler
luacmartins Jan 24, 2022
78a8256
remove weird import
luacmartins Jan 24, 2022
bfd6926
move submit and add propTypes to TextInput
luacmartins Jan 24, 2022
b9fc378
refactor TextInput to use Forms interface
luacmartins Jan 24, 2022
4791aa8
fix TextInput propType for inputID
luacmartins Jan 25, 2022
8aaf7c6
update error message for inputID prop
luacmartins Jan 25, 2022
eca774a
create Form story
luacmartins Jan 25, 2022
d6d94a7
fix styles
luacmartins Jan 25, 2022
1bd951c
fix value and defaultValue in BaseTextInput
luacmartins Jan 26, 2022
5753b18
expose value to input ref in native
luacmartins Jan 26, 2022
4339f6e
move native ref value to Form component
luacmartins Jan 26, 2022
de3dcf1
remove console logs
luacmartins Jan 26, 2022
2afa829
remove changes to BankAccountStep, add more Form stories and fix vali…
luacmartins Jan 26, 2022
1d0ce6f
fix submit class method
luacmartins Jan 26, 2022
0295840
add validate and submit logic to stories
luacmartins Jan 26, 2022
45d6aaf
fix form style
luacmartins Jan 26, 2022
c3cf1a4
add comment to stories
luacmartins Jan 26, 2022
ff5e498
remove podfile.lock changes
luacmartins Jan 26, 2022
6b860bc
rename previous Form component to SignInPageForm
luacmartins Jan 26, 2022
0c7e37f
fix typo in comments and story args
luacmartins Jan 27, 2022
253f5d5
add docs to setTouchedInput, rm _.keys usage and child onChange, fix …
luacmartins Jan 27, 2022
ee401db
crate getInputIDPropTypes util function
luacmartins Jan 27, 2022
5763970
fix defaultValue logic, add draftValue docs and examples to validate …
luacmartins Jan 27, 2022
06b9559
add validation for validationErrors
luacmartins Jan 28, 2022
51af475
move childrenWrapperWithProps out of render
luacmartins Jan 28, 2022
8d073e4
fix error with childrenWrapperWithProps declaration
luacmartins Jan 28, 2022
f4202d1
move native ref.value to TextInput
luacmartins Jan 28, 2022
4c31d29
fix js style
luacmartins Jan 28, 2022
cf7a5fb
disable param reassign lint rule
luacmartins Jan 28, 2022
73a44ee
fix style
luacmartins Jan 28, 2022
9685fbf
revert previous changes
luacmartins Jan 31, 2022
91b0de0
rm assigning input value in render
luacmartins Jan 31, 2022
98ab01e
fix onyx race condition on submit
luacmartins Jan 31, 2022
032683a
revert race condition changes
luacmartins Jan 31, 2022
76abec7
Merge branch 'main' into cmartins-form
luacmartins Jan 31, 2022
3de3c37
remove eslint comment
luacmartins Jan 31, 2022
fd38d13
fix onSubmit handler
luacmartins Jan 31, 2022
25fed0e
add js docs, rm onBlur handler, rm touched input check in onChange, a…
luacmartins Feb 1, 2022
ecf7677
rm getValues, add inputValues, update references to inputRefs.value
luacmartins Feb 1, 2022
4c48227
fix jslint
luacmartins Feb 1, 2022
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
209 changes: 209 additions & 0 deletions src/components/Form.js
Original file line number Diff line number Diff line change
@@ -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 <Text> 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 (
<>
<ScrollView
style={[styles.w100, styles.flex1]}
contentContainerStyle={styles.flexGrow1}
keyboardShouldPersistTaps="handled"
>
<View style={[this.props.style]}>
{this.childrenWrapperWithProps(this.props.children)}
<FormAlertWithSubmitButton
buttonText={this.props.submitButtonText}
isAlertVisible={_.size(this.state.errors) > 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]}
/>
</View>
</ScrollView>
</>
);
}
}

Form.propTypes = propTypes;
Form.defaultProps = defaultProps;

export default compose(
withLocalize,
withOnyx({
formState: {
key: props => props.formID,
},
draftValues: {
key: props => `${props.formID}DraftValues`,
},
}),
)(Form);
File renamed without changes.
8 changes: 6 additions & 2 deletions src/components/TextInput/BaseTextInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 ? (
Expand All @@ -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"
Expand Down
28 changes: 25 additions & 3 deletions src/components/TextInput/baseTextInputPropTypes.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import PropTypes from 'prop-types';
import * as FormUtils from '../../libs/FormUtils';

const propTypes = {
/** Input label */
Expand All @@ -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),

Expand All @@ -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: '',
Expand All @@ -52,6 +71,9 @@ const defaultProps = {
value: undefined,
defaultValue: undefined,
forceActiveLabel: false,
shouldSaveDraft: false,
maxLength: undefined,
hint: '',
};

export {propTypes, defaultProps};
22 changes: 22 additions & 0 deletions src/libs/FormUtils.js
Original file line number Diff line number Diff line change
@@ -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,
};
31 changes: 31 additions & 0 deletions src/libs/actions/FormActions.js
Original file line number Diff line number Diff line change
@@ -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,
};
6 changes: 3 additions & 3 deletions src/pages/signin/SignInPageLayout/SignInPageContent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -45,7 +45,7 @@ const SignInPageContent = props => (
!props.isSmallScreenWidth && styles.ph6,
]}
>
<Form style={[
<SignInPageForm style={[
styles.flex1,
styles.alignSelfStretch,
props.isSmallScreenWidth && styles.signInPageNarrowContentContainer,
Expand Down Expand Up @@ -81,7 +81,7 @@ const SignInPageContent = props => (
)}
{props.children}
</LoginKeyboardAvoidingView>
</Form>
</SignInPageForm>
<View style={[styles.mb5, styles.alignSelfCenter, props.isSmallScreenWidth && styles.signInPageNarrowContentContainer, styles.ph5]}>
<TermsAndLicenses />
</View>
Expand Down
Loading