From f2898285b3e6ded37b434f2f30bbad735308b203 Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Wed, 22 Jun 2022 14:41:21 +0200 Subject: [PATCH 1/5] console,account: Convert Form component to functional component --- pkg/webui/components/form/index.js | 333 ++++++++++++++--------------- 1 file changed, 163 insertions(+), 170 deletions(-) diff --git a/pkg/webui/components/form/index.js b/pkg/webui/components/form/index.js index 6f53d3e622..d79be50d0b 100644 --- a/pkg/webui/components/form/index.js +++ b/pkg/webui/components/form/index.js @@ -13,7 +13,7 @@ // limitations under the License. /* eslint-disable react/sort-prop-types */ -import React from 'react' +import React, { useCallback, useEffect } from 'react' import { Formik, yupToFormErrors, useFormikContext, validateYupSchema } from 'formik' import bind from 'autobind-decorator' import scrollIntoView from 'scroll-into-view-if-needed' @@ -37,86 +37,78 @@ const m = defineMessages({ submitFailed: 'Submit failed', }) -class InnerForm extends React.PureComponent { - static 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, - } - - static defaultProps = { - className: undefined, - id: undefined, - formInfo: undefined, - formInfoTitle: undefined, - formError: undefined, - formErrorTitle: m.submitFailed, - } - - constructor(props) { - super(props) - this.notificationRef = React.createRef() - } - - componentDidUpdate(prevProps) { - const { formError, isSubmitting, isValid } = this.props - const { isSubmitting: prevIsSubmitting, formError: prevFormError } = prevProps +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 && !prevFormError) { - scrollIntoView(this.notificationRef.current, { behavior: 'smooth' }) - this.notificationRef.current.focus({ preventScroll: true }) + if (formError) { + scrollIntoView(notificationRef.current, { behavior: 'smooth' }) + notificationRef.current.focus({ preventScroll: true }) } // Scroll invalid fields into view if needed and focus them. - if (prevIsSubmitting && !isSubmitting && !isValid) { + 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]) - render() { - const { - className, - children, - formError, - formErrorTitle, - formInfo, - formInfoTitle, - handleSubmit, - id, - ...rest - } = this.props + 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, +} - return ( -
- {(formError || formInfo) && ( -
- {formError && } - {formInfo && } -
- )} - - {children} - -
- ) - } +InnerForm.defaultProps = { + className: undefined, + id: undefined, + formInfo: undefined, + formInfoTitle: undefined, + formError: undefined, + formErrorTitle: m.submitFailed, } const formRenderer = @@ -142,119 +134,120 @@ const formRenderer = ) } -class Form extends React.PureComponent { - static propTypes = { - enableReinitialize: PropTypes.bool, - formikRef: PropTypes.shape({ current: PropTypes.shape({}) }), - initialValues: PropTypes.shape({}), - onReset: PropTypes.func, - onSubmit: PropTypes.func, - validateOnMount: PropTypes.bool, - validateOnBlur: PropTypes.bool, - validateOnChange: PropTypes.bool, - validationSchema: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.func]), - validationContext: PropTypes.shape({}), - validateSync: PropTypes.bool, - } - - static defaultProps = { - enableReinitialize: false, - formikRef: undefined, - initialValues: undefined, - onReset: () => null, - validateOnBlur: true, - validateOnMount: false, - validateOnChange: false, - validationSchema: undefined, - validationContext: {}, - validateSync: true, - onSubmit: () => null, - } - - @bind - async handleSubmit(...args) { - const { onSubmit } = this.props - - try { - return await onSubmit(...args) - } catch (error) { - // Make sure all unhandled exceptions during submit are ingested. - ingestError(error, { ingestedBy: 'FormSubmit' }) - - throw error - } - } - - @bind - validate(values) { - const { validationSchema, validationContext, validateSync } = this.props +const Form = props => { + const { + onReset, + initialValues, + validateOnBlur, + validateOnChange, + validationSchema, + validationContext, + validateOnMount, + formikRef, + enableReinitialize, + onSubmit, + validateSync, + ...rest + } = props - if (!validationSchema) { - return {} - } - - if (validateSync) { + const handleSubmit = useCallback( + async (...args) => { try { - validateYupSchema(values, validationSchema, validateSync, validationContext) - - return {} + return await onSubmit(...args) } catch (error) { - if (error.name === 'ValidationError') { - return yupToFormErrors(error) - } + // Make sure all unhandled exceptions during submit are ingested. + ingestError(error, { ingestedBy: 'FormSubmit' }) throw error } - } + }, + [onSubmit], + ) - return new Promise((resolve, reject) => { - validateYupSchema(values, validationSchema, validateSync, validationContext).then( - () => { - resolve({}) - }, - error => { - // Resolve yup errors, see https://jaredpalmer.com/formik/docs/migrating-v2#validate. + const validate = useEffect( + values => { + if (!validationSchema) { + return {} + } + + if (validateSync) { + try { + validateYupSchema(values, validationSchema, validateSync, validationContext) + + return {} + } catch (error) { if (error.name === 'ValidationError') { - resolve(yupToFormErrors(error)) - } else { - // Throw any other errors as it is not related to the validation process. - reject(error) + return yupToFormErrors(error) } - }, - ) - }) - } - render() { - const { - onReset, - initialValues, - validateOnBlur, - validateOnChange, - validationSchema, - validationContext, - validateOnMount, - formikRef, - enableReinitialize, - ...rest - } = this.props + throw error + } + } - return ( - - {formRenderer(rest)} - - ) - } + return new Promise((resolve, reject) => { + validateYupSchema(values, validationSchema, validateSync, validationContext).then( + () => { + resolve({}) + }, + error => { + // Resolve yup errors, see https://jaredpalmer.com/formik/docs/migrating-v2#validate. + if (error.name === 'ValidationError') { + resolve(yupToFormErrors(error)) + } else { + // Throw any other errors as it is not related to the validation process. + reject(error) + } + }, + ) + }) + }, + [validationSchema, validateSync, validationContext], + ) + + return ( + + {formRenderer(rest)} + + ) +} + +Form.propTypes = { + enableReinitialize: PropTypes.bool, + formikRef: PropTypes.shape({ current: PropTypes.shape({}) }), + initialValues: PropTypes.shape({}), + onReset: PropTypes.func, + onSubmit: PropTypes.func, + validateOnMount: PropTypes.bool, + validateOnBlur: PropTypes.bool, + validateOnChange: PropTypes.bool, + validationSchema: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.func]), + validationContext: PropTypes.shape({}), + validateSync: PropTypes.bool, +} + +Form.defaultProps = { + enableReinitialize: false, + formikRef: undefined, + initialValues: undefined, + onReset: () => null, + validateOnBlur: true, + validateOnMount: false, + validateOnChange: false, + validationSchema: undefined, + validationContext: {}, + validateSync: true, + onSubmit: () => null, } Form.Field = FormField From e0158f0ed1cc8776412d5b99ab5c7f29b32f8f0b Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Wed, 22 Jun 2022 15:31:45 +0200 Subject: [PATCH 2/5] console,account: Simplify form component --- pkg/webui/components/form/index.js | 106 ++++++++++++++++------------- 1 file changed, 59 insertions(+), 47 deletions(-) diff --git a/pkg/webui/components/form/index.js b/pkg/webui/components/form/index.js index d79be50d0b..a893c9358c 100644 --- a/pkg/webui/components/form/index.js +++ b/pkg/webui/components/form/index.js @@ -15,7 +15,6 @@ /* eslint-disable react/sort-prop-types */ import React, { useCallback, useEffect } from 'react' import { Formik, yupToFormErrors, useFormikContext, validateYupSchema } from 'formik' -import bind from 'autobind-decorator' import scrollIntoView from 'scroll-into-view-if-needed' import { defineMessages } from 'react-intl' @@ -111,43 +110,27 @@ InnerForm.defaultProps = { formErrorTitle: m.submitFailed, } -const formRenderer = - ({ children, ...rest }) => - renderProps => { - const { className, error, errorTitle, info, infoTitle, disabled, id } = rest - const { handleSubmit, ...restFormikProps } = renderProps - - return ( - - {children} - - ) - } - const Form = props => { const { - onReset, + children, + className, + disabled, + enableReinitialize, + error, + errorTitle, + formikRef, + id, + info, + infoTitle, initialValues, + onReset, + onSubmit, validateOnBlur, validateOnChange, - validationSchema, - validationContext, validateOnMount, - formikRef, - enableReinitialize, - onSubmit, validateSync, - ...rest + validationContext, + validationSchema, } = props const handleSubmit = useCallback( @@ -175,9 +158,9 @@ const Form = props => { validateYupSchema(values, validationSchema, validateSync, validationContext) return {} - } catch (error) { - if (error.name === 'ValidationError') { - return yupToFormErrors(error) + } catch (err) { + if (err.name === 'ValidationError') { + return yupToFormErrors(err) } throw error @@ -189,19 +172,19 @@ const Form = props => { () => { resolve({}) }, - error => { + err => { // Resolve yup errors, see https://jaredpalmer.com/formik/docs/migrating-v2#validate. - if (error.name === 'ValidationError') { - resolve(yupToFormErrors(error)) + if (err.name === 'ValidationError') { + resolve(yupToFormErrors(err)) } else { // Throw any other errors as it is not related to the validation process. - reject(error) + reject(err) } }, ) }) }, - [validationSchema, validateSync, validationContext], + [validationSchema, validateSync, validationContext, error], ) return ( @@ -217,37 +200,66 @@ const Form = props => { validateOnChange={validateOnChange} enableReinitialize={enableReinitialize} > - {formRenderer(rest)} + {({ handleSubmit, ...restFormikProps }) => ( + + {children} + + )} ) } Form.propTypes = { + children: PropTypes.node.isRequired, + className: PropTypes.string, + disabled: PropTypes.bool, enableReinitialize: PropTypes.bool, + error: PropTypes.error, + errorTitle: PropTypes.message, + info: PropTypes.message, + infoTitle: PropTypes.message, formikRef: PropTypes.shape({ current: PropTypes.shape({}) }), + id: PropTypes.string, initialValues: PropTypes.shape({}), onReset: PropTypes.func, onSubmit: PropTypes.func, - validateOnMount: PropTypes.bool, validateOnBlur: PropTypes.bool, validateOnChange: PropTypes.bool, - validationSchema: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.func]), - validationContext: PropTypes.shape({}), + validateOnMount: PropTypes.bool, validateSync: PropTypes.bool, + validationContext: PropTypes.shape({}), + validationSchema: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.func]), } Form.defaultProps = { + className: undefined, + disabled: false, enableReinitialize: false, + error: undefined, + errorTitle: m.submitFailed, + info: undefined, + infoTitle: undefined, formikRef: undefined, + id: undefined, initialValues: undefined, onReset: () => null, + onSubmit: () => null, validateOnBlur: true, - validateOnMount: false, validateOnChange: false, - validationSchema: undefined, - validationContext: {}, + validateOnMount: false, validateSync: true, - onSubmit: () => null, + validationContext: {}, + validationSchema: undefined, } Form.Field = FormField From bb890271a5446a51f9083d670c2ea0771e480022 Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Wed, 22 Jun 2022 16:46:14 +0200 Subject: [PATCH 3/5] console,account: Convert field component into functional component --- pkg/webui/components/form/field/error.js | 70 ++++ pkg/webui/components/form/field/index.js | 432 +++++++++------------- pkg/webui/components/form/field/info.js | 66 ++-- pkg/webui/components/form/index.js | 3 + pkg/webui/components/form/submit/index.js | 51 ++- 5 files changed, 312 insertions(+), 310 deletions(-) create mode 100644 pkg/webui/components/form/field/error.js diff --git a/pkg/webui/components/form/field/error.js b/pkg/webui/components/form/field/error.js new file mode 100644 index 0000000000..4ed76f4e4b --- /dev/null +++ b/pkg/webui/components/form/field/error.js @@ -0,0 +1,70 @@ +// Copyright © 2022 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 classNames from 'classnames' +import React from 'react' + +import Icon from '@ttn-lw/components/icon' + +import Message from '@ttn-lw/lib/components/message' + +import PropTypes from '@ttn-lw/lib/prop-types' + +import style from './field.styl' + +const FieldError = ({ content, error, warning, title, className, id }) => { + const icon = error ? 'error' : 'warning' + const contentValues = content.values || {} + const classname = classNames(style.message, className, { + [style.show]: content && content !== '', + [style.hide]: !content || content === '', + [style.err]: error, + [style.warn]: warning, + }) + + if (title) { + contentValues.field = + } + + return ( +
+ + +
+ ) +} + +FieldError.propTypes = { + className: PropTypes.string, + content: PropTypes.oneOfType([ + PropTypes.error, + PropTypes.shape({ + message: PropTypes.error.isRequired, + values: PropTypes.shape({}).isRequired, + }), + ]).isRequired, + error: PropTypes.bool, + id: PropTypes.string.isRequired, + title: PropTypes.message, + warning: PropTypes.bool, +} + +FieldError.defaultProps = { + className: undefined, + title: undefined, + warning: false, + error: false, +} + +export default FieldError diff --git a/pkg/webui/components/form/field/index.js b/pkg/webui/components/form/field/index.js index 6bd7f0c22e..d10566fefb 100644 --- a/pkg/webui/components/form/field/index.js +++ b/pkg/webui/components/form/field/index.js @@ -1,4 +1,4 @@ -// Copyright © 2019 The Things Network Foundation, The Things Industries B.V. +// Copyright © 2022 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. @@ -12,12 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React from 'react' -import bind from 'autobind-decorator' +import React, { useCallback, useContext } from 'react' import classnames from 'classnames' -import { getIn } from 'formik' - -import Icon from '@ttn-lw/components/icon' +import { useField } from 'formik' +import { isPlainObject } from 'lodash' import Message from '@ttn-lw/lib/components/message' @@ -27,6 +25,7 @@ import PropTypes from '@ttn-lw/lib/prop-types' import FormContext from '../context' import Tooltip from './tooltip' +import FieldError from './error' import style from './field.styl' @@ -56,284 +55,221 @@ const isValueEmpty = value => { return false } -class FormField extends React.Component { - static contextType = FormContext - - static propTypes = { - className: PropTypes.string, - component: PropTypes.oneOfType([ - PropTypes.func, - PropTypes.string, - PropTypes.shape({ - render: PropTypes.func.isRequired, - }), - ]).isRequired, - decode: PropTypes.func, - description: PropTypes.message, - disabled: PropTypes.bool, - encode: PropTypes.func, - fieldWidth: PropTypes.oneOf([ - 'xxs', - 'xs', - 's', - 'm', - 'l', - 'xl', - 'xxl', - 'full', - 'half', - 'third', - 'quarter', - ]), - name: PropTypes.string.isRequired, - onBlur: PropTypes.func, - onChange: PropTypes.func, - readOnly: PropTypes.bool, - required: PropTypes.bool, - title: PropTypes.message, - titleChildren: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]), - tooltipId: PropTypes.string, - warning: PropTypes.message, - } - - static defaultProps = { - className: undefined, - disabled: false, - encode: value => value, - decode: value => value, - fieldWidth: undefined, - onChange: () => null, - onBlur: () => null, - warning: '', - description: '', - readOnly: false, - required: false, - title: undefined, - titleChildren: null, - tooltipId: '', +const extractValue = value => { + let newValue = value + if (typeof value === 'object' && value !== null && 'target' in value) { + const target = value.target + if ('type' in target && target.type === 'checkbox') { + newValue = target.checked + } else if ('value' in target) { + newValue = target.value + } } - componentDidMount() { - const { name } = this.props + return newValue +} - this.context.registerField(name, this) - } +const FormField = props => { + const { + className, + component: Component, + decode, + description, + disabled: inputDisabled, + encode, + fieldWidth, + name, + readOnly, + required, + title, + titleChildren, + tooltipId, + warning, + validate, + onChange, + onBlur, + } = props + const { disabled: formDisabled, validateOnBlur } = useContext(FormContext) + const [{ value: encodedValue }, { touched, error = false }, { setTouched, setValue }] = useField({ + name, + validate, + }) - componentWillUnmount() { - const { name } = this.props + const handleChange = useCallback( + async (value, enforceValidation = false) => { + const newValue = encode(extractValue(value)) + let isSyntheticEvent = false - this.context.unregisterField(name) - } + if (isPlainObject(value)) { + // Check if the value is react's synthetic event. + isSyntheticEvent = 'target' in value - extractValue(value) { - let newValue = value - if (typeof value === 'object' && value !== null && 'target' in value) { - const target = value.target - if ('type' in target && target.type === 'checkbox') { - newValue = target.checked - } else if ('value' in target) { - newValue = target.value + // TODO: Remove `await` and event persist when https://github.com/jaredpalmer/formik/issues/2457 + // is resolved. + if (typeof value.persist === 'function') { + value.persist() + } } - } - - return newValue - } - - @bind - async handleChange(value, enforceValidation = false) { - const { name, onChange, encode } = this.props - const { setFieldValue, setFieldTouched } = this.context - const newValue = encode(this.extractValue(value)) - let isSyntheticEvent = false + await setValue(newValue) - if (typeof value === 'object' && value !== null) { - // Check if the value is react's synthetic event. - isSyntheticEvent = 'target' in value - - // TODO: Remove `await` and event persist when https://github.com/jaredpalmer/formik/issues/2457 - // is resolved. - if (typeof value.persist === 'function') { - value.persist() + if (enforceValidation) { + setTouched(true, true) } - } - await setFieldValue(name, newValue) - - if (enforceValidation) { - setFieldTouched(name) - } - - onChange(isSyntheticEvent ? value : encode(value)) - } + onChange(isSyntheticEvent ? value : encode(value)) + }, + [setTouched, setValue, onChange, encode], + ) - @bind - handleBlur(event) { - const { name, onBlur } = this.props - const { validateOnBlur, setFieldTouched } = this.context + const handleBlur = useCallback( + event => { + if (validateOnBlur) { + const value = extractValue(event) + setTouched(!isValueEmpty(value)) + } - if (validateOnBlur) { - const value = this.extractValue(event) - setFieldTouched(name, !isValueEmpty(value)) - } + onBlur(event) + }, + [validateOnBlur, onBlur, setTouched], + ) - onBlur(event) + 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 showDescription = !showError && !showWarning && Boolean(description) + const tooltipIcon = hasTooltip ? : null + + const describedBy = showError + ? `${name}-field-error` + : showWarning + ? `${name}-field-warning` + : showDescription + ? `${name}-field-description` + : undefined + + const fieldMessage = showError ? ( +
+ +
+ ) : showWarning ? ( +
+ +
+ ) : showDescription ? ( + + ) : null + + const fieldComponentProps = { + value, + error: showError, + warning: showWarning, + name, + id: name, + disabled, + onChange: handleChange, + onBlur: handleBlur, + readOnly, } - render() { - const { - className, - decode, - fieldWidth, - name, - title, - titleChildren, - warning, - description, - disabled, - required, - readOnly, - tooltipId, - component: Component, - } = this.props - const { disabled: formDisabled } = this.context - - const fieldValue = decode(getIn(this.context.values, name)) - const fieldError = getIn(this.context.errors, name) - const fieldTouched = getIn(this.context.touched, name) || false - const fieldDisabled = disabled || formDisabled - - const hasError = Boolean(fieldError) - const hasWarning = Boolean(warning) - const hasDescription = Boolean(description) - const hasTooltip = Boolean(tooltipId) - const hasTitle = Boolean(title) - - const showError = fieldTouched && hasError - const showWarning = !hasError && hasWarning - const showDescription = !showError && !showWarning && hasDescription - - const describedBy = showError - ? `${name}-field-error` - : showWarning - ? `${name}-field-warning` - : showDescription - ? `${name}-field-description` - : undefined - - const fieldMessage = showError ? ( -
- -
- ) : showWarning ? ( -
- -
- ) : showDescription ? ( - - ) : null - - let tooltipIcon = null - if (hasTooltip) { - tooltipIcon = - } - - const fieldComponentProps = { - value: fieldValue, + const cls = classnames( + className, + style.field, + from(style, { error: showError, warning: showWarning, - name, - id: name, - disabled: fieldDisabled, - onChange: this.handleChange, - onBlur: this.handleBlur, + [`field-width-${fieldWidth}`]: Boolean(fieldWidth), + required, readOnly, - } - - const cls = classnames( - className, - style.field, - from(style, { - error: showError, - warning: showWarning, - [`field-width-${fieldWidth}`]: Boolean(fieldWidth), - required, - readOnly, - hasTooltip, - }), - ) + hasTooltip, + }), + ) - return ( -
- {hasTitle && ( -
- - {tooltipIcon} - {titleChildren} -
- )} -
- + {hasTitle && ( +
+ - {fieldMessage} + {tooltipIcon} + {titleChildren}
+ )} +
+ + {fieldMessage}
- ) - } -} - -const Err = ({ content, error, warning, title, className, id }) => { - const icon = error ? 'error' : 'warning' - const contentValues = content.values || {} - const classname = classnames(style.message, className, { - [style.show]: content && content !== '', - [style.hide]: !content || content === '', - [style.err]: error, - [style.warn]: warning, - }) - - if (title) { - contentValues.field = - } - - return ( -
- -
) } -Err.propTypes = { +FormField.propTypes = { className: PropTypes.string, - content: PropTypes.oneOfType([ - PropTypes.error, + component: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.string, PropTypes.shape({ - message: PropTypes.error.isRequired, - values: PropTypes.shape({}).isRequired, + render: PropTypes.func.isRequired, }), ]).isRequired, - error: PropTypes.bool, - id: PropTypes.string.isRequired, + decode: PropTypes.func, + description: PropTypes.message, + disabled: PropTypes.bool, + encode: PropTypes.func, + fieldWidth: PropTypes.oneOf([ + 'xxs', + 'xs', + 's', + 'm', + 'l', + 'xl', + 'xxl', + 'full', + 'half', + 'third', + 'quarter', + ]), + name: PropTypes.string.isRequired, + onBlur: PropTypes.func, + onChange: PropTypes.func, + readOnly: PropTypes.bool, + required: PropTypes.bool, title: PropTypes.message, - warning: PropTypes.bool, + titleChildren: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]), + tooltipId: PropTypes.string, + validate: PropTypes.func, + warning: PropTypes.message, } -Err.defaultProps = { +FormField.defaultProps = { className: undefined, + decode: value => value, + description: '', + disabled: false, + encode: value => value, + fieldWidth: undefined, + onBlur: () => null, + onChange: () => null, + readOnly: false, + required: false, title: undefined, - warning: false, - error: false, + titleChildren: null, + tooltipId: '', + validate: undefined, + warning: '', } export default FormField diff --git a/pkg/webui/components/form/field/info.js b/pkg/webui/components/form/field/info.js index a9459180cb..4a2586f9b2 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 from 'react' +import React, { useContext } from 'react' import classnames from 'classnames' import Message from '@ttn-lw/lib/components/message' @@ -26,42 +26,38 @@ import Tooltip from './tooltip' import style from './field.styl' -class InfoField extends React.Component { - static contextType = FormContext - - static propTypes = { - children: PropTypes.node.isRequired, - className: PropTypes.string, - disabled: PropTypes.bool, - title: PropTypes.message, - tooltipId: PropTypes.string, - } - - static defaultProps = { - className: undefined, - title: undefined, - disabled: false, - tooltipId: undefined, - } +const InfoField = props => { + const { children, className, title, disabled: fieldDisabled, tooltipId } = props + const { disabled: formDisabled } = useContext(FormContext) + const disabled = formDisabled || fieldDisabled + const cls = classnames(className, style.field, from(style, { disabled })) + + return ( +
+ {title && ( +
+ + {tooltipId && } +
+ )} +
{children}
+
+ ) +} - render() { - const { children, className, title, disabled: fieldDisabled, tooltipId } = this.props - const { disabled: formDisabled } = this.context - const disabled = formDisabled || fieldDisabled - const cls = classnames(className, style.field, from(style, { disabled })) +InfoField.propTypes = { + children: PropTypes.node.isRequired, + className: PropTypes.string, + disabled: PropTypes.bool, + title: PropTypes.message, + tooltipId: PropTypes.string, +} - return ( -
- {title && ( -
- - {tooltipId && } -
- )} -
{children}
-
- ) - } +InfoField.defaultProps = { + className: undefined, + title: undefined, + disabled: false, + tooltipId: undefined, } export default InfoField diff --git a/pkg/webui/components/form/index.js b/pkg/webui/components/form/index.js index a893c9358c..280fff7006 100644 --- a/pkg/webui/components/form/index.js +++ b/pkg/webui/components/form/index.js @@ -80,6 +80,8 @@ const InnerForm = props => { @@ -147,6 +149,7 @@ const Form = props => { [onSubmit], ) + // Recreate the validation hook to allow passing down validation contexts. const validate = useEffect( values => { if (!validationSchema) { diff --git a/pkg/webui/components/form/submit/index.js b/pkg/webui/components/form/submit/index.js index a7a30db52c..0e9bbb0d2f 100644 --- a/pkg/webui/components/form/submit/index.js +++ b/pkg/webui/components/form/submit/index.js @@ -12,41 +12,38 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React from 'react' +import React, { useContext } from 'react' import PropTypes from '@ttn-lw/lib/prop-types' import FormContext from '../context' -class FormSubmit extends React.Component { - static contextType = FormContext - - static propTypes = { - component: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), - disabled: PropTypes.bool, +const FormSubmit = props => { + const { component: Component, disabled, ...rest } = props + const formContext = useContext(FormContext) + + const submitProps = { + isValid: context.isValid, + isSubmitting: formContext.isSubmitting, + isValidating: formContext.isValidating, + submitCount: formContext.submitCount, + dirty: formContext.dirty, + validateForm: formContext.validateForm, + validateField: formContext.validateField, + disabled: formContext.disabled || disabled, } - static defaultProps = { - component: 'button', - disabled: false, - } + return +} - render() { - const { component: Component, disabled, ...rest } = this.props - - const submitProps = { - isValid: this.context.isValid, - isSubmitting: this.context.isSubmitting, - isValidating: this.context.isValidating, - submitCount: this.context.submitCount, - dirty: this.context.dirty, - validateForm: this.context.validateForm, - validateField: this.context.validateField, - disabled: this.context.disabled || disabled, - } - - return - } +FormSubmit.propTypes = { + component: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + disabled: PropTypes.bool, +} + +FormSubmit.defaultProps = { + component: 'button', + disabled: false, } export default FormSubmit From 3fd3b9d331ce6b2acfb54fc08bedf5afcb180c03 Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Thu, 23 Jun 2022 16:19:13 +0200 Subject: [PATCH 4/5] console,account: Refactor form component to make better use of formik --- pkg/webui/components/form/context.js | 17 -- pkg/webui/components/form/field/index.js | 27 ++- pkg/webui/components/form/field/info.js | 6 +- pkg/webui/components/form/index.js | 207 +++++++++------------ pkg/webui/components/form/section/index.js | 3 +- pkg/webui/components/form/submit/index.js | 8 +- 6 files changed, 111 insertions(+), 157 deletions(-) delete mode 100644 pkg/webui/components/form/context.js 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, From 292d6d7d0cc578fa5c2e3946ebbd8ee76b25dd4d Mon Sep 17 00:00:00 2001 From: Kevin Schiffer Date: Thu, 23 Jun 2022 16:19:39 +0200 Subject: [PATCH 5/5] console: Remove unnecessary ref --- pkg/webui/console/components/webhook-template-form/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/webui/console/components/webhook-template-form/index.js b/pkg/webui/console/components/webhook-template-form/index.js index e594e5a478..bed01ea334 100644 --- a/pkg/webui/console/components/webhook-template-form/index.js +++ b/pkg/webui/console/components/webhook-template-form/index.js @@ -130,7 +130,6 @@ export default class WebhookTemplateForm extends Component { validationSchema={validationSchema} initialValues={initialValues} error={error} - formikRef={this.form} >