From 593df794004f2cf8b16eb99983c6ee9ce33cd7fc Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Fri, 25 Aug 2023 14:50:40 +0200 Subject: [PATCH 01/19] Prepare POC --- src/components/DummyComponentWithStateHook.js | 16 ++ src/hooks/form/FormWrapper.js | 206 ++++++++++++++++++ src/hooks/form/useForm.js | 112 ++++++++++ src/pages/settings/Profile/DisplayNamePage.js | 52 +++-- 4 files changed, 364 insertions(+), 22 deletions(-) create mode 100644 src/components/DummyComponentWithStateHook.js create mode 100644 src/hooks/form/FormWrapper.js create mode 100644 src/hooks/form/useForm.js diff --git a/src/components/DummyComponentWithStateHook.js b/src/components/DummyComponentWithStateHook.js new file mode 100644 index 000000000000..1ed2f8dab43b --- /dev/null +++ b/src/components/DummyComponentWithStateHook.js @@ -0,0 +1,16 @@ +import {Text} from 'react-native'; +import React, {useState} from 'react'; + +const propTypes = {}; +const defaultProps = {}; + +function DummyComponentWithStateHook() { + const [state] = useState(''); + return {state}; +} + +DummyComponentWithStateHook.propTypes = propTypes; +DummyComponentWithStateHook.defaultProps = defaultProps; +DummyComponentWithStateHook.displayName = 'DummyComponentWithStateHook'; + +export default DummyComponentWithStateHook; diff --git a/src/hooks/form/FormWrapper.js b/src/hooks/form/FormWrapper.js new file mode 100644 index 000000000000..a63d282efb7e --- /dev/null +++ b/src/hooks/form/FormWrapper.js @@ -0,0 +1,206 @@ +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 '../../components/FormSubmit'; +import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton'; +import styles from '../../styles/styles'; +import SafeAreaConsumer from '../../components/SafeAreaConsumer'; +import ScrollViewWithContext from '../../components/ScrollViewWithContext'; + +import stylePropTypes from '../../styles/stylePropTypes'; +import networkPropTypes from '../../components/networkPropTypes'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import compose from '../../libs/compose'; +import {withNetwork} from '../../components/OnyxProvider'; + +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, + + ...withLocalizePropTypes, +}; + +const defaultProps = { + isSubmitButtonVisible: true, + formState: { + isLoading: false, + }, + enabledWhenOffline: false, + isSubmitActionDangerous: false, + scrollContextEnabled: false, + footerContent: null, + style: [], + validate: () => ({}), +}; + +function FormWrapper(props) { + const formRef = useRef(null); + const formContentRef = useRef(null); + const {onSubmit, children, formState, errors, inputRefs, submitButtonText, footerContent, isSubmitButtonVisible, style, enabledWhenOffline, isSubmitActionDangerous, formID} = props; + + const errorMessage = useMemo(() => { + const latestErrorMessage = ErrorUtils.getLatestErrorMessage(props.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 + /> + )} + + ), + [ + style, + onSubmit, + children, + isSubmitButtonVisible, + submitButtonText, + errors, + errorMessage, + formState.errorFields, + formState.isLoading, + footerContent, + enabledWhenOffline, + isSubmitActionDangerous, + inputRefs, + ], + ); + + return ( + + {({safeAreaPaddingBottomStyle}) => + props.scrollContextEnabled ? ( + + {scrollViewContent(safeAreaPaddingBottomStyle)} + + ) : ( + + {scrollViewContent(safeAreaPaddingBottomStyle)} + + ) + } + + ); +} + +FormWrapper.displayName = 'Form'; +FormWrapper.propTypes = propTypes; +FormWrapper.defaultProps = defaultProps; + +export default compose( + withLocalize, + withNetwork(), + withOnyx({ + formState: { + key: (props) => props.formID, + }, + }), +)(FormWrapper); diff --git a/src/hooks/form/useForm.js b/src/hooks/form/useForm.js new file mode 100644 index 000000000000..5fc13f701441 --- /dev/null +++ b/src/hooks/form/useForm.js @@ -0,0 +1,112 @@ +import React, {createRef, useCallback, useRef, useState} from 'react'; +import _ from 'underscore'; +import FormWrapper from './FormWrapper'; +import Visibility from '../../libs/Visibility'; +import * as FormActions from '../../libs/actions/FormActions'; + +/* eslint-disable react/jsx-props-no-spreading */ +function useForm({validate, shouldValidateOnBlur = true, shouldValidateOnChange = true}) { + const refs = useRef({}); + const touchedInputs = useRef({}); + const [inputValues, setInputValues] = useState({}); + const [errors, setErrors] = useState([]); + + const onValidate = (values) => { + const validateErrors = validate(values); + setErrors(validateErrors); + }; + /** + * @param {String} inputID - The inputID of the input being touched + */ + const setTouchedInput = useCallback( + (inputID) => { + touchedInputs.current[inputID] = true; + }, + [touchedInputs], + ); + + const registerInput = (inputID, props = {}) => { + const newRef = props.ref || createRef(); + refs[inputID] = newRef; + + // We want to initialize the input value if it's undefined + if (_.isUndefined(inputValues[inputID])) { + inputValues[inputID] = props.defaultValue || ''; + } + + // We force the form to set the input value from the defaultValue props if there is a saved valid value + if (props.shouldUseDefaultValue) { + inputValues[inputID] = props.defaultValue; + } + + if (!_.isUndefined(props.value)) { + inputValues[inputID] = props.value; + } + + return { + ...props, + ref: newRef, + inputID, + errorText: errors[inputID], + 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(props.onTouched)) { + props.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 are 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(props.onBlur)) { + props.onBlur(event); + } + }, + onInputChange: (value, key) => { + const inputKey = key || inputID; + setInputValues((prevState) => { + const newState = { + ...prevState, + [inputKey]: value, + }; + + if (shouldValidateOnChange) { + onValidate(newState); + } + return newState; + }); + + if (props.shouldSaveDraft) { + FormActions.setDraftValues(props.formID, {[inputKey]: value}); + } + + if (_.isFunction(props.onValueChange)) { + props.onValueChange(value, inputKey); + } + }, + }; + }; + + const Form = useCallback((props) => { + const {children, ...rest} = props; + return {children}; + }, []); + + return {registerInput, Form}; +} + +export default useForm; diff --git a/src/pages/settings/Profile/DisplayNamePage.js b/src/pages/settings/Profile/DisplayNamePage.js index 31ec955ed0c6..b872e494ce39 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'; @@ -13,10 +12,12 @@ import TextInput from '../../../components/TextInput'; import Text from '../../../components/Text'; import styles from '../../../styles/styles'; import * as PersonalDetails from '../../../libs/actions/PersonalDetails'; +import DummyComponentWithStateHook from '../../../components/DummyComponentWithStateHook'; import compose from '../../../libs/compose'; import * as ErrorUtils from '../../../libs/ErrorUtils'; import ROUTES from '../../../ROUTES'; import Navigation from '../../../libs/Navigation/Navigation'; +import useForm from '../../../hooks/form/useForm'; const propTypes = { ...withLocalizePropTypes, @@ -47,7 +48,8 @@ function DisplayNamePage(props) { * @returns {Object} - An object containing the errors for each inputID */ const validate = (values) => { - const errors = {}; + const requiredFields = ['firstName']; + const errors = ValidationUtils.getFieldRequiredErrors(values, requiredFields); // First we validate the first name field if (!ValidationUtils.isValidDisplayName(values.firstName)) { @@ -61,10 +63,13 @@ function DisplayNamePage(props) { if (!ValidationUtils.isValidDisplayName(values.lastName)) { errors.lastName = 'personalDetails.error.hasInvalidCharacter'; } - return errors; }; + // register input requires props spreading + /* eslint-disable react/jsx-props-no-spreading */ + const {Form, registerInput} = useForm({validate}); + return ( + {props.translate('displayNamePage.isShownOnProfile')} From 32beaafcc9f01bd54a7ad779b871ad79dcf6fd33 Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Tue, 5 Sep 2023 11:23:08 +0200 Subject: [PATCH 02/19] POC of Form using custom input components --- src/hooks/form/useForm.js | 112 --------- src/hooks/useForm/FormContext.js | 4 + src/hooks/useForm/FormProvider.js | 235 ++++++++++++++++++ src/hooks/{form => useForm}/FormWrapper.js | 2 +- src/hooks/useForm/InputWrapper.js | 11 + src/hooks/useForm/index.js | 11 + src/pages/settings/Profile/DisplayNamePage.js | 53 ++-- 7 files changed, 289 insertions(+), 139 deletions(-) delete mode 100644 src/hooks/form/useForm.js create mode 100644 src/hooks/useForm/FormContext.js create mode 100644 src/hooks/useForm/FormProvider.js rename src/hooks/{form => useForm}/FormWrapper.js (99%) create mode 100644 src/hooks/useForm/InputWrapper.js create mode 100644 src/hooks/useForm/index.js diff --git a/src/hooks/form/useForm.js b/src/hooks/form/useForm.js deleted file mode 100644 index 5fc13f701441..000000000000 --- a/src/hooks/form/useForm.js +++ /dev/null @@ -1,112 +0,0 @@ -import React, {createRef, useCallback, useRef, useState} from 'react'; -import _ from 'underscore'; -import FormWrapper from './FormWrapper'; -import Visibility from '../../libs/Visibility'; -import * as FormActions from '../../libs/actions/FormActions'; - -/* eslint-disable react/jsx-props-no-spreading */ -function useForm({validate, shouldValidateOnBlur = true, shouldValidateOnChange = true}) { - const refs = useRef({}); - const touchedInputs = useRef({}); - const [inputValues, setInputValues] = useState({}); - const [errors, setErrors] = useState([]); - - const onValidate = (values) => { - const validateErrors = validate(values); - setErrors(validateErrors); - }; - /** - * @param {String} inputID - The inputID of the input being touched - */ - const setTouchedInput = useCallback( - (inputID) => { - touchedInputs.current[inputID] = true; - }, - [touchedInputs], - ); - - const registerInput = (inputID, props = {}) => { - const newRef = props.ref || createRef(); - refs[inputID] = newRef; - - // We want to initialize the input value if it's undefined - if (_.isUndefined(inputValues[inputID])) { - inputValues[inputID] = props.defaultValue || ''; - } - - // We force the form to set the input value from the defaultValue props if there is a saved valid value - if (props.shouldUseDefaultValue) { - inputValues[inputID] = props.defaultValue; - } - - if (!_.isUndefined(props.value)) { - inputValues[inputID] = props.value; - } - - return { - ...props, - ref: newRef, - inputID, - errorText: errors[inputID], - 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(props.onTouched)) { - props.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 are 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(props.onBlur)) { - props.onBlur(event); - } - }, - onInputChange: (value, key) => { - const inputKey = key || inputID; - setInputValues((prevState) => { - const newState = { - ...prevState, - [inputKey]: value, - }; - - if (shouldValidateOnChange) { - onValidate(newState); - } - return newState; - }); - - if (props.shouldSaveDraft) { - FormActions.setDraftValues(props.formID, {[inputKey]: value}); - } - - if (_.isFunction(props.onValueChange)) { - props.onValueChange(value, inputKey); - } - }, - }; - }; - - const Form = useCallback((props) => { - const {children, ...rest} = props; - return {children}; - }, []); - - return {registerInput, Form}; -} - -export default useForm; diff --git a/src/hooks/useForm/FormContext.js b/src/hooks/useForm/FormContext.js new file mode 100644 index 000000000000..40edaa7cca69 --- /dev/null +++ b/src/hooks/useForm/FormContext.js @@ -0,0 +1,4 @@ +import {createContext} from 'react'; + +const FormContext = createContext({}); +export default FormContext; diff --git a/src/hooks/useForm/FormProvider.js b/src/hooks/useForm/FormProvider.js new file mode 100644 index 000000000000..0961dce91df6 --- /dev/null +++ b/src/hooks/useForm/FormProvider.js @@ -0,0 +1,235 @@ +import React, {createRef, useCallback, useRef, useState} from 'react'; +import _ from 'underscore'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +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 withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import {withNetwork} from '../../components/OnyxProvider'; +import stylePropTypes from '../../styles/stylePropTypes'; +import networkPropTypes from '../../components/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, + + ...withLocalizePropTypes, +}; + +const defaultProps = { + isSubmitButtonVisible: true, + formState: { + isLoading: false, + }, + enabledWhenOffline: false, + isSubmitActionDangerous: false, + scrollContextEnabled: false, + footerContent: null, + style: [], + validate: () => ({}), +}; + +function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, onSubmit, ...rest}) { + const inputRefs = useRef({}); + 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; + } + // Call submit handler + onSubmit(inputValues); + }, [enabledWhenOffline, formState.isLoading, inputValues, network.isOffline, onSubmit, onValidate]); + + const registerInput = useCallback( + (inputID, propsToParse = {}) => { + const newRef = propsToParse.ref || createRef(); + inputRefs[inputID] = newRef; + + // We want to initialize the input value if it's undefined + if (_.isUndefined(inputValues[inputID])) { + inputValues[inputID] = propsToParse.defaultValue || ''; + } + + // We force the form to set the input value from the defaultValue props if there is a saved valid value + if (propsToParse.shouldUseDefaultValue) { + inputValues[inputID] = propsToParse.defaultValue; + } + + if (!_.isUndefined(propsToParse.value)) { + inputValues[inputID] = propsToParse.value; + } + + return { + ...propsToParse, + ref: newRef, + inputID, + key: propsToParse.key || inputID, + errorText: errors[inputID], + 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 are 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, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange], + ); + + return ( + + {/* eslint-disable react/jsx-props-no-spreading */} + + {children} + + + ); +} + +FormProvider.displayName = 'Form'; +FormProvider.propTypes = propTypes; +FormProvider.defaultProps = defaultProps; + +export default compose( + withLocalize, + withNetwork(), + withOnyx({ + formState: { + key: (props) => props.formID, + }, + }), +)(FormProvider); diff --git a/src/hooks/form/FormWrapper.js b/src/hooks/useForm/FormWrapper.js similarity index 99% rename from src/hooks/form/FormWrapper.js rename to src/hooks/useForm/FormWrapper.js index a63d282efb7e..221eb2b152f0 100644 --- a/src/hooks/form/FormWrapper.js +++ b/src/hooks/useForm/FormWrapper.js @@ -191,7 +191,7 @@ function FormWrapper(props) { ); } -FormWrapper.displayName = 'Form'; +FormWrapper.displayName = 'FormWrapper'; FormWrapper.propTypes = propTypes; FormWrapper.defaultProps = defaultProps; diff --git a/src/hooks/useForm/InputWrapper.js b/src/hooks/useForm/InputWrapper.js new file mode 100644 index 000000000000..ad3d4a2f0fc8 --- /dev/null +++ b/src/hooks/useForm/InputWrapper.js @@ -0,0 +1,11 @@ +import React, {forwardRef, useContext} from 'react'; +import FormContext from './FormContext'; + +const InputWrapper = forwardRef((props, ref) => { + const {RenderInput, inputID, ...rest} = props; + const {registerInput} = useContext(FormContext); + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +}); + +export default InputWrapper; diff --git a/src/hooks/useForm/index.js b/src/hooks/useForm/index.js new file mode 100644 index 000000000000..5e3a2b480172 --- /dev/null +++ b/src/hooks/useForm/index.js @@ -0,0 +1,11 @@ +import React, {useContext} from 'react'; +import InputWrapper from './InputWrapper'; +import FormContext from './FormContext'; +import FormProvider from './FormProvider'; + +function useForm() { + const formContext = useContext(FormContext); + return {Input: InputWrapper, Form: FormProvider, formContext}; +} + +export default useForm; diff --git a/src/pages/settings/Profile/DisplayNamePage.js b/src/pages/settings/Profile/DisplayNamePage.js index b872e494ce39..d13920881034 100644 --- a/src/pages/settings/Profile/DisplayNamePage.js +++ b/src/pages/settings/Profile/DisplayNamePage.js @@ -17,7 +17,7 @@ import compose from '../../../libs/compose'; import * as ErrorUtils from '../../../libs/ErrorUtils'; import ROUTES from '../../../ROUTES'; import Navigation from '../../../libs/Navigation/Navigation'; -import useForm from '../../../hooks/form/useForm'; +import useForm from '../../../hooks/useForm'; const propTypes = { ...withLocalizePropTypes, @@ -66,9 +66,7 @@ function DisplayNamePage(props) { return errors; }; - // register input requires props spreading - /* eslint-disable react/jsx-props-no-spreading */ - const {Form, registerInput} = useForm({validate}); + const {Form, Input} = useForm(); return ( {props.translate('displayNamePage.isShownOnProfile')} - - From 51b1ab3ec537d0586f22abbb4ba99e3187eb4db6 Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Thu, 7 Sep 2023 13:16:44 +0200 Subject: [PATCH 03/19] Cleanup input wrapper --- src/hooks/useForm/InputWrapper.js | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/hooks/useForm/InputWrapper.js b/src/hooks/useForm/InputWrapper.js index ad3d4a2f0fc8..009bb762f270 100644 --- a/src/hooks/useForm/InputWrapper.js +++ b/src/hooks/useForm/InputWrapper.js @@ -1,11 +1,32 @@ import React, {forwardRef, useContext} from 'react'; +import PropTypes from 'prop-types'; import FormContext from './FormContext'; -const InputWrapper = forwardRef((props, ref) => { - const {RenderInput, inputID, ...rest} = props; +const propTypes = { + RenderInput: PropTypes.node.isRequired, + inputID: PropTypes.string.isRequired, + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), +}; + +const defaultProps = { + forwardedRef: undefined, +}; + +function InputWrapper(props) { + const {RenderInput, inputID, forwardedRef, ...rest} = props; const {registerInput} = useContext(FormContext); // eslint-disable-next-line react/jsx-props-no-spreading - return ; -}); + return ; +} + +InputWrapper.propTypes = propTypes; +InputWrapper.defaultProps = defaultProps; +InputWrapper.displayName = 'InputWrapper'; -export default InputWrapper; +export default forwardRef((props, ref) => ( + +)); From 620b48c6975494e7c3a350acd325e83bbb70f1f6 Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Thu, 7 Sep 2023 13:17:50 +0200 Subject: [PATCH 04/19] Remove redundant testing component --- src/components/DummyComponentWithStateHook.js | 16 ---------------- src/pages/settings/Profile/DisplayNamePage.js | 2 -- 2 files changed, 18 deletions(-) delete mode 100644 src/components/DummyComponentWithStateHook.js diff --git a/src/components/DummyComponentWithStateHook.js b/src/components/DummyComponentWithStateHook.js deleted file mode 100644 index 1ed2f8dab43b..000000000000 --- a/src/components/DummyComponentWithStateHook.js +++ /dev/null @@ -1,16 +0,0 @@ -import {Text} from 'react-native'; -import React, {useState} from 'react'; - -const propTypes = {}; -const defaultProps = {}; - -function DummyComponentWithStateHook() { - const [state] = useState(''); - return {state}; -} - -DummyComponentWithStateHook.propTypes = propTypes; -DummyComponentWithStateHook.defaultProps = defaultProps; -DummyComponentWithStateHook.displayName = 'DummyComponentWithStateHook'; - -export default DummyComponentWithStateHook; diff --git a/src/pages/settings/Profile/DisplayNamePage.js b/src/pages/settings/Profile/DisplayNamePage.js index d13920881034..4eb9e44f30ae 100644 --- a/src/pages/settings/Profile/DisplayNamePage.js +++ b/src/pages/settings/Profile/DisplayNamePage.js @@ -12,7 +12,6 @@ import TextInput from '../../../components/TextInput'; import Text from '../../../components/Text'; import styles from '../../../styles/styles'; import * as PersonalDetails from '../../../libs/actions/PersonalDetails'; -import DummyComponentWithStateHook from '../../../components/DummyComponentWithStateHook'; import compose from '../../../libs/compose'; import * as ErrorUtils from '../../../libs/ErrorUtils'; import ROUTES from '../../../ROUTES'; @@ -87,7 +86,6 @@ function DisplayNamePage(props) { shouldValidateOnBlur shouldValidateOnChange > - {props.translate('displayNamePage.isShownOnProfile')} Date: Thu, 7 Sep 2023 14:55:10 +0200 Subject: [PATCH 05/19] Cleanup form wrapper --- src/hooks/useForm/FormWrapper.js | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/src/hooks/useForm/FormWrapper.js b/src/hooks/useForm/FormWrapper.js index 221eb2b152f0..9ec4492f97aa 100644 --- a/src/hooks/useForm/FormWrapper.js +++ b/src/hooks/useForm/FormWrapper.js @@ -11,10 +11,8 @@ import SafeAreaConsumer from '../../components/SafeAreaConsumer'; import ScrollViewWithContext from '../../components/ScrollViewWithContext'; import stylePropTypes from '../../styles/stylePropTypes'; -import networkPropTypes from '../../components/networkPropTypes'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; import compose from '../../libs/compose'; -import {withNetwork} from '../../components/OnyxProvider'; const propTypes = { /** A unique Onyx key identifying the form */ @@ -26,9 +24,6 @@ const propTypes = { /** 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, @@ -66,9 +61,6 @@ const propTypes = { /** Custom content to display in the footer after submit button */ footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), - /** Information about the network */ - network: networkPropTypes.isRequired, - ...withLocalizePropTypes, }; @@ -82,7 +74,6 @@ const defaultProps = { scrollContextEnabled: false, footerContent: null, style: [], - validate: () => ({}), }; function FormWrapper(props) { @@ -91,7 +82,7 @@ function FormWrapper(props) { const {onSubmit, children, formState, errors, inputRefs, submitButtonText, footerContent, isSubmitButtonVisible, style, enabledWhenOffline, isSubmitActionDangerous, formID} = props; const errorMessage = useMemo(() => { - const latestErrorMessage = ErrorUtils.getLatestErrorMessage(props.formState); + const latestErrorMessage = ErrorUtils.getLatestErrorMessage(formState); return typeof latestErrorMessage === 'string' ? latestErrorMessage : ''; }, [formState]); @@ -148,19 +139,20 @@ function FormWrapper(props) { ), [ - style, - onSubmit, children, - isSubmitButtonVisible, - submitButtonText, - errors, + enabledWhenOffline, errorMessage, + errors, + footerContent, + formID, formState.errorFields, formState.isLoading, - footerContent, - enabledWhenOffline, - isSubmitActionDangerous, inputRefs, + isSubmitActionDangerous, + isSubmitButtonVisible, + onSubmit, + style, + submitButtonText, ], ); @@ -197,7 +189,6 @@ FormWrapper.defaultProps = defaultProps; export default compose( withLocalize, - withNetwork(), withOnyx({ formState: { key: (props) => props.formID, From 59652ce37082ee122e8cc3de704e922474b6aa56 Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Thu, 7 Sep 2023 16:14:59 +0200 Subject: [PATCH 06/19] Cleanup DisplayNamePage --- src/pages/settings/Profile/DisplayNamePage.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/settings/Profile/DisplayNamePage.js b/src/pages/settings/Profile/DisplayNamePage.js index 8f031d87e162..ec8f96cd210e 100644 --- a/src/pages/settings/Profile/DisplayNamePage.js +++ b/src/pages/settings/Profile/DisplayNamePage.js @@ -47,8 +47,7 @@ function DisplayNamePage(props) { * @returns {Object} - An object containing the errors for each inputID */ const validate = (values) => { - const requiredFields = ['firstName']; - const errors = ValidationUtils.getFieldRequiredErrors(values, requiredFields); + const errors = {}; // First we validate the first name field if (!ValidationUtils.isValidDisplayName(values.firstName)) { @@ -92,7 +91,6 @@ function DisplayNamePage(props) { RenderInput={TextInput} inputID="firstName" name="fname" - key="iuadgbkj" label={props.translate('common.firstName')} accessibilityLabel={props.translate('common.firstName')} accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} From 0491bc3e3b92797ea2a65df650c158d60274d3e4 Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Fri, 8 Sep 2023 13:46:38 +0200 Subject: [PATCH 07/19] Cleanup FormProvider --- src/hooks/useForm/FormProvider.js | 33 +++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/hooks/useForm/FormProvider.js b/src/hooks/useForm/FormProvider.js index 0961dce91df6..03095d8f74f5 100644 --- a/src/hooks/useForm/FormProvider.js +++ b/src/hooks/useForm/FormProvider.js @@ -1,4 +1,4 @@ -import React, {createRef, useCallback, useRef, useState} from 'react'; +import React, {createRef, useCallback, useMemo, useRef, useState} from 'react'; import _ from 'underscore'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; @@ -11,6 +11,8 @@ import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize import {withNetwork} from '../../components/OnyxProvider'; import stylePropTypes from '../../styles/stylePropTypes'; import networkPropTypes from '../../components/networkPropTypes'; +import * as ErrorUtils from '../../libs/ErrorUtils'; +import lodashGet from 'lodash/get'; const propTypes = { /** A unique Onyx key identifying the form */ @@ -81,6 +83,19 @@ const defaultProps = { validate: () => ({}), }; +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({}); const touchedInputs = useRef({}); @@ -134,7 +149,7 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c // We want to initialize the input value if it's undefined if (_.isUndefined(inputValues[inputID])) { - inputValues[inputID] = propsToParse.defaultValue || ''; + inputValues[inputID] = propsToParse.defaultValue || getInitialValueByType(propsToParse.valueType); } // We force the form to set the input value from the defaultValue props if there is a saved valid value @@ -146,12 +161,22 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c inputValues[inputID] = propsToParse.value; } + 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], + 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. @@ -204,7 +229,7 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c }, }; }, - [errors, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange], + [errors, formState, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange], ); return ( From 74f69a552ca2c288f60e1ab3076eb0bd8b0f97e2 Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Fri, 8 Sep 2023 17:12:03 +0200 Subject: [PATCH 08/19] Fix lint --- src/hooks/useForm/FormProvider.js | 3 +-- src/hooks/useForm/index.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/hooks/useForm/FormProvider.js b/src/hooks/useForm/FormProvider.js index 03095d8f74f5..e28c8543f6ab 100644 --- a/src/hooks/useForm/FormProvider.js +++ b/src/hooks/useForm/FormProvider.js @@ -1,4 +1,4 @@ -import React, {createRef, useCallback, useMemo, useRef, useState} from 'react'; +import React, {createRef, useCallback, useRef, useState} from 'react'; import _ from 'underscore'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; @@ -11,7 +11,6 @@ import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize import {withNetwork} from '../../components/OnyxProvider'; import stylePropTypes from '../../styles/stylePropTypes'; import networkPropTypes from '../../components/networkPropTypes'; -import * as ErrorUtils from '../../libs/ErrorUtils'; import lodashGet from 'lodash/get'; const propTypes = { diff --git a/src/hooks/useForm/index.js b/src/hooks/useForm/index.js index 5e3a2b480172..faf102e5fd16 100644 --- a/src/hooks/useForm/index.js +++ b/src/hooks/useForm/index.js @@ -1,4 +1,4 @@ -import React, {useContext} from 'react'; +import {useContext} from 'react'; import InputWrapper from './InputWrapper'; import FormContext from './FormContext'; import FormProvider from './FormProvider'; From b6c8a6212582cad7d9df5f1eb1b1666dda40820c Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Mon, 11 Sep 2023 15:39:54 +0200 Subject: [PATCH 09/19] Cleanup --- src/hooks/useForm/FormProvider.js | 2 +- src/hooks/useForm/InputWrapper.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useForm/FormProvider.js b/src/hooks/useForm/FormProvider.js index e28c8543f6ab..c29597f48028 100644 --- a/src/hooks/useForm/FormProvider.js +++ b/src/hooks/useForm/FormProvider.js @@ -2,6 +2,7 @@ import React, {createRef, useCallback, 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'; @@ -11,7 +12,6 @@ import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize import {withNetwork} from '../../components/OnyxProvider'; import stylePropTypes from '../../styles/stylePropTypes'; import networkPropTypes from '../../components/networkPropTypes'; -import lodashGet from 'lodash/get'; const propTypes = { /** A unique Onyx key identifying the form */ diff --git a/src/hooks/useForm/InputWrapper.js b/src/hooks/useForm/InputWrapper.js index 009bb762f270..31dc3edb02e8 100644 --- a/src/hooks/useForm/InputWrapper.js +++ b/src/hooks/useForm/InputWrapper.js @@ -5,11 +5,13 @@ import FormContext from './FormContext'; const propTypes = { RenderInput: PropTypes.node.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) { From 43d8080e810b61155f1096f9cd6e6fb9e3c348be Mon Sep 17 00:00:00 2001 From: Kamil Owczarz <91068263+kowczarz@users.noreply.github.com> Date: Wed, 13 Sep 2023 11:59:30 +0200 Subject: [PATCH 10/19] Apply suggestions from code review Co-authored-by: Carlos Martins --- src/hooks/useForm/FormProvider.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/hooks/useForm/FormProvider.js b/src/hooks/useForm/FormProvider.js index c29597f48028..2709f68b906d 100644 --- a/src/hooks/useForm/FormProvider.js +++ b/src/hooks/useForm/FormProvider.js @@ -79,7 +79,7 @@ const defaultProps = { scrollContextEnabled: false, footerContent: null, style: [], - validate: () => ({}), + validate: () => {}, }; function getInitialValueByType(valueType) { @@ -137,6 +137,7 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c if (network.isOffline && !enabledWhenOffline) { return; } + // Call submit handler onSubmit(inputValues); }, [enabledWhenOffline, formState.isLoading, inputValues, network.isOffline, onSubmit, onValidate]); @@ -190,7 +191,7 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c // 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 are focusing a TextInput and proceeds to toggle a CheckBox in + // the user is focusing a TextInput and proceeds to toggle a CheckBox in // web and mobile web platforms. setTimeout(() => { setTouchedInput(inputID); From 01a455f38706530a7589398708f037960d66f5ee Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Wed, 13 Sep 2023 18:26:16 +0200 Subject: [PATCH 11/19] Code review changes --- src/hooks/useForm/FormProvider.js | 24 ++++++++++++++---------- src/hooks/useForm/FormWrapper.js | 19 ++++++++----------- src/hooks/useForm/InputWrapper.js | 2 +- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/hooks/useForm/FormProvider.js b/src/hooks/useForm/FormProvider.js index 2709f68b906d..ddc59b63bf0b 100644 --- a/src/hooks/useForm/FormProvider.js +++ b/src/hooks/useForm/FormProvider.js @@ -8,7 +8,6 @@ import * as FormActions from '../../libs/actions/FormActions'; import FormContext from './FormContext'; import FormWrapper from './FormWrapper'; import compose from '../../libs/compose'; -import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; import {withNetwork} from '../../components/OnyxProvider'; import stylePropTypes from '../../styles/stylePropTypes'; import networkPropTypes from '../../components/networkPropTypes'; @@ -66,7 +65,9 @@ const propTypes = { /** Information about the network */ network: networkPropTypes.isRequired, - ...withLocalizePropTypes, + shouldValidateOnBlur: PropTypes.bool, + + shouldValidateOnChange: PropTypes.bool, }; const defaultProps = { @@ -80,6 +81,8 @@ const defaultProps = { footerContent: null, style: [], validate: () => {}, + shouldValidateOnBlur: false, + shouldValidateOnChange: false, }; function getInitialValueByType(valueType) { @@ -99,7 +102,7 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c const inputRefs = useRef({}); const touchedInputs = useRef({}); const [inputValues, setInputValues] = useState({}); - const [errors, setErrors] = useState([]); + const [errors, setErrors] = useState({}); const onValidate = useCallback( (values) => { @@ -147,18 +150,18 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c const newRef = propsToParse.ref || createRef(); inputRefs[inputID] = newRef; - // We want to initialize the input value if it's undefined - if (_.isUndefined(inputValues[inputID])) { - inputValues[inputID] = propsToParse.defaultValue || getInitialValueByType(propsToParse.valueType); + if (!_.isUndefined(propsToParse.value)) { + inputValues[inputID] = propsToParse.value; } // We force the form to set the input value from the defaultValue props if there is a saved valid value - if (propsToParse.shouldUseDefaultValue) { + else if (propsToParse.shouldUseDefaultValue) { inputValues[inputID] = propsToParse.defaultValue; } - if (!_.isUndefined(propsToParse.value)) { - inputValues[inputID] = propsToParse.value; + // We want to initialize the input value if it's undefined + else if (_.isUndefined(inputValues[inputID])) { + inputValues[inputID] = _.isUndefined(propsToParse.defaultValue) ? getInitialValueByType(propsToParse.valueType) : propsToParse.defaultValue; } const errorFields = lodashGet(formState, 'errorFields', {}); @@ -238,6 +241,8 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c {children} @@ -250,7 +255,6 @@ FormProvider.propTypes = propTypes; FormProvider.defaultProps = defaultProps; export default compose( - withLocalize, withNetwork(), withOnyx({ formState: { diff --git a/src/hooks/useForm/FormWrapper.js b/src/hooks/useForm/FormWrapper.js index 9ec4492f97aa..b8d1dc999ef9 100644 --- a/src/hooks/useForm/FormWrapper.js +++ b/src/hooks/useForm/FormWrapper.js @@ -11,8 +11,6 @@ import SafeAreaConsumer from '../../components/SafeAreaConsumer'; import ScrollViewWithContext from '../../components/ScrollViewWithContext'; import stylePropTypes from '../../styles/stylePropTypes'; -import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; -import compose from '../../libs/compose'; const propTypes = { /** A unique Onyx key identifying the form */ @@ -61,7 +59,9 @@ const propTypes = { /** Custom content to display in the footer after submit button */ footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), - ...withLocalizePropTypes, + errors: PropTypes.objectOf(PropTypes.string).isRequired, + + inputRefs: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(Element)})])).isRequired, }; const defaultProps = { @@ -187,11 +187,8 @@ FormWrapper.displayName = 'FormWrapper'; FormWrapper.propTypes = propTypes; FormWrapper.defaultProps = defaultProps; -export default compose( - withLocalize, - withOnyx({ - formState: { - key: (props) => props.formID, - }, - }), -)(FormWrapper); +export default withOnyx({ + formState: { + key: (props) => props.formID, + }, +})(FormWrapper); diff --git a/src/hooks/useForm/InputWrapper.js b/src/hooks/useForm/InputWrapper.js index 31dc3edb02e8..83fede5a0a89 100644 --- a/src/hooks/useForm/InputWrapper.js +++ b/src/hooks/useForm/InputWrapper.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import FormContext from './FormContext'; const propTypes = { - RenderInput: PropTypes.node.isRequired, + RenderInput: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, inputID: PropTypes.string.isRequired, valueType: PropTypes.string, forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), From 9f28189ca9b747ea3c4052c63bca006d5589929c Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Wed, 13 Sep 2023 18:27:44 +0200 Subject: [PATCH 12/19] Code review changes --- src/hooks/useForm/FormProvider.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useForm/FormProvider.js b/src/hooks/useForm/FormProvider.js index ddc59b63bf0b..3ebda7edecae 100644 --- a/src/hooks/useForm/FormProvider.js +++ b/src/hooks/useForm/FormProvider.js @@ -141,7 +141,6 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c return; } - // Call submit handler onSubmit(inputValues); }, [enabledWhenOffline, formState.isLoading, inputValues, network.isOffline, onSubmit, onValidate]); From 7ee8ee39fa072917bee1cb7ac1bfc21e7125ca57 Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Thu, 14 Sep 2023 10:45:15 +0200 Subject: [PATCH 13/19] Code style fixes --- src/hooks/useForm/FormProvider.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/hooks/useForm/FormProvider.js b/src/hooks/useForm/FormProvider.js index 3ebda7edecae..545916c1d71f 100644 --- a/src/hooks/useForm/FormProvider.js +++ b/src/hooks/useForm/FormProvider.js @@ -151,15 +151,11 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c if (!_.isUndefined(propsToParse.value)) { inputValues[inputID] = propsToParse.value; - } - - // We force the form to set the input value from the defaultValue props if there is a saved valid value - else if (propsToParse.shouldUseDefaultValue) { + } 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; - } - - // We want to initialize the input value if it's undefined - else if (_.isUndefined(inputValues[inputID])) { + } 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; } From 858e8fc03bc8c0fab32b654cdfe70a69a9708dbe Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Mon, 18 Sep 2023 11:22:41 +0200 Subject: [PATCH 14/19] Fix crashing issues --- src/hooks/useForm/FormWrapper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useForm/FormWrapper.js b/src/hooks/useForm/FormWrapper.js index b8d1dc999ef9..9b3159059c59 100644 --- a/src/hooks/useForm/FormWrapper.js +++ b/src/hooks/useForm/FormWrapper.js @@ -61,7 +61,7 @@ const propTypes = { errors: PropTypes.objectOf(PropTypes.string).isRequired, - inputRefs: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(Element)})])).isRequired, + inputRefs: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.element})])).isRequired, }; const defaultProps = { From 106afb724b24a95018879af5ecfd163c354e1b77 Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Wed, 20 Sep 2023 14:52:27 +0200 Subject: [PATCH 15/19] Fix PropTypes errors --- src/hooks/useForm/FormProvider.js | 2 +- src/hooks/useForm/FormWrapper.js | 5 ++--- src/hooks/useForm/InputWrapper.js | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/hooks/useForm/FormProvider.js b/src/hooks/useForm/FormProvider.js index 545916c1d71f..9cbfdd3189a7 100644 --- a/src/hooks/useForm/FormProvider.js +++ b/src/hooks/useForm/FormProvider.js @@ -99,7 +99,7 @@ function getInitialValueByType(valueType) { } function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, onSubmit, ...rest}) { - const inputRefs = useRef({}); + const inputRefs = useRef(null); const touchedInputs = useRef({}); const [inputValues, setInputValues] = useState({}); const [errors, setErrors] = useState({}); diff --git a/src/hooks/useForm/FormWrapper.js b/src/hooks/useForm/FormWrapper.js index 9b3159059c59..dc350b78983d 100644 --- a/src/hooks/useForm/FormWrapper.js +++ b/src/hooks/useForm/FormWrapper.js @@ -61,7 +61,7 @@ const propTypes = { errors: PropTypes.objectOf(PropTypes.string).isRequired, - inputRefs: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.element})])).isRequired, + inputRefs: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object])).isRequired, }; const defaultProps = { @@ -77,10 +77,9 @@ const defaultProps = { }; 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 {onSubmit, children, formState, errors, inputRefs, submitButtonText, footerContent, isSubmitButtonVisible, style, enabledWhenOffline, isSubmitActionDangerous, formID} = props; - const errorMessage = useMemo(() => { const latestErrorMessage = ErrorUtils.getLatestErrorMessage(formState); return typeof latestErrorMessage === 'string' ? latestErrorMessage : ''; diff --git a/src/hooks/useForm/InputWrapper.js b/src/hooks/useForm/InputWrapper.js index 83fede5a0a89..3d118fa6f3f1 100644 --- a/src/hooks/useForm/InputWrapper.js +++ b/src/hooks/useForm/InputWrapper.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import FormContext from './FormContext'; const propTypes = { - RenderInput: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, + RenderInput: 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)})]), From ce80fef9d00f18f711d8acae7923b41057920fb3 Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Thu, 21 Sep 2023 14:56:13 +0200 Subject: [PATCH 16/19] Form performance optimisation --- .../useForm => components/Form}/FormContext.js | 0 .../useForm => components/Form}/FormProvider.js | 9 +++++---- .../useForm => components/Form}/FormWrapper.js | 8 ++++---- .../useForm => components/Form}/InputWrapper.js | 6 +++--- src/hooks/useForm/index.js | 11 ----------- src/pages/settings/Profile/DisplayNamePage.js | 17 ++++++++--------- 6 files changed, 20 insertions(+), 31 deletions(-) rename src/{hooks/useForm => components/Form}/FormContext.js (100%) rename src/{hooks/useForm => components/Form}/FormProvider.js (96%) rename src/{hooks/useForm => components/Form}/FormWrapper.js (96%) rename src/{hooks/useForm => components/Form}/InputWrapper.js (78%) delete mode 100644 src/hooks/useForm/index.js diff --git a/src/hooks/useForm/FormContext.js b/src/components/Form/FormContext.js similarity index 100% rename from src/hooks/useForm/FormContext.js rename to src/components/Form/FormContext.js diff --git a/src/hooks/useForm/FormProvider.js b/src/components/Form/FormProvider.js similarity index 96% rename from src/hooks/useForm/FormProvider.js rename to src/components/Form/FormProvider.js index 9cbfdd3189a7..3926680a5eeb 100644 --- a/src/hooks/useForm/FormProvider.js +++ b/src/components/Form/FormProvider.js @@ -1,4 +1,4 @@ -import React, {createRef, useCallback, useRef, useState} from 'react'; +import React, {createRef, useCallback, useMemo, useRef, useState} from 'react'; import _ from 'underscore'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; @@ -8,9 +8,9 @@ import * as FormActions from '../../libs/actions/FormActions'; import FormContext from './FormContext'; import FormWrapper from './FormWrapper'; import compose from '../../libs/compose'; -import {withNetwork} from '../../components/OnyxProvider'; +import {withNetwork} from '../OnyxProvider'; import stylePropTypes from '../../styles/stylePropTypes'; -import networkPropTypes from '../../components/networkPropTypes'; +import networkPropTypes from '../networkPropTypes'; const propTypes = { /** A unique Onyx key identifying the form */ @@ -229,9 +229,10 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c }, [errors, formState, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange], ); + const value = useMemo(() => ({registerInput}), [registerInput]); return ( - + {/* eslint-disable react/jsx-props-no-spreading */} ; + return ; } InputWrapper.propTypes = propTypes; diff --git a/src/hooks/useForm/index.js b/src/hooks/useForm/index.js deleted file mode 100644 index faf102e5fd16..000000000000 --- a/src/hooks/useForm/index.js +++ /dev/null @@ -1,11 +0,0 @@ -import {useContext} from 'react'; -import InputWrapper from './InputWrapper'; -import FormContext from './FormContext'; -import FormProvider from './FormProvider'; - -function useForm() { - const formContext = useContext(FormContext); - return {Input: InputWrapper, Form: FormProvider, formContext}; -} - -export default useForm; diff --git a/src/pages/settings/Profile/DisplayNamePage.js b/src/pages/settings/Profile/DisplayNamePage.js index ec8f96cd210e..4b6ae4df80be 100644 --- a/src/pages/settings/Profile/DisplayNamePage.js +++ b/src/pages/settings/Profile/DisplayNamePage.js @@ -16,7 +16,8 @@ import compose from '../../../libs/compose'; import * as ErrorUtils from '../../../libs/ErrorUtils'; import ROUTES from '../../../ROUTES'; import Navigation from '../../../libs/Navigation/Navigation'; -import useForm from '../../../hooks/useForm'; +import FormProvider from "../../../components/Form/FormProvider"; +import InputWrapper from "../../../components/Form/InputWrapper"; const propTypes = { ...withLocalizePropTypes, @@ -64,8 +65,6 @@ function DisplayNamePage(props) { return errors; }; - const {Form, Input} = useForm(); - return ( Navigation.goBack(ROUTES.SETTINGS_PROFILE)} /> -
{props.translate('displayNamePage.isShownOnProfile')} - - -
+
); } From 6cba7bed9870e0a4ad9ed3cb8bf3fd7a49c1424c Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Thu, 21 Sep 2023 16:42:51 +0200 Subject: [PATCH 17/19] Cleanup --- src/components/Form/FormWrapper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Form/FormWrapper.js b/src/components/Form/FormWrapper.js index 73534d456862..44bfee1a9e4a 100644 --- a/src/components/Form/FormWrapper.js +++ b/src/components/Form/FormWrapper.js @@ -36,7 +36,7 @@ const propTypes = { isLoading: PropTypes.bool, /** Server side errors keyed by microtime */ - errors: PropTypes.objectOf(PropTypes.string), + 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)), From ff8653f84a7439d3448e4331a5ffaa1f6fb9c581 Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Thu, 21 Sep 2023 17:20:41 +0200 Subject: [PATCH 18/19] Fix wrong default props --- src/components/Form/FormProvider.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js index 3926680a5eeb..408f8c2c2b7f 100644 --- a/src/components/Form/FormProvider.js +++ b/src/components/Form/FormProvider.js @@ -81,8 +81,8 @@ const defaultProps = { footerContent: null, style: [], validate: () => {}, - shouldValidateOnBlur: false, - shouldValidateOnChange: false, + shouldValidateOnBlur: true, + shouldValidateOnChange: true, }; function getInitialValueByType(valueType) { From 073af5d2f772a96e62d1878b85e28bc4b1b6104f Mon Sep 17 00:00:00 2001 From: Kamil Owczarz Date: Thu, 21 Sep 2023 17:30:05 +0200 Subject: [PATCH 19/19] Fix prettier --- src/pages/settings/Profile/DisplayNamePage.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/settings/Profile/DisplayNamePage.js b/src/pages/settings/Profile/DisplayNamePage.js index 4b6ae4df80be..31c257af11e3 100644 --- a/src/pages/settings/Profile/DisplayNamePage.js +++ b/src/pages/settings/Profile/DisplayNamePage.js @@ -16,8 +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"; +import FormProvider from '../../../components/Form/FormProvider'; +import InputWrapper from '../../../components/Form/InputWrapper'; const propTypes = { ...withLocalizePropTypes,