From 8b7812f13a77ce476f0b6120372047e142526b7f Mon Sep 17 00:00:00 2001 From: roman Date: Tue, 18 Aug 2026 16:34:42 +0200 Subject: [PATCH] refactor(form): migrate Form from Flow to TypeScript --- .../form/{Form.js => Form.js.flow} | 32 ++-- .../{Form.stories.js => Form.stories.tsx} | 13 +- src/components/form-elements/form/Form.tsx | 156 ++++++++++++++++++ .../form-elements/form/FormContext.js | 13 -- .../form-elements/form/FormContext.js.flow | 6 + .../form-elements/form/FormContext.ts | 14 ++ .../form/{FormInput.js => FormInput.js.flow} | 15 +- .../form-elements/form/FormInput.tsx | 39 +++++ .../__tests__/{Form.test.js => Form.test.tsx} | 39 ++--- .../{FormInput.test.js => FormInput.test.tsx} | 2 +- .../form/{index.js => index.js.flow} | 0 src/components/form-elements/form/index.ts | 4 + 12 files changed, 260 insertions(+), 73 deletions(-) rename src/components/form-elements/form/{Form.js => Form.js.flow} (80%) rename src/components/form-elements/form/{Form.stories.js => Form.stories.tsx} (90%) create mode 100644 src/components/form-elements/form/Form.tsx delete mode 100644 src/components/form-elements/form/FormContext.js create mode 100644 src/components/form-elements/form/FormContext.js.flow create mode 100644 src/components/form-elements/form/FormContext.ts rename src/components/form-elements/form/{FormInput.js => FormInput.js.flow} (59%) create mode 100644 src/components/form-elements/form/FormInput.tsx rename src/components/form-elements/form/__tests__/{Form.test.js => Form.test.tsx} (82%) rename src/components/form-elements/form/__tests__/{FormInput.test.js => FormInput.test.tsx} (93%) rename src/components/form-elements/form/{index.js => index.js.flow} (100%) create mode 100644 src/components/form-elements/form/index.ts diff --git a/src/components/form-elements/form/Form.js b/src/components/form-elements/form/Form.js.flow similarity index 80% rename from src/components/form-elements/form/Form.js rename to src/components/form-elements/form/Form.js.flow index d2aa40e860..c6c451eed5 100644 --- a/src/components/form-elements/form/Form.js +++ b/src/components/form-elements/form/Form.js.flow @@ -1,10 +1,10 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; +// @flow +import * as React from 'react'; import serialize from 'form-serialize'; import { FormContext } from './FormContext'; -function getFormValidityState(form) { +function getFormValidityState(form: any) { // Turn the form.elements HTMLCollection into Array before reducing return [].slice.call(form.elements).reduce((validityObj, inputEl) => { // Only serialize inputs that have a name defined @@ -26,20 +26,8 @@ function getFormValidityState(form) { }, {}); } -class Form extends Component { - static propTypes = { - children: PropTypes.node, - /** Called when an input in the form changes */ - onChange: PropTypes.func, - /** Called when a valid submit is made */ - onValidSubmit: PropTypes.func.isRequired, - /** Called when an invalid submit is made */ - onInvalidSubmit: PropTypes.func, - /** An object mapping input names to error messages */ - formValidityState: PropTypes.object, // eslint-disable-line react/no-unused-prop-types - }; - - constructor(props) { +class Form extends React.Component { + constructor(props: any) { super(props); this.state = { @@ -47,7 +35,7 @@ class Form extends Component { }; } - componentDidUpdate({ formValidityState: prevFormValidityState }) { + componentDidUpdate({ formValidityState: prevFormValidityState }: any) { const { formValidityState } = this.props; const { registeredInputs } = this.state; @@ -60,7 +48,7 @@ class Form extends Component { } } - onChange = ({ currentTarget }) => { + onChange = ({ currentTarget }: any) => { if (this.props.onChange) { const formData = serialize(currentTarget, { hash: true, @@ -70,7 +58,7 @@ class Form extends Component { } }; - onSubmit = event => { + onSubmit = (event: any) => { const form = event.target; event.preventDefault(); const isValid = form.checkValidity(); @@ -94,7 +82,7 @@ class Form extends Component { } }; - registerInput = (name, setValidityStateHandler) => { + registerInput = (name: string, setValidityStateHandler: Function) => { const { registeredInputs } = this.state; if (registeredInputs[name]) { @@ -106,7 +94,7 @@ class Form extends Component { this.setState(nextState); }; - unregisterInput = name => { + unregisterInput = (name: string) => { const nextState = this.state; delete nextState.registeredInputs[name]; this.setState(nextState); diff --git a/src/components/form-elements/form/Form.stories.js b/src/components/form-elements/form/Form.stories.tsx similarity index 90% rename from src/components/form-elements/form/Form.stories.js rename to src/components/form-elements/form/Form.stories.tsx index d0b243b95c..3094f39d30 100644 --- a/src/components/form-elements/form/Form.stories.js +++ b/src/components/form-elements/form/Form.stories.tsx @@ -1,23 +1,22 @@ -// @flow /* eslint-disable react-hooks/rules-of-hooks */ import * as React from 'react'; -import Button from '../../button/Button'; +import Button, { ButtonType } from '../../button/Button'; import Select from '../../select/Select'; import TextArea from '../text-area/TextArea'; import TextInput from '../text-input/TextInput'; import Toggle from '../../toggle/Toggle'; -import Form from './Form'; +import Form, { FormSerializedData, FormValidityStateMap } from './Form'; import notes from './Form.stories.md'; export const basic = () => { - const [formData, setFormData] = React.useState({ + const [formData, setFormData] = React.useState({ showtextareatoggle: '', }); - const [formValidityState, setFormValidityState] = React.useState({}); + const [formValidityState, setFormValidityState] = React.useState({}); - const customValidationFunc = value => { + const customValidationFunc = (value: string) => { if (value !== 'box') { return { code: 'notbox', @@ -91,7 +90,7 @@ export const basic = () => { ) : null} - + ); }; diff --git a/src/components/form-elements/form/Form.tsx b/src/components/form-elements/form/Form.tsx new file mode 100644 index 0000000000..d0805a3805 --- /dev/null +++ b/src/components/form-elements/form/Form.tsx @@ -0,0 +1,156 @@ +import * as React from 'react'; +// @ts-ignore no types for form-serialize +import serialize from 'form-serialize'; + +import { FormContext } from './FormContext'; + +export type FormSerializedData = Record; + +export type FormInputValidityState = ValidityState & { + customErrorCode?: string; +}; + +export interface FormFieldValidityState { + /** Server-side error code */ + code?: string; + /** Server-side error message */ + message?: string; + /** HTML constraint-validation state from an invalid named input */ + validityState?: FormInputValidityState; +} + +export type FormValidityStateMap = Record; + +export interface FormProps { + /** Form fields and other child content */ + children?: React.ReactNode; + /** An object mapping input names to error messages */ + formValidityState?: FormValidityStateMap; + /** Called when an input in the form changes */ + onChange?: (formData: FormSerializedData) => void; + /** Called when an invalid submit is made */ + onInvalidSubmit?: (formValidityState: FormValidityStateMap) => void; + /** Called when a valid submit is made */ + onValidSubmit: (formData: FormSerializedData) => void; +} + +interface FormState { + registeredInputs: Record void>; +} + +function getFormValidityState(form: HTMLFormElement): FormValidityStateMap { + // Turn the form.elements HTMLCollection into Array before reducing + return [].slice.call(form.elements).reduce((validityObj: FormValidityStateMap, inputEl: HTMLInputElement) => { + // Only serialize inputs that have a name defined + if (inputEl.name && !inputEl.validity.valid) { + const validityState = inputEl.validity as FormInputValidityState; + + if (inputEl.validity.customError) { + // If the input is displaying a custom error, + // we expose the errorCode stored in the validationMessage + validityState.customErrorCode = inputEl.validationMessage; + } + + validityObj[inputEl.name] = { + validityState, + }; + return validityObj; + } + return validityObj; + }, {}); +} + +class Form extends React.Component { + constructor(props: FormProps) { + super(props); + + this.state = { + registeredInputs: {}, + }; + } + + componentDidUpdate({ formValidityState: prevFormValidityState }: FormProps) { + const { formValidityState } = this.props; + const { registeredInputs } = this.state; + + if (formValidityState !== prevFormValidityState) { + Object.keys(formValidityState).forEach(key => { + if (registeredInputs[key]) { + registeredInputs[key](formValidityState[key]); + } + }); + } + } + + onChange = ({ currentTarget }: React.FormEvent) => { + if (this.props.onChange) { + const formData = serialize(currentTarget, { + hash: true, + empty: true, + }); + this.props.onChange(formData); + } + }; + + onSubmit = (event: React.FormEvent) => { + const form = event.target as HTMLFormElement; + event.preventDefault(); + const isValid = form.checkValidity(); + const { onInvalidSubmit, onValidSubmit } = this.props; + const { registeredInputs } = this.state; + + if (isValid) { + const formData = serialize(form, { hash: true, empty: true }); + onValidSubmit(formData); + } else { + const formValidityState = getFormValidityState(form); + + // Push form validity state to inputs so errors are shown on submit + Object.keys(formValidityState).forEach( + key => registeredInputs[key] && registeredInputs[key](formValidityState[key].validityState), + ); + + if (onInvalidSubmit) { + onInvalidSubmit(formValidityState); + } + } + }; + + registerInput = (name: string, setValidityStateHandler: (validityState: unknown) => void) => { + const { registeredInputs } = this.state; + + if (registeredInputs[name]) { + throw new Error(`Input '${name}' is already registered.`); + } + + const nextState = this.state; + nextState.registeredInputs[name] = setValidityStateHandler; + this.setState(nextState); + }; + + unregisterInput = (name: string) => { + const nextState = this.state; + delete nextState.registeredInputs[name]; + this.setState(nextState); + }; + + render() { + const { children } = this.props; + return ( + +
+ {children} +
+
+ ); + } +} + +export default Form; diff --git a/src/components/form-elements/form/FormContext.js b/src/components/form-elements/form/FormContext.js deleted file mode 100644 index 6f0cc7b315..0000000000 --- a/src/components/form-elements/form/FormContext.js +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -export const FormContext = React.createContext(null); - -FormContext.displayName = 'FormContext'; - -export const FormContextPropTypes = { - form: PropTypes.shape({ - registerInput: PropTypes.func.isRequired, - unregisterInput: PropTypes.func.isRequired, - }), -}; diff --git a/src/components/form-elements/form/FormContext.js.flow b/src/components/form-elements/form/FormContext.js.flow new file mode 100644 index 0000000000..ea6a1ae07d --- /dev/null +++ b/src/components/form-elements/form/FormContext.js.flow @@ -0,0 +1,6 @@ +// @flow +import * as React from 'react'; + +export const FormContext = React.createContext(null); + +FormContext.displayName = 'FormContext'; diff --git a/src/components/form-elements/form/FormContext.ts b/src/components/form-elements/form/FormContext.ts new file mode 100644 index 0000000000..5015bb8bc8 --- /dev/null +++ b/src/components/form-elements/form/FormContext.ts @@ -0,0 +1,14 @@ +import * as React from 'react'; + +export interface FormContextValue { + form?: { + /** Registers a named input so the form can push validity updates */ + registerInput: (name: string, setValidityStateHandler: (validityState: unknown) => void) => void; + /** Unregisters a previously registered named input */ + unregisterInput: (name: string) => void; + }; +} + +export const FormContext = React.createContext(null); + +FormContext.displayName = 'FormContext'; diff --git a/src/components/form-elements/form/FormInput.js b/src/components/form-elements/form/FormInput.js.flow similarity index 59% rename from src/components/form-elements/form/FormInput.js rename to src/components/form-elements/form/FormInput.js.flow index db91b2d273..3f8c655c04 100644 --- a/src/components/form-elements/form/FormInput.js +++ b/src/components/form-elements/form/FormInput.js.flow @@ -1,17 +1,9 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; +// @flow +import * as React from 'react'; import { FormContext } from './FormContext'; -class FormInput extends Component { - static propTypes = { - children: PropTypes.node.isRequired, - /** callback called when Form pushed down a new validityState, useful for displaying server validation errors */ - onValidityStateUpdate: PropTypes.func.isRequired, - /** Input name */ - name: PropTypes.string.isRequired, - }; - +class FormInput extends React.Component { componentDidMount() { const { name, onValidityStateUpdate } = this.props; const formContext = this.context; @@ -33,6 +25,7 @@ class FormInput extends Component { } } +// $FlowFixMe contextType is supported at runtime FormInput.contextType = FormContext; export default FormInput; diff --git a/src/components/form-elements/form/FormInput.tsx b/src/components/form-elements/form/FormInput.tsx new file mode 100644 index 0000000000..c92b106c0c --- /dev/null +++ b/src/components/form-elements/form/FormInput.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; + +import { FormContext } from './FormContext'; +import type { FormContextValue } from './FormContext'; + +export interface FormInputProps { + /** Input (or other field) registered with the parent form */ + children: React.ReactNode; + /** Input name */ + name: string; + /** Called when Form pushes down a new validityState, useful for displaying server validation errors */ + onValidityStateUpdate: (validityState: unknown) => void; +} + +class FormInput extends React.Component { + static contextType = FormContext; + + componentDidMount() { + const { name, onValidityStateUpdate } = this.props; + const formContext = this.context as FormContextValue | null; + + if (formContext?.form) { + formContext.form.registerInput(name, onValidityStateUpdate); + } + } + + componentWillUnmount() { + const formContext = this.context as FormContextValue | null; + if (formContext?.form) { + formContext.form.unregisterInput(this.props.name); + } + } + + render() { + return
{this.props.children}
; + } +} + +export default FormInput; diff --git a/src/components/form-elements/form/__tests__/Form.test.js b/src/components/form-elements/form/__tests__/Form.test.tsx similarity index 82% rename from src/components/form-elements/form/__tests__/Form.test.js rename to src/components/form-elements/form/__tests__/Form.test.tsx index e40ee12b7d..c91822925e 100644 --- a/src/components/form-elements/form/__tests__/Form.test.js +++ b/src/components/form-elements/form/__tests__/Form.test.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { mount, shallow } from 'enzyme'; +import noop from 'lodash/noop'; import sinon from 'sinon'; import Form from '..'; @@ -13,12 +14,12 @@ describe('components/form-elements/form/Form', () => { test('should correctly render default component', () => { const wrapper = shallow( -
{}} onValidSubmit={() => {}}> +
, ); - expect(wrapper.find('form').length).toEqual(1); + expect(wrapper.find('form')).toHaveLength(1); expect(wrapper.find('form').prop('noValidate')).toBeTruthy(); }); @@ -55,7 +56,7 @@ describe('components/form-elements/form/Form', () => { ); const form = wrapper.find('form'); - const formEl = form.getDOMNode(); + const formEl = form.getDOMNode() as HTMLFormElement; formEl.checkValidity = () => true; form.simulate('submit', { target: formEl }); @@ -74,7 +75,7 @@ describe('components/form-elements/form/Form', () => { ); const form = wrapper.find('form'); - const formEl = form.getDOMNode(); + const formEl = form.getDOMNode() as HTMLFormElement; formEl.checkValidity = () => false; form.simulate('submit', { target: formEl }); @@ -99,7 +100,7 @@ describe('components/form-elements/form/Form', () => { ); const form = wrapper.find('form'); - const formEl = form.getDOMNode(); + const formEl = form.getDOMNode() as HTMLFormElement; formEl.checkValidity = () => true; form.simulate('submit', { target: formEl }); }); @@ -119,19 +120,19 @@ describe('components/form-elements/form/Form', () => { ); const form = wrapper.find('form'); - const formEl = form.getDOMNode(); + const formEl = form.getDOMNode() as HTMLFormElement; formEl.checkValidity = () => false; form.simulate('submit', { target: formEl }); }); test('should expose form register/unregister function on the context', () => { const wrapper = mount( -
{}} onValidSubmit={() => {}}> +
, ); - const instance = wrapper.find('Form').instance(); + const instance = wrapper.find('Form').instance() as Form; expect(instance.registerInput).toBeTruthy(); expect(instance.unregisterInput).toBeTruthy(); @@ -139,26 +140,26 @@ describe('components/form-elements/form/Form', () => { test('should register an input when registerInput is called', () => { const wrapper = mount( -
{}} onValidSubmit={() => {}}> +
, ); const inputHandlerSpy = sinon.spy(); - const instance = wrapper.find('Form').instance(); + const instance = wrapper.find('Form').instance() as Form; instance.registerInput('testinput', inputHandlerSpy); expect(instance.state.registeredInputs.testinput).toBe(inputHandlerSpy); }); test('should correctly register multiple inputs when registerInput is called', () => { const wrapper = mount( -
{}} onValidSubmit={() => {}}> +
, ); const inputHandlerSpy = sinon.spy(); - const instance = wrapper.find('Form').instance(); + const instance = wrapper.find('Form').instance() as Form; instance.registerInput('testinput1', inputHandlerSpy); instance.registerInput('testinput2', inputHandlerSpy); expect(instance.state.registeredInputs.testinput1).toBe(inputHandlerSpy); @@ -167,31 +168,31 @@ describe('components/form-elements/form/Form', () => { test('should throw an error if registerInput is called for already registered input', done => { const wrapper = mount( -
{}} onValidSubmit={() => {}}> +
, ); - const instance = wrapper.find('Form').instance(); - instance.registerInput('testinput', () => {}); + const instance = wrapper.find('Form').instance() as Form; + instance.registerInput('testinput', noop); try { - instance.registerInput('testinput', () => {}); + instance.registerInput('testinput', noop); } catch (e) { - expect(e.message).toEqual("Input 'testinput' is already registered."); + expect((e as Error).message).toEqual("Input 'testinput' is already registered."); done(); } }); test('should unregister an input when unregisterInput is called', () => { const wrapper = mount( -
{}} onValidSubmit={() => {}}> +
, ); const inputHandlerSpy = sinon.spy(); - const instance = wrapper.find('Form').instance(); + const instance = wrapper.find('Form').instance() as Form; instance.registerInput('testinput', inputHandlerSpy); expect(instance.state.registeredInputs.testinput).toBe(inputHandlerSpy); instance.unregisterInput('testinput'); diff --git a/src/components/form-elements/form/__tests__/FormInput.test.js b/src/components/form-elements/form/__tests__/FormInput.test.tsx similarity index 93% rename from src/components/form-elements/form/__tests__/FormInput.test.js rename to src/components/form-elements/form/__tests__/FormInput.test.tsx index 2e55836a11..5c08b92f2b 100644 --- a/src/components/form-elements/form/__tests__/FormInput.test.js +++ b/src/components/form-elements/form/__tests__/FormInput.test.tsx @@ -35,7 +35,7 @@ describe('components/form-elements/form/FormInput', () => { const component = mount( - + Children , diff --git a/src/components/form-elements/form/index.js b/src/components/form-elements/form/index.js.flow similarity index 100% rename from src/components/form-elements/form/index.js rename to src/components/form-elements/form/index.js.flow diff --git a/src/components/form-elements/form/index.ts b/src/components/form-elements/form/index.ts new file mode 100644 index 0000000000..f9ca1ce37a --- /dev/null +++ b/src/components/form-elements/form/index.ts @@ -0,0 +1,4 @@ +export { default as FormInput } from './FormInput'; +export type { FormInputProps } from './FormInput'; +export { default } from './Form'; +export type { FormProps } from './Form';