Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More form updates #5678

Merged
merged 5 commits into from
Aug 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 65 additions & 17 deletions pkg/webui/components/form/field/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import React, { useCallback } from 'react'
import React, { useCallback, useEffect, useMemo } from 'react'
import classnames from 'classnames'
import { useField } from 'formik'
import { isPlainObject } from 'lodash'
import { isPlainObject, pick, isEmpty, at, compact, get } from 'lodash'

import Message from '@ttn-lw/lib/components/message'

Expand Down Expand Up @@ -69,6 +68,9 @@ const extractValue = value => {
return newValue
}

const defaultValueSetter = ({ setFieldValue, setValues }, { name, names, value }) =>
names.length > 1 ? setValues(values => ({ ...values, ...value })) : setFieldValue(name, value)

const FormField = props => {
const {
className,
Expand All @@ -86,6 +88,7 @@ const FormField = props => {
tooltipId,
warning,
validate,
valueSetter,
onChange,
onBlur,
} = props
Expand All @@ -95,17 +98,39 @@ const FormField = props => {
validateOnBlur,
setFieldValue,
setFieldTouched,
setValues,
values,
errors: formErrors,
registerField,
unregisterField,
touched: formTouched,
} = useFormContext()

// Initialize field, which also takes care of registering fields in formik's internal registry.
const [{ value: encodedValue }, { touched, error = false }] = useField({
name,
validate,
})
// Generate streamlined `names` variable to handle both composite and simple fields.
const names = useMemo(() => name.split(','), [name])
const isCompositeField = names.length > 1

// Extract field state.
const errors = compact(at(formErrors, names))
const touched = at(formTouched, names).some(Boolean)
const encodedValue = isCompositeField ? pick(values, names) : get(values, name)

// Register field(s) in formiks internal field registry.
useEffect(() => {
for (const name of names) {
registerField(name, { validate })
}
return () => {
for (const name of names) {
unregisterField(name)
}
}
}, [names, registerField, unregisterField, validate])

const handleChange = useCallback(
async (value, enforceValidation = false) => {
const newValue = encode(extractValue(value))
const oldValue = encodedValue
const newValue = encode(extractValue(value), oldValue)
let isSyntheticEvent = false

if (isPlainObject(value)) {
Expand All @@ -119,35 +144,56 @@ const FormField = props => {
}
}

await setFieldValue(name, newValue)
// This middleware takes care of updating the form values and allows for more control
// over how the form values are changed if needed. See the default prop to understand
// how the value is set by default.
await valueSetter(
{ setFieldValue, setValues, setFieldTouched },
{ name, names, value: newValue, oldValue },
)

if (enforceValidation) {
setFieldTouched(name, true, true)
for (const name of names) {
setFieldTouched(name, true, true)
}
}

onChange(isSyntheticEvent ? value : encode(value))
onChange(isSyntheticEvent ? value : encode(value, oldValue))
},
[encode, name, onChange, setFieldTouched, setFieldValue],
[
encode,
encodedValue,
name,
names,
onChange,
setFieldTouched,
setFieldValue,
setValues,
valueSetter,
],
)

const handleBlur = useCallback(
event => {
if (validateOnBlur) {
const value = extractValue(event)
setFieldTouched(name, !isValueEmpty(value))
for (const name of names) {
setFieldTouched(name, !isValueEmpty(value))
}
}

onBlur(event)
},
[validateOnBlur, onBlur, setFieldTouched, name],
[validateOnBlur, onBlur, names, setFieldTouched],
)

const value = decode(encodedValue)
const disabled = inputDisabled || formDisabled
const hasTooltip = Boolean(tooltipId)
const hasTitle = Boolean(title)
const showError = touched && Boolean(error)
const showWarning = !Boolean(error) && Boolean(warning)
const showError = touched && !isEmpty(errors)
const showWarning = !isEmpty(errors) && Boolean(warning)
const error = showError && errors[0]
const showDescription = !showError && !showWarning && Boolean(description)
const tooltipIcon = hasTooltip ? <Tooltip id={tooltipId} glossaryTerm={title} /> : null
const describedBy = showError
Expand Down Expand Up @@ -258,6 +304,7 @@ FormField.propTypes = {
titleChildren: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
tooltipId: PropTypes.string,
validate: PropTypes.func,
valueSetter: PropTypes.func,
warning: PropTypes.message,
}

Expand All @@ -276,6 +323,7 @@ FormField.defaultProps = {
titleChildren: null,
tooltipId: '',
validate: undefined,
valueSetter: defaultValueSetter,
warning: '',
}

Expand Down
79 changes: 69 additions & 10 deletions pkg/webui/components/form/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, useEffect } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import {
yupToFormErrors,
useFormikContext,
Expand All @@ -22,7 +22,7 @@ import {
} from 'formik'
import scrollIntoView from 'scroll-into-view-if-needed'
import { defineMessages } from 'react-intl'
import { isPlainObject } from 'lodash'
import { isPlainObject, isFunction, pick, omitBy, pull } from 'lodash'

import Notification from '@ttn-lw/components/notification'
import ErrorNotification from '@ttn-lw/components/error-notification'
Expand Down Expand Up @@ -50,6 +50,7 @@ const Form = props => {
error,
errorTitle,
formikRef,
hiddenFields,
id,
info,
infoTitle,
Expand All @@ -60,11 +61,14 @@ const Form = props => {
validateOnChange,
validateOnMount,
validateSync,
validationContext,
validationContext: initialValidationContext,
validationSchema,
validateAgainstCleanedValues,
} = props

const notificationRef = React.useRef()
const [fieldRegistry, setFieldRegistry] = useState(hiddenFields)
const [validationContext, setValidationContext] = useState(initialValidationContext)

// Recreate the validation hook to allow passing down validation contexts.
const validate = useCallback(
Expand All @@ -73,9 +77,14 @@ const Form = props => {
return {}
}

// If wished, validate against cleaned values. This flag is used solely for backwards
// compatibility and new forms should always validate against cleaned values.
// TODO: Refactor forms so that cleaned values can be used always.
const validateValues = validateAgainstCleanedValues ? pick(values, fieldRegistry) : values

if (validateSync) {
try {
validateYupSchema(values, validationSchema, validateSync, validationContext)
validateYupSchema(validateValues, validationSchema, validateSync, validationContext)

return {}
} catch (err) {
Expand All @@ -88,7 +97,7 @@ const Form = props => {
}

return new Promise((resolve, reject) => {
validateYupSchema(values, validationSchema, validateSync, validationContext).then(
validateYupSchema(validateValues, validationSchema, validateSync, validationContext).then(
() => {
resolve({})
},
Expand All @@ -104,21 +113,32 @@ const Form = props => {
)
})
},
[validationSchema, validateSync, validationContext, error],
[
validationSchema,
validateAgainstCleanedValues,
fieldRegistry,
validateSync,
validationContext,
error,
],
)

// Recreate form submit handler to enable stripping values as well as error logging.
const handleSubmit = useCallback(
async (...args) => {
(values, formikBag) => {
try {
return await onSubmit(...args)
// Compose clean values as well, which do not contain values of unmounted
// fields, as well as pseudo values (starting with `_`).
const cleanedValues = omitBy(pick(values, fieldRegistry), (_, key) => key.startsWith('_'))
return onSubmit(values, formikBag, cleanedValues)
} catch (error) {
// Make sure all unhandled exceptions during submit are ingested.
ingestError(error, { ingestedBy: 'FormSubmit' })

throw error
}
},
[onSubmit],
[fieldRegistry, onSubmit],
)

// Initialize formik and get the formik context to provide to form children.
Expand All @@ -139,8 +159,38 @@ const Form = props => {
isValid,
handleSubmit: handleFormikSubmit,
handleReset: handleFormikReset,
registerField: registerFormikField,
unregisterField: unregisterFormikField,
} = formik

const addToFieldRegistry = useCallback(name => {
setFieldRegistry(fieldRegistry => [...fieldRegistry, name])
}, [])

const removeFromFieldRegistry = useCallback(name => {
setFieldRegistry(fieldRegistry => pull([...fieldRegistry], name))
}, [])

// Recreate field registration, so the component can keep track of registered fields,
// allowing automatic removal of unused field values from the value set if wished.
const registerField = useCallback(
(name, validate) => {
registerFormikField(name, validate)
addToFieldRegistry(name)
},
[addToFieldRegistry, registerFormikField],
)

// Recreate field registration, so the component can keep track of registered fields,
// allowing automatic removal of unused field values from the value set if wished.
const unregisterField = useCallback(
name => {
unregisterFormikField(name)
removeFromFieldRegistry(name)
},
[removeFromFieldRegistry, unregisterFormikField],
)

// 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.
Expand Down Expand Up @@ -169,7 +219,12 @@ const Form = props => {
<FormikProvider
value={{
disabled,
addToFieldRegistry,
removeFromFieldRegistry,
...formik,
registerField,
unregisterField,
setValidationContext,
}}
>
<form className={className} id={id} onSubmit={handleFormikSubmit} onReset={handleFormikReset}>
Expand All @@ -179,7 +234,7 @@ const Form = props => {
{info && <Notification content={info} title={infoTitle} info small />}
</div>
)}
{children}
{isFunction(children) ? children(formik) : children}
</form>
</FormikProvider>
)
Expand All @@ -193,12 +248,14 @@ Form.propTypes = {
error: PropTypes.error,
errorTitle: PropTypes.message,
formikRef: PropTypes.shape({ current: PropTypes.shape({}) }),
hiddenFields: PropTypes.arrayOf(PropTypes.string),
id: PropTypes.string,
info: PropTypes.message,
infoTitle: PropTypes.message,
initialValues: PropTypes.shape({}),
onReset: PropTypes.func,
onSubmit: PropTypes.func,
validateAgainstCleanedValues: PropTypes.bool,
validateOnBlur: PropTypes.bool,
validateOnChange: PropTypes.bool,
validateOnMount: PropTypes.bool,
Expand All @@ -213,13 +270,15 @@ Form.defaultProps = {
enableReinitialize: false,
error: undefined,
errorTitle: m.submitFailed,
hiddenFields: [],
info: undefined,
infoTitle: undefined,
formikRef: undefined,
id: undefined,
initialValues: undefined,
onReset: () => null,
onSubmit: () => null,
validateAgainstCleanedValues: false,
validateOnBlur: true,
validateOnChange: false,
validateOnMount: false,
Expand Down