diff --git a/pkg/webui/components/form/context.js b/pkg/webui/components/form/context.js deleted file mode 100644 index 86ca8e4ae1..0000000000 --- a/pkg/webui/components/form/context.js +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright © 2019 The Things Network Foundation, The Things Industries B.V. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import React from 'react' - -export default React.createContext() diff --git a/pkg/webui/components/form/field/index.js b/pkg/webui/components/form/field/index.js index d10566fefb..ceaf099276 100644 --- a/pkg/webui/components/form/field/index.js +++ b/pkg/webui/components/form/field/index.js @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { useCallback, useContext } from 'react' +import React, { useCallback } from 'react' import classnames from 'classnames' import { useField } from 'formik' import { isPlainObject } from 'lodash' @@ -22,7 +22,7 @@ import Message from '@ttn-lw/lib/components/message' import from from '@ttn-lw/lib/from' import PropTypes from '@ttn-lw/lib/prop-types' -import FormContext from '../context' +import { useFormContext } from '..' import Tooltip from './tooltip' import FieldError from './error' @@ -89,8 +89,16 @@ const FormField = props => { onChange, onBlur, } = props - const { disabled: formDisabled, validateOnBlur } = useContext(FormContext) - const [{ value: encodedValue }, { touched, error = false }, { setTouched, setValue }] = useField({ + + const { + disabled: formDisabled, + validateOnBlur, + setFieldValue, + setFieldTouched, + } = useFormContext() + + // Initialize field, which also takes care of registering fields in formik's internal registry. + const [{ value: encodedValue }, { touched, error = false }] = useField({ name, validate, }) @@ -111,27 +119,27 @@ const FormField = props => { } } - await setValue(newValue) + await setFieldValue(name, newValue) if (enforceValidation) { - setTouched(true, true) + setFieldTouched(name, true, true) } onChange(isSyntheticEvent ? value : encode(value)) }, - [setTouched, setValue, onChange, encode], + [encode, name, onChange, setFieldTouched, setFieldValue], ) const handleBlur = useCallback( event => { if (validateOnBlur) { const value = extractValue(event) - setTouched(!isValueEmpty(value)) + setFieldTouched(name, !isValueEmpty(value)) } onBlur(event) }, - [validateOnBlur, onBlur, setTouched], + [validateOnBlur, onBlur, setFieldTouched, name], ) const value = decode(encodedValue) @@ -142,7 +150,6 @@ const FormField = props => { const showWarning = !Boolean(error) && Boolean(warning) const showDescription = !showError && !showWarning && Boolean(description) const tooltipIcon = hasTooltip ? : null - const describedBy = showError ? `${name}-field-error` : showWarning diff --git a/pkg/webui/components/form/field/info.js b/pkg/webui/components/form/field/info.js index 4a2586f9b2..964648e06a 100644 --- a/pkg/webui/components/form/field/info.js +++ b/pkg/webui/components/form/field/info.js @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { useContext } from 'react' +import React from 'react' import classnames from 'classnames' import Message from '@ttn-lw/lib/components/message' @@ -20,7 +20,7 @@ import Message from '@ttn-lw/lib/components/message' import PropTypes from '@ttn-lw/lib/prop-types' import from from '@ttn-lw/lib/from' -import FormContext from '../context' +import { useFormContext } from '..' import Tooltip from './tooltip' @@ -28,7 +28,7 @@ import style from './field.styl' const InfoField = props => { const { children, className, title, disabled: fieldDisabled, tooltipId } = props - const { disabled: formDisabled } = useContext(FormContext) + const { disabled: formDisabled } = useFormContext() const disabled = formDisabled || fieldDisabled const cls = classnames(className, style.field, from(style, { disabled })) diff --git a/pkg/webui/components/form/index.js b/pkg/webui/components/form/index.js index 280fff7006..c1022329c9 100644 --- a/pkg/webui/components/form/index.js +++ b/pkg/webui/components/form/index.js @@ -12,11 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -/* eslint-disable react/sort-prop-types */ import React, { useCallback, useEffect } from 'react' -import { Formik, yupToFormErrors, useFormikContext, validateYupSchema } from 'formik' +import { + yupToFormErrors, + useFormikContext, + validateYupSchema, + useFormik, + FormikProvider, +} from 'formik' import scrollIntoView from 'scroll-into-view-if-needed' import { defineMessages } from 'react-intl' +import { isPlainObject } from 'lodash' import Notification from '@ttn-lw/components/notification' import ErrorNotification from '@ttn-lw/components/error-notification' @@ -24,7 +30,6 @@ import ErrorNotification from '@ttn-lw/components/error-notification' import PropTypes from '@ttn-lw/lib/prop-types' import { ingestError } from '@ttn-lw/lib/errors/utils' -import FormContext from './context' import FormField from './field' import FormInfoField from './field/info' import FormSubmit from './submit' @@ -36,82 +41,6 @@ const m = defineMessages({ submitFailed: 'Submit failed', }) -const InnerForm = props => { - const { - formError, - isSubmitting, - isValid, - className, - children, - formErrorTitle, - formInfo, - formInfoTitle, - handleSubmit, - id, - ...rest - } = props - const notificationRef = React.useRef() - - useEffect(() => { - // Scroll form notification into view if needed. - if (formError) { - scrollIntoView(notificationRef.current, { behavior: 'smooth' }) - notificationRef.current.focus({ preventScroll: true }) - } - - // Scroll invalid fields into view if needed and focus them. - if (!isSubmitting && !isValid) { - const firstErrorNode = document.querySelectorAll('[data-needs-focus="true"]')[0] - if (firstErrorNode) { - scrollIntoView(firstErrorNode, { behavior: 'smooth' }) - firstErrorNode.querySelector('input,textarea,canvas,video').focus({ preventScroll: true }) - } - } - }, [formError, isSubmitting, isValid]) - - return ( -
- {(formError || formInfo) && ( -
- {formError && } - {formInfo && } -
- )} - - {children} - -
- ) -} -InnerForm.propTypes = { - children: PropTypes.node.isRequired, - className: PropTypes.string, - id: PropTypes.string, - formError: PropTypes.error, - formErrorTitle: PropTypes.message, - formInfo: PropTypes.message, - formInfoTitle: PropTypes.message, - handleSubmit: PropTypes.func.isRequired, - isSubmitting: PropTypes.bool.isRequired, - isValid: PropTypes.bool.isRequired, -} - -InnerForm.defaultProps = { - className: undefined, - id: undefined, - formInfo: undefined, - formInfoTitle: undefined, - formError: undefined, - formErrorTitle: m.submitFailed, -} - const Form = props => { const { children, @@ -135,22 +64,10 @@ const Form = props => { validationSchema, } = props - const handleSubmit = useCallback( - async (...args) => { - try { - return await onSubmit(...args) - } catch (error) { - // Make sure all unhandled exceptions during submit are ingested. - ingestError(error, { ingestedBy: 'FormSubmit' }) - - throw error - } - }, - [onSubmit], - ) + const notificationRef = React.useRef() // Recreate the validation hook to allow passing down validation contexts. - const validate = useEffect( + const validate = useCallback( values => { if (!validationSchema) { return {} @@ -190,35 +107,81 @@ const Form = props => { [validationSchema, validateSync, validationContext, error], ) + const handleSubmit = useCallback( + async (...args) => { + try { + return await onSubmit(...args) + } catch (error) { + // Make sure all unhandled exceptions during submit are ingested. + ingestError(error, { ingestedBy: 'FormSubmit' }) + + throw error + } + }, + [onSubmit], + ) + + // Initialize formik and get the formik context to provide to form children. + const formik = useFormik({ + initialValues, + validate, + onSubmit: handleSubmit, + onReset, + validateOnMount, + validateOnBlur, + validateSync, + validateOnChange, + enableReinitialize, + }) + + const { + isSubmitting, + isValid, + handleSubmit: handleFormikSubmit, + handleReset: handleFormikReset, + } = formik + + // Connect the ref with the formik context to ensure compatibility with older form components. + // NOTE: New components should not use the ref, but use the form context directly. + // TODO: Remove this once all forms have been refactored to use context. + if (isPlainObject(formikRef) && 'current' in formikRef) { + formikRef.current = formik + } + + useEffect(() => { + // Scroll form notification into view if needed. + if (error) { + scrollIntoView(notificationRef.current, { behavior: 'smooth' }) + notificationRef.current.focus({ preventScroll: true }) + } + + // Scroll invalid fields into view if needed and focus them. + if (!isSubmitting && !isValid) { + const firstErrorNode = document.querySelectorAll('[data-needs-focus="true"]')[0] + if (firstErrorNode) { + scrollIntoView(firstErrorNode, { behavior: 'smooth' }) + firstErrorNode.querySelector('input,textarea,canvas,video').focus({ preventScroll: true }) + } + } + }, [error, isSubmitting, isValid]) + return ( - - {({ handleSubmit, ...restFormikProps }) => ( - - {children} - - )} - +
+ {(error || info) && ( +
+ {error && } + {info && } +
+ )} + {children} +
+ ) } @@ -229,10 +192,10 @@ Form.propTypes = { enableReinitialize: PropTypes.bool, error: PropTypes.error, errorTitle: PropTypes.message, - info: PropTypes.message, - infoTitle: PropTypes.message, formikRef: PropTypes.shape({ current: PropTypes.shape({}) }), id: PropTypes.string, + info: PropTypes.message, + infoTitle: PropTypes.message, initialValues: PropTypes.shape({}), onReset: PropTypes.func, onSubmit: PropTypes.func, diff --git a/pkg/webui/components/form/section/index.js b/pkg/webui/components/form/section/index.js index ba04122be4..879aabe88d 100644 --- a/pkg/webui/components/form/section/index.js +++ b/pkg/webui/components/form/section/index.js @@ -16,13 +16,14 @@ import React from 'react' import classnames from 'classnames' import { defineMessages, useIntl } from 'react-intl' -import { useFormContext } from '@ttn-lw/components/form' import Icon from '@ttn-lw/components/icon' import Message from '@ttn-lw/lib/components/message' import PropTypes from '@ttn-lw/lib/prop-types' +import { useFormContext } from '..' + import style from './section.styl' const m = defineMessages({ diff --git a/pkg/webui/components/form/submit/index.js b/pkg/webui/components/form/submit/index.js index 0e9bbb0d2f..ed8bf55efe 100644 --- a/pkg/webui/components/form/submit/index.js +++ b/pkg/webui/components/form/submit/index.js @@ -12,18 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { useContext } from 'react' +import React from 'react' import PropTypes from '@ttn-lw/lib/prop-types' -import FormContext from '../context' +import { useFormContext } from '..' const FormSubmit = props => { const { component: Component, disabled, ...rest } = props - const formContext = useContext(FormContext) + const formContext = useFormContext() const submitProps = { - isValid: context.isValid, + isValid: formContext.isValid, isSubmitting: formContext.isSubmitting, isValidating: formContext.isValidating, submitCount: formContext.submitCount,