Skip to content

Commit

Permalink
console,account: Refactor form component to make better use of formik
Browse files Browse the repository at this point in the history
  • Loading branch information
kschiffer committed Jun 24, 2022
1 parent bb89027 commit 3fd3b9d
Show file tree
Hide file tree
Showing 6 changed files with 111 additions and 157 deletions.
17 changes: 0 additions & 17 deletions pkg/webui/components/form/context.js

This file was deleted.

27 changes: 17 additions & 10 deletions pkg/webui/components/form/field/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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,
})
Expand All @@ -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)
Expand All @@ -142,7 +150,6 @@ const FormField = props => {
const showWarning = !Boolean(error) && Boolean(warning)
const showDescription = !showError && !showWarning && Boolean(description)
const tooltipIcon = hasTooltip ? <Tooltip id={tooltipId} glossaryTerm={title} /> : null

const describedBy = showError
? `${name}-field-error`
: showWarning
Expand Down
6 changes: 3 additions & 3 deletions pkg/webui/components/form/field/info.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,23 @@
// 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'

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'

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 }))

Expand Down
207 changes: 85 additions & 122 deletions pkg/webui/components/form/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,24 @@
// 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'

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'
Expand All @@ -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 (
<form className={className} onSubmit={handleSubmit} id={id}>
{(formError || formInfo) && (
<div style={{ outline: 'none' }} ref={notificationRef} tabIndex="-1">
{formError && <ErrorNotification content={formError} title={formErrorTitle} small />}
{formInfo && <Notification content={formInfo} title={formInfoTitle} info small />}
</div>
)}
<FormContext.Provider
value={{
formError,
isSubmitting,
isValid,
...rest,
}}
>
{children}
</FormContext.Provider>
</form>
)
}
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,
Expand All @@ -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 {}
Expand Down Expand Up @@ -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 (
<Formik
innerRef={formikRef}
validate={validate}
onSubmit={handleSubmit}
onReset={onReset}
validateOnMount={validateOnMount}
initialValues={initialValues}
validateOnBlur={validateOnBlur}
validateSync={validateSync}
validateOnChange={validateOnChange}
enableReinitialize={enableReinitialize}
<FormikProvider
value={{
disabled,
...formik,
}}
>
{({ handleSubmit, ...restFormikProps }) => (
<InnerForm
className={className}
formError={error}
formErrorTitle={errorTitle}
formInfo={info}
formInfoTitle={infoTitle}
handleSubmit={handleSubmit}
disabled={disabled}
id={id}
{...restFormikProps}
>
{children}
</InnerForm>
)}
</Formik>
<form className={className} id={id} onSubmit={handleFormikSubmit} onReset={handleFormikReset}>
{(error || info) && (
<div style={{ outline: 'none' }} ref={notificationRef} tabIndex="-1">
{error && <ErrorNotification content={error} title={errorTitle} small />}
{info && <Notification content={info} title={infoTitle} info small />}
</div>
)}
{children}
</form>
</FormikProvider>
)
}

Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion pkg/webui/components/form/section/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading

0 comments on commit 3fd3b9d

Please sign in to comment.