From 909b24c0f3f3ad035a4a1674d34f829fa8f40f81 Mon Sep 17 00:00:00 2001 From: roman Date: Tue, 18 Aug 2026 17:07:18 +0200 Subject: [PATCH] refactor(form-elements-text-input): migrate TextInput from Flow to TypeScript --- .../{TextInput.js => TextInput.js.flow} | 0 ...Input.stories.js => TextInput.stories.tsx} | 3 +- .../form-elements/text-input/TextInput.tsx | 230 ++++++++++++++++++ .../{TextInput.test.js => TextInput.test.tsx} | 47 ++-- .../text-input/{index.js => index.js.flow} | 0 .../form-elements/text-input/index.ts | 2 + 6 files changed, 259 insertions(+), 23 deletions(-) rename src/components/form-elements/text-input/{TextInput.js => TextInput.js.flow} (100%) rename src/components/form-elements/text-input/{TextInput.stories.js => TextInput.stories.tsx} (97%) create mode 100644 src/components/form-elements/text-input/TextInput.tsx rename src/components/form-elements/text-input/__tests__/{TextInput.test.js => TextInput.test.tsx} (86%) rename src/components/form-elements/text-input/{index.js => index.js.flow} (100%) create mode 100644 src/components/form-elements/text-input/index.ts diff --git a/src/components/form-elements/text-input/TextInput.js b/src/components/form-elements/text-input/TextInput.js.flow similarity index 100% rename from src/components/form-elements/text-input/TextInput.js rename to src/components/form-elements/text-input/TextInput.js.flow diff --git a/src/components/form-elements/text-input/TextInput.stories.js b/src/components/form-elements/text-input/TextInput.stories.tsx similarity index 97% rename from src/components/form-elements/text-input/TextInput.stories.js rename to src/components/form-elements/text-input/TextInput.stories.tsx index 674abda03c..02d35cdb0c 100644 --- a/src/components/form-elements/text-input/TextInput.stories.js +++ b/src/components/form-elements/text-input/TextInput.stories.tsx @@ -1,4 +1,3 @@ -// @flow import * as React from 'react'; import TextInput from './TextInput'; @@ -17,7 +16,7 @@ export const basic = () => ( export const urlInput = () => ; export const withCustomValidation = () => { - const customValidationFunc = value => { + const customValidationFunc = (value: string) => { if (value !== 'box') { return { code: 'notbox', diff --git a/src/components/form-elements/text-input/TextInput.tsx b/src/components/form-elements/text-input/TextInput.tsx new file mode 100644 index 0000000000..8cf369d3b7 --- /dev/null +++ b/src/components/form-elements/text-input/TextInput.tsx @@ -0,0 +1,230 @@ +import * as React from 'react'; + +import TextInputCore from '../../text-input'; + +// @ts-ignore flow import +import * as messages from '../input-messages'; +// @ts-ignore flow import +import FormInput from '../form/FormInput'; + +export interface TextInputValidationError { + /** Error code used with HTML constraint validation */ + code: string; + /** Message displayed in the error tooltip */ + message: React.ReactNode; +} + +export interface TextInputProps { + /** Whether to automatically focus the input */ + autoFocus?: boolean; + /** Add a class to the component */ + className?: string; + /** Hides the visible label (label remains accessible) */ + hideLabel?: boolean; + /** Whether the input is disabled */ + isDisabled?: boolean; + /** Whether to show a loading indicator */ + isLoading?: boolean; + /** Whether the input is read-only */ + isReadOnly?: boolean; + /** Is input required */ + isRequired?: boolean; + /** Label displayed for the text input */ + label: React.ReactNode; + /** Tooltip shown on the label */ + labelTooltip?: React.ReactNode; + /** Maximum character length */ + maxLength?: number; + /** Minimum character length */ + minLength?: number; + /** Name of the text input */ + name: string; + /** Called when the text input is focused */ + onFocus?: (event: React.FocusEvent) => void; + /** html5 regex pattern for validation */ + pattern?: string; + /** Placeholder for the text input */ + placeholder?: string; + /** html input types (email, url, text, number), defaults to 'text' */ + type?: string; + /** Custom validation. Returns `{ code, message }` when invalid, or a falsy value when valid. */ + validation?: (value: string) => TextInputValidationError | null | undefined; + /** Value of the text input */ + value: string; +} + +interface TextInputState { + error: TextInputValidationError | null | undefined; + value: string; +} + +class TextInput extends React.Component { + static defaultProps = { + autoFocus: false, + value: '', + type: 'text', + isReadOnly: false, + isLoading: false, + }; + + constructor(props: TextInputProps) { + super(props); + this.state = { + error: null, + value: props.value, + }; + } + + componentDidUpdate(prevProps: TextInputProps) { + // If a new value is passed by prop, set it + if (prevProps.value !== this.props.value) { + this.setState({ + value: this.props.value, + }); + } + } + + onChange = ({ currentTarget }: React.SyntheticEvent) => { + const { value } = currentTarget; + if (this.state.error) { + this.setState( + { + value, + }, + this.checkValidity, + ); + } else { + this.setState({ + value, + }); + } + }; + + onValidityStateUpdateHandler = (error: ValidityState | TextInputValidationError) => { + if ((error as ValidityState).valid !== undefined) { + this.setErrorFromValidityState(error as ValidityState); + } else { + this.setState({ + error: error as TextInputValidationError, + }); + } + }; + + setErrorFromValidityState(validityState: ValidityState) { + const { badInput, customError, patternMismatch, tooLong, tooShort, typeMismatch, valid, valueMissing } = + validityState; + + const { isRequired, minLength, maxLength, type, validation } = this.props; + + const { value } = this.state; + + let error: TextInputValidationError | null | undefined; + + if (valid) { + error = null; + } else if (badInput) { + error = messages.badInput(); + } else if (patternMismatch) { + error = messages.patternMismatch(); + } else if (tooShort && typeof minLength !== 'undefined') { + error = messages.tooShort(minLength); + } else if (tooLong && typeof maxLength !== 'undefined') { + error = messages.tooLong(maxLength); + } else if (typeMismatch && type === 'email') { + error = messages.typeMismatchEmail(); + } else if (typeMismatch && type === 'url') { + error = messages.typeMismatchUrl(); + } else if (valueMissing) { + error = messages.valueMissing(); + } else if (customError && (isRequired || value.trim().length) && validation) { + error = validation(value); + } + + this.setState({ + error, + }); + } + + input: HTMLInputElement | null | undefined; + + // Updates component value and validity state + checkValidity = () => { + const { isRequired, validation } = this.props; + const { input } = this; + if (!input) { + return; + } + + if (validation && (isRequired || input.value.trim().length)) { + const error = validation(input.value); + this.setState({ + error, + value: input.value, + }); + + if (error) { + input.setCustomValidity(error.code); + } else { + input.setCustomValidity(''); + } + } else { + this.setErrorFromValidityState(input.validity); + } + }; + + render() { + const { + autoFocus, + className = '', + isDisabled, + isRequired, + label, + maxLength, + minLength, + name, + onFocus, + pattern, + placeholder, + type, + isReadOnly, + isLoading, + labelTooltip, + hideLabel, + } = this.props; + + const { error, value } = this.state; + + return ( +
+ + { + this.input = input; + }} + type={type} + value={value} + readOnly={isReadOnly} + isLoading={isLoading} + labelTooltip={labelTooltip} + hideLabel={hideLabel} + /> + +
+ ); + } +} + +export default TextInput; diff --git a/src/components/form-elements/text-input/__tests__/TextInput.test.js b/src/components/form-elements/text-input/__tests__/TextInput.test.tsx similarity index 86% rename from src/components/form-elements/text-input/__tests__/TextInput.test.js rename to src/components/form-elements/text-input/__tests__/TextInput.test.tsx index 7c25aca20c..9e01149fdd 100644 --- a/src/components/form-elements/text-input/__tests__/TextInput.test.js +++ b/src/components/form-elements/text-input/__tests__/TextInput.test.tsx @@ -3,9 +3,12 @@ import { shallow, mount } from 'enzyme'; import sinon from 'sinon'; import TextInput from '..'; +// @ts-ignore flow import import { FormContext } from '../../form/FormContext'; const sandbox = sinon.sandbox.create(); +const getTextInputInstance = (wrapper: { instance: () => React.Component }) => + wrapper.instance() as InstanceType; describe('components/form-elements/text-input/TextInput', () => { afterEach(() => { @@ -15,7 +18,7 @@ describe('components/form-elements/text-input/TextInput', () => { test('should correctly render default component', () => { const wrapper = shallow(); - expect(wrapper.find('TextInput').length).toEqual(1); + expect(wrapper.find('TextInput')).toHaveLength(1); }); test('should update state if value prop changes', () => { @@ -49,7 +52,7 @@ describe('components/form-elements/text-input/TextInput', () => { wrapper.setProps({ value: 'a' }); input.simulate('blur'); - const inputEl = input.getDOMNode(); + const inputEl = input.getDOMNode() as HTMLInputElement; inputEl.value = 'a'; input.simulate('change', { currentTarget: inputEl, @@ -74,8 +77,8 @@ describe('components/form-elements/text-input/TextInput', () => { test('should mark url fields invalid when invalid', () => { const wrapper = mount(); - const instance = wrapper.instance(); - instance.input = { validity: { typeMismatch: true } }; + const instance = getTextInputInstance(wrapper); + instance.input = { validity: { typeMismatch: true } } as HTMLInputElement; act(() => { instance.checkValidity(); }); @@ -86,8 +89,8 @@ describe('components/form-elements/text-input/TextInput', () => { test('should mark url fields valid when valid', () => { const wrapper = mount(); - const instance = wrapper.instance(); - instance.input = { validity: { valid: true } }; + const instance = getTextInputInstance(wrapper); + instance.input = { validity: { valid: true } } as HTMLInputElement; act(() => { instance.checkValidity(); }); @@ -98,13 +101,15 @@ describe('components/form-elements/text-input/TextInput', () => { }); test('should set an input as valid when the validityFn returns an void', () => { - function validityFn() {} + function validityFn() { + return undefined; + } const wrapper = mount(); const input = wrapper.find('input'); input.simulate('blur'); - expect(input.getDOMNode().validity.valid).toBeTruthy(); + expect((input.getDOMNode() as HTMLInputElement).validity.valid).toBeTruthy(); }); test('should set an input as invalid when the validityFn returns an error string and input is not empty', () => { @@ -119,7 +124,7 @@ describe('components/form-elements/text-input/TextInput', () => { , ); const input = wrapper.find('input'); - const setCustomValiditySpy = jest.spyOn(input.getDOMNode(), 'setCustomValidity'); + const setCustomValiditySpy = jest.spyOn(input.getDOMNode() as HTMLInputElement, 'setCustomValidity'); input.simulate('blur'); expect(setCustomValiditySpy).toHaveBeenCalledWith('errCode'); @@ -133,18 +138,18 @@ describe('components/form-elements/text-input/TextInput', () => { message: 'errMessage', }); - stub.onCall(1).returns(); + stub.onCall(1).returns(undefined); const wrapper = mount(); let input = wrapper.find('input'); - let setCustomValiditySpy = jest.spyOn(input.getDOMNode(), 'setCustomValidity'); + let setCustomValiditySpy = jest.spyOn(input.getDOMNode() as HTMLInputElement, 'setCustomValidity'); input.simulate('blur'); expect(setCustomValiditySpy).toHaveBeenCalledWith('errCode'); // Get the re-rendered input again input = wrapper.find('input'); - setCustomValiditySpy = jest.spyOn(input.getDOMNode(), 'setCustomValidity'); + setCustomValiditySpy = jest.spyOn(input.getDOMNode() as HTMLInputElement, 'setCustomValidity'); input.simulate('blur'); expect(setCustomValiditySpy).toHaveBeenCalledWith(''); @@ -162,7 +167,7 @@ describe('components/form-elements/text-input/TextInput', () => { const input = wrapper.find('input'); input.simulate('blur'); - expect(input.getDOMNode().validity.valid).toBeTruthy(); + expect((input.getDOMNode() as HTMLInputElement).validity.valid).toBeTruthy(); }); test('should set input invalid when the validityFn returns an error string, input is empty and is required', () => { @@ -177,7 +182,7 @@ describe('components/form-elements/text-input/TextInput', () => { , ); const input = wrapper.find('input'); - const setCustomValiditySpy = jest.spyOn(input.getDOMNode(), 'setCustomValidity'); + const setCustomValiditySpy = jest.spyOn(input.getDOMNode() as HTMLInputElement, 'setCustomValidity'); input.simulate('blur'); expect(setCustomValiditySpy).toHaveBeenCalledWith('errCode'); @@ -193,7 +198,7 @@ describe('components/form-elements/text-input/TextInput', () => { wrapper.setProps({ value: 'abba' }); input.simulate('blur'); - const inputEl = input.getDOMNode(); + const inputEl = input.getDOMNode() as HTMLInputElement; inputEl.value = 'a'; input.simulate('change', { currentTarget: inputEl, @@ -210,7 +215,7 @@ describe('components/form-elements/text-input/TextInput', () => { expect(wrapper.find('.text-input-container').hasClass('show-error')).toBeTruthy(); - const inputEl = input.getDOMNode(); + const inputEl = input.getDOMNode() as HTMLInputElement; inputEl.value = 'a'; input.simulate('change', { currentTarget: inputEl, @@ -241,7 +246,7 @@ describe('components/form-elements/text-input/TextInput', () => { validityStateHandlerSpy.callArgWith(1, error); }); - expect(component.find('TextInput').first().instance().state.error).toEqual(error); + expect(getTextInputInstance(component.find('TextInput').first()).state.error).toEqual(error); }); test('should set validity state when set validity state handler is called with ValidityState object', () => { @@ -264,7 +269,7 @@ describe('components/form-elements/text-input/TextInput', () => { act(() => { validityStateHandlerSpy.callArgWith(1, error); }); - expect(component.find('TextInput').first().instance().state.error.code).toEqual('badInput'); + expect(getTextInputInstance(component.find('TextInput').first()).state.error?.code).toEqual('badInput'); }); /** @@ -291,7 +296,7 @@ describe('components/form-elements/text-input/TextInput', () => { act(() => { validityStateHandlerSpy.callArgWith(1, error); }); - expect(component.find('TextInput').first().instance().state.error.code).toEqual('patternMismatch'); + expect(getTextInputInstance(component.find('TextInput').first()).state.error?.code).toEqual('patternMismatch'); }); test('should correctly validate tooLong', () => { @@ -314,7 +319,7 @@ describe('components/form-elements/text-input/TextInput', () => { act(() => { validityStateHandlerSpy.callArgWith(1, error); }); - expect(component.find('TextInput').first().instance().state.error.code).toEqual('tooLong'); + expect(getTextInputInstance(component.find('TextInput').first()).state.error?.code).toEqual('tooLong'); }); test('should correctly validate tooShort', () => { @@ -338,6 +343,6 @@ describe('components/form-elements/text-input/TextInput', () => { act(() => { validityStateHandlerSpy.callArgWith(1, error); }); - expect(component.find('TextInput').first().instance().state.error.code).toEqual('tooShort'); + expect(getTextInputInstance(component.find('TextInput').first()).state.error?.code).toEqual('tooShort'); }); }); diff --git a/src/components/form-elements/text-input/index.js b/src/components/form-elements/text-input/index.js.flow similarity index 100% rename from src/components/form-elements/text-input/index.js rename to src/components/form-elements/text-input/index.js.flow diff --git a/src/components/form-elements/text-input/index.ts b/src/components/form-elements/text-input/index.ts new file mode 100644 index 0000000000..3bb5243696 --- /dev/null +++ b/src/components/form-elements/text-input/index.ts @@ -0,0 +1,2 @@ +export { default } from './TextInput'; +export type { TextInputProps, TextInputValidationError } from './TextInput';