diff --git a/src/components/Form/FormContext.js b/src/components/Form/FormContext.js new file mode 100644 index 000000000000..40edaa7cca69 --- /dev/null +++ b/src/components/Form/FormContext.js @@ -0,0 +1,4 @@ +import {createContext} from 'react'; + +const FormContext = createContext({}); +export default FormContext; diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js new file mode 100644 index 000000000000..408f8c2c2b7f --- /dev/null +++ b/src/components/Form/FormProvider.js @@ -0,0 +1,260 @@ +import React, {createRef, useCallback, useMemo, useRef, useState} from 'react'; +import _ from 'underscore'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import lodashGet from 'lodash/get'; +import Visibility from '../../libs/Visibility'; +import * as FormActions from '../../libs/actions/FormActions'; +import FormContext from './FormContext'; +import FormWrapper from './FormWrapper'; +import compose from '../../libs/compose'; +import {withNetwork} from '../OnyxProvider'; +import stylePropTypes from '../../styles/stylePropTypes'; +import networkPropTypes from '../networkPropTypes'; + +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, + + /** Controls the submit button's visibility */ + isSubmitButtonVisible: PropTypes.bool, + + /** Callback to validate the form */ + validate: PropTypes.func, + + /** Callback to submit the form */ + onSubmit: PropTypes.func.isRequired, + + /** Children to render. */ + children: PropTypes.oneOfType([PropTypes.func, 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 */ + isLoading: PropTypes.bool, + + /** Server side errors keyed by microtime */ + errors: PropTypes.objectOf(PropTypes.string), + + /** Field-specific server side errors keyed by microtime */ + errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + }), + + /** Should the button be enabled when offline */ + enabledWhenOffline: PropTypes.bool, + + /** Whether the form submit action is dangerous */ + isSubmitActionDangerous: PropTypes.bool, + + /** Whether ScrollWithContext should be used instead of regular ScrollView. + * Set to true when there's a nested Picker component in Form. + */ + scrollContextEnabled: PropTypes.bool, + + /** Container styles */ + style: stylePropTypes, + + /** Custom content to display in the footer after submit button */ + footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), + + /** Information about the network */ + network: networkPropTypes.isRequired, + + shouldValidateOnBlur: PropTypes.bool, + + shouldValidateOnChange: PropTypes.bool, +}; + +const defaultProps = { + isSubmitButtonVisible: true, + formState: { + isLoading: false, + }, + enabledWhenOffline: false, + isSubmitActionDangerous: false, + scrollContextEnabled: false, + footerContent: null, + style: [], + validate: () => {}, + shouldValidateOnBlur: true, + shouldValidateOnChange: true, +}; + +function getInitialValueByType(valueType) { + switch (valueType) { + case 'string': + return ''; + case 'boolean': + return false; + case 'date': + return new Date(); + default: + return ''; + } +} + +function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, onSubmit, ...rest}) { + const inputRefs = useRef(null); + const touchedInputs = useRef({}); + const [inputValues, setInputValues] = useState({}); + const [errors, setErrors] = useState({}); + + const onValidate = useCallback( + (values) => { + const validateErrors = validate(values); + setErrors(validateErrors); + }, + [validate], + ); + + /** + * @param {String} inputID - The inputID of the input being touched + */ + const setTouchedInput = useCallback( + (inputID) => { + touchedInputs.current[inputID] = true; + }, + [touchedInputs], + ); + + const submit = useCallback(() => { + // Return early if the form is already submitting to avoid duplicate submission + if (formState.isLoading) { + return; + } + + // Touches all form inputs so we can validate the entire form + _.each(inputRefs.current, (inputRef, inputID) => (touchedInputs.current[inputID] = true)); + + // Validate form and return early if any errors are found + if (!_.isEmpty(onValidate(inputValues))) { + return; + } + + // Do not submit form if network is offline and the form is not enabled when offline + if (network.isOffline && !enabledWhenOffline) { + return; + } + + onSubmit(inputValues); + }, [enabledWhenOffline, formState.isLoading, inputValues, network.isOffline, onSubmit, onValidate]); + + const registerInput = useCallback( + (inputID, propsToParse = {}) => { + const newRef = propsToParse.ref || createRef(); + inputRefs[inputID] = newRef; + + if (!_.isUndefined(propsToParse.value)) { + inputValues[inputID] = propsToParse.value; + } else if (propsToParse.shouldUseDefaultValue) { + // We force the form to set the input value from the defaultValue props if there is a saved valid value + inputValues[inputID] = propsToParse.defaultValue; + } else if (_.isUndefined(inputValues[inputID])) { + // We want to initialize the input value if it's undefined + inputValues[inputID] = _.isUndefined(propsToParse.defaultValue) ? getInitialValueByType(propsToParse.valueType) : propsToParse.defaultValue; + } + + const errorFields = lodashGet(formState, 'errorFields', {}); + const fieldErrorMessage = + _.chain(errorFields[inputID]) + .keys() + .sortBy() + .reverse() + .map((key) => errorFields[inputID][key]) + .first() + .value() || ''; + + return { + ...propsToParse, + ref: newRef, + inputID, + key: propsToParse.key || inputID, + errorText: errors[inputID] || fieldErrorMessage, + value: inputValues[inputID], + // As the text input is controlled, we never set the defaultValue prop + // as this is already happening by the value prop. + defaultValue: undefined, + onTouched: (event) => { + setTouchedInput(inputID); + if (_.isFunction(propsToParse.onTouched)) { + propsToParse.onTouched(event); + } + }, + onBlur: (event) => { + // Only run validation when user proactively blurs the input. + if (Visibility.isVisible() && Visibility.hasFocus()) { + // We delay the validation in order to prevent Checkbox loss of focus when + // the user is focusing a TextInput and proceeds to toggle a CheckBox in + // web and mobile web platforms. + setTimeout(() => { + setTouchedInput(inputID); + if (shouldValidateOnBlur) { + onValidate(inputValues); + } + }, 200); + } + + if (_.isFunction(propsToParse.onBlur)) { + propsToParse.onBlur(event); + } + }, + onInputChange: (value, key) => { + const inputKey = key || inputID; + setInputValues((prevState) => { + const newState = { + ...prevState, + [inputKey]: value, + }; + + if (shouldValidateOnChange) { + onValidate(newState); + } + return newState; + }); + + if (propsToParse.shouldSaveDraft) { + FormActions.setDraftValues(propsToParse.formID, {[inputKey]: value}); + } + + if (_.isFunction(propsToParse.onValueChange)) { + propsToParse.onValueChange(value, inputKey); + } + }, + }; + }, + [errors, formState, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange], + ); + const value = useMemo(() => ({registerInput}), [registerInput]); + + return ( + + {/* eslint-disable react/jsx-props-no-spreading */} + + {children} + + + ); +} + +FormProvider.displayName = 'Form'; +FormProvider.propTypes = propTypes; +FormProvider.defaultProps = defaultProps; + +export default compose( + withNetwork(), + withOnyx({ + formState: { + key: (props) => props.formID, + }, + }), +)(FormProvider); diff --git a/src/components/Form/FormWrapper.js b/src/components/Form/FormWrapper.js new file mode 100644 index 000000000000..44bfee1a9e4a --- /dev/null +++ b/src/components/Form/FormWrapper.js @@ -0,0 +1,193 @@ +import React, {useCallback, useMemo, useRef} from 'react'; +import {Keyboard, ScrollView, StyleSheet} from 'react-native'; +import _ from 'underscore'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import * as ErrorUtils from '../../libs/ErrorUtils'; +import FormSubmit from '../FormSubmit'; +import FormAlertWithSubmitButton from '../FormAlertWithSubmitButton'; +import styles from '../../styles/styles'; +import SafeAreaConsumer from '../SafeAreaConsumer'; +import ScrollViewWithContext from '../ScrollViewWithContext'; + +import stylePropTypes from '../../styles/stylePropTypes'; + +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, + + /** Controls the submit button's visibility */ + isSubmitButtonVisible: PropTypes.bool, + + /** Callback to submit the form */ + onSubmit: PropTypes.func.isRequired, + + /** Children to render. */ + children: PropTypes.oneOfType([PropTypes.func, 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 */ + isLoading: PropTypes.bool, + + /** Server side errors keyed by microtime */ + errors: PropTypes.objectOf(PropTypes.oneOf([PropTypes.string, PropTypes.arrayOf(PropTypes.string)])), + + /** Field-specific server side errors keyed by microtime */ + errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + }), + + /** Should the button be enabled when offline */ + enabledWhenOffline: PropTypes.bool, + + /** Whether the form submit action is dangerous */ + isSubmitActionDangerous: PropTypes.bool, + + /** Whether ScrollWithContext should be used instead of regular ScrollView. + * Set to true when there's a nested Picker component in Form. + */ + scrollContextEnabled: PropTypes.bool, + + /** Container styles */ + style: stylePropTypes, + + /** Custom content to display in the footer after submit button */ + footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), + + errors: PropTypes.objectOf(PropTypes.string).isRequired, + + inputRefs: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object])).isRequired, +}; + +const defaultProps = { + isSubmitButtonVisible: true, + formState: { + isLoading: false, + }, + enabledWhenOffline: false, + isSubmitActionDangerous: false, + scrollContextEnabled: false, + footerContent: null, + style: [], +}; + +function FormWrapper(props) { + const {onSubmit, children, formState, errors, inputRefs, submitButtonText, footerContent, isSubmitButtonVisible, style, enabledWhenOffline, isSubmitActionDangerous, formID} = props; + const formRef = useRef(null); + const formContentRef = useRef(null); + const errorMessage = useMemo(() => { + const latestErrorMessage = ErrorUtils.getLatestErrorMessage(formState); + return typeof latestErrorMessage === 'string' ? latestErrorMessage : ''; + }, [formState]); + + const scrollViewContent = useCallback( + (safeAreaPaddingBottomStyle) => ( + + {children} + {isSubmitButtonVisible && ( + 0 || Boolean(errorMessage) || !_.isEmpty(formState.errorFields)} + isLoading={formState.isLoading} + message={_.isEmpty(formState.errorFields) ? errorMessage : null} + onSubmit={onSubmit} + footerContent={footerContent} + onFixTheErrorsLinkPressed={() => { + const errorFields = !_.isEmpty(errors) ? errors : formState.errorFields; + const focusKey = _.find(_.keys(inputRefs), (key) => _.keys(errorFields).includes(key)); + const focusInput = inputRefs[focusKey].current; + + // Dismiss the keyboard for non-text fields by checking if the component has the isFocused method, as only TextInput has this method. + if (typeof focusInput.isFocused !== 'function') { + Keyboard.dismiss(); + } + + // We subtract 10 to scroll slightly above the input + if (focusInput.measureLayout && typeof focusInput.measureLayout === 'function') { + // We measure relative to the content root, not the scroll view, as that gives + // consistent results across mobile and web + focusInput.measureLayout(formContentRef.current, (x, y) => + formRef.current.scrollTo({ + y: y - 10, + animated: false, + }), + ); + } + + // Focus the input after scrolling, as on the Web it gives a slightly better visual result + if (focusInput.focus && typeof focusInput.focus === 'function') { + focusInput.focus(); + } + }} + containerStyles={[styles.mh0, styles.mt5, styles.flex1]} + enabledWhenOffline={enabledWhenOffline} + isSubmitActionDangerous={isSubmitActionDangerous} + disablePressOnEnter + /> + )} + + ), + [ + children, + enabledWhenOffline, + errorMessage, + errors, + footerContent, + formID, + formState.errorFields, + formState.isLoading, + inputRefs, + isSubmitActionDangerous, + isSubmitButtonVisible, + onSubmit, + style, + submitButtonText, + ], + ); + + return ( + + {({safeAreaPaddingBottomStyle}) => + props.scrollContextEnabled ? ( + + {scrollViewContent(safeAreaPaddingBottomStyle)} + + ) : ( + + {scrollViewContent(safeAreaPaddingBottomStyle)} + + ) + } + + ); +} + +FormWrapper.displayName = 'FormWrapper'; +FormWrapper.propTypes = propTypes; +FormWrapper.defaultProps = defaultProps; + +export default withOnyx({ + formState: { + key: (props) => props.formID, + }, +})(FormWrapper); diff --git a/src/components/Form/InputWrapper.js b/src/components/Form/InputWrapper.js new file mode 100644 index 000000000000..43064b5a6690 --- /dev/null +++ b/src/components/Form/InputWrapper.js @@ -0,0 +1,34 @@ +import React, {forwardRef, useContext} from 'react'; +import PropTypes from 'prop-types'; +import FormContext from './FormContext'; + +const propTypes = { + InputComponent: PropTypes.oneOfType([PropTypes.func, PropTypes.elementType]).isRequired, + inputID: PropTypes.string.isRequired, + valueType: PropTypes.string, + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), +}; + +const defaultProps = { + forwardedRef: undefined, + valueType: 'string', +}; + +function InputWrapper(props) { + const {InputComponent, inputID, forwardedRef, ...rest} = props; + const {registerInput} = useContext(FormContext); + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} + +InputWrapper.propTypes = propTypes; +InputWrapper.defaultProps = defaultProps; +InputWrapper.displayName = 'InputWrapper'; + +export default forwardRef((props, ref) => ( + +)); diff --git a/src/pages/settings/Profile/DisplayNamePage.js b/src/pages/settings/Profile/DisplayNamePage.js index af98a0317a77..386648bb69b9 100644 --- a/src/pages/settings/Profile/DisplayNamePage.js +++ b/src/pages/settings/Profile/DisplayNamePage.js @@ -1,11 +1,10 @@ import lodashGet from 'lodash/get'; import React from 'react'; import {View} from 'react-native'; -import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../components/withCurrentUserPersonalDetails'; +import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '../../../components/withCurrentUserPersonalDetails'; import ScreenWrapper from '../../../components/ScreenWrapper'; import HeaderWithBackButton from '../../../components/HeaderWithBackButton'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; -import Form from '../../../components/Form'; import ONYXKEYS from '../../../ONYXKEYS'; import CONST from '../../../CONST'; import * as ValidationUtils from '../../../libs/ValidationUtils'; @@ -17,6 +16,8 @@ import compose from '../../../libs/compose'; import * as ErrorUtils from '../../../libs/ErrorUtils'; import ROUTES from '../../../ROUTES'; import Navigation from '../../../libs/Navigation/Navigation'; +import FormProvider from '../../../components/Form/FormProvider'; +import InputWrapper from '../../../components/Form/InputWrapper'; const propTypes = { ...withLocalizePropTypes, @@ -61,7 +62,6 @@ function DisplayNamePage(props) { if (!ValidationUtils.isValidDisplayName(values.lastName)) { errors.lastName = 'personalDetails.error.hasInvalidCharacter'; } - return errors; }; @@ -75,17 +75,20 @@ function DisplayNamePage(props) { title={props.translate('displayNamePage.headerTitle')} onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_PROFILE)} /> -
{props.translate('displayNamePage.isShownOnProfile')} - - -
+ ); }