diff --git a/src/components/search-form/SearchForm.js b/src/components/search-form/SearchForm.js.flow similarity index 100% rename from src/components/search-form/SearchForm.js rename to src/components/search-form/SearchForm.js.flow diff --git a/src/components/search-form/SearchForm.tsx b/src/components/search-form/SearchForm.tsx new file mode 100644 index 0000000000..19c2856b66 --- /dev/null +++ b/src/components/search-form/SearchForm.tsx @@ -0,0 +1,200 @@ +import * as React from 'react'; +import { injectIntl, IntlShape } from 'react-intl'; +import classNames from 'classnames'; +import omit from 'lodash/omit'; + +import SearchActions from './SearchActions'; + +import messages from './messages'; + +import './SearchForm.scss'; + +export interface SearchFormProps + extends Omit< + React.InputHTMLAttributes, + 'className' | 'name' | 'onChange' | 'onSubmit' | 'value' + > { + /** Form submit action */ + action: string; + /** Custom class name for the search input container */ + className?: string; + /** Called with the search input element when its ref changes */ + getSearchInput?: (input: HTMLInputElement | null) => void; + /** Ref attached to the search input container */ + innerRef?: React.Ref; + /** Internationalization utilities */ + intl: IntlShape; + /** Whether to show a loading indicator instead of search actions */ + isLoading?: boolean; + /** The way to send the form data, get or post */ + method: 'get' | 'post'; + /** Name of the text input */ + name: string; + /** On change handler for the search input */ + onChange?: (value: string) => void; + /** On submit handler for the search input */ + onSubmit?: (value: string, event: React.FormEvent) => void; + /** Extra query parameters in addition to the form data */ + queryParams: Record; + /** Whether to prevent propagation of search clear action */ + shouldPreventClearEventPropagation?: boolean; + /** If the clear button is shown when input field is not empty */ + useClearButton: boolean; + /** The value of the input if controlled */ + value?: string; +} + +interface SearchFormState { + isEmpty: boolean; +} + +type SearchFormDefaultProps = Pick; + +type SearchFormConfig = Omit & Partial; + +class SearchFormBase extends React.Component { + static readonly defaultProps: SearchFormDefaultProps = { + action: '', + method: 'get', + name: 'search', + queryParams: {}, + useClearButton: false, + }; + + state = { + isEmpty: true, + }; + + static getDerivedStateFromProps(props: SearchFormProps): Partial | null { + const { value } = props; + + if (value && !!value.trim()) { + return { + isEmpty: true, + }; + } + + return null; + } + + onClearHandler = (event: React.SyntheticEvent) => { + const { onChange, shouldPreventClearEventPropagation } = this.props; + if (shouldPreventClearEventPropagation) { + event.stopPropagation(); + } + + if (this.searchInput) { + this.searchInput.value = ''; + } + this.setState({ isEmpty: true }); + + if (onChange) { + onChange(''); + } + }; + + onChangeHandler = (event: React.FormEvent) => { + const { value } = event.target as HTMLInputElement; + const { onChange } = this.props; + this.setState({ isEmpty: !value?.trim().length }); + + if (onChange) { + onChange(value); + } + }; + + onSubmitHandler = (event: React.FormEvent) => { + const form = event.target as HTMLFormElement; + const { value } = form.elements[0] as HTMLInputElement; + const { onSubmit } = this.props; + + if (onSubmit) { + onSubmit(value, event); + } + }; + + setInputRef = (element: HTMLInputElement | null) => { + this.searchInput = element; + + if (this.props.getSearchInput) { + this.props.getSearchInput(this.searchInput); + } + }; + + searchInput: HTMLInputElement | null | undefined; + + render() { + const { + action, + className, + innerRef, + intl, + isLoading, + method, + name, + queryParams, + onSubmit, + useClearButton, + ...rest + } = this.props; + const { isEmpty } = this.state; + + const inputProps = omit(rest, ['getSearchInput', 'onChange', 'required', 'shouldPreventClearEventPropagation']); + + const { formatMessage } = intl; + const classes = classNames(className, 'search-input-container'); + const formClassNames = classNames('search-form', { + 'is-empty': isEmpty, + 'use-clear-button': useClearButton, + }); + const hiddenInputs = Object.keys(queryParams).map((param, index) => ( + + )); + + // @NOTE Prevent errors from React about controlled inputs + const onChangeStub = () => undefined; + + return ( +
+
+ + + {hiddenInputs} + +
+ ); + } +} + +const SearchFormBaseIntl = injectIntl(SearchFormBase) as React.ComponentType; +export { SearchFormBaseIntl }; + +const SearchForm = React.forwardRef((props, ref) => ( + +)); +SearchForm.displayName = 'SearchForm'; + +export default SearchForm; diff --git a/src/components/search-form/__tests__/SearchForm.test.js b/src/components/search-form/__tests__/SearchForm.test.tsx similarity index 72% rename from src/components/search-form/__tests__/SearchForm.test.js rename to src/components/search-form/__tests__/SearchForm.test.tsx index 494647821c..f7c027fe50 100644 --- a/src/components/search-form/__tests__/SearchForm.test.js +++ b/src/components/search-form/__tests__/SearchForm.test.tsx @@ -2,22 +2,24 @@ import * as React from 'react'; import { mount, shallow } from 'enzyme'; import sinon from 'sinon'; -import { SearchFormBaseIntl as SearchForm } from '../SearchForm'; +import { SearchFormBaseIntl, SearchFormProps } from '../SearchForm'; -let clock; const sandbox = sinon.sandbox.create(); -const intlShape = { - formatMessage: message => message.id, -}; +const SearchForm = SearchFormBaseIntl as React.ComponentType>>; -describe('components/search-form/SearchForm', () => { - beforeEach(() => { - clock = sinon.useFakeTimers(); - }); +interface SearchFormInstance extends React.Component { + onChangeHandler: (event: { target: { value: string | null } }) => void; + onClearHandler: (event?: React.SyntheticEvent) => void; + searchInput: HTMLInputElement | null; + setInputRef: (element: HTMLInputElement | null) => void; +} + +const getSearchFormInstance = (wrapper: { instance: () => React.Component }): SearchFormInstance => + wrapper.instance() as SearchFormInstance; +describe('components/search-form/SearchForm', () => { afterEach(() => { sandbox.verifyAndRestore(); - clock.restore(); }); test('should correctly render default component', () => { @@ -31,18 +33,8 @@ describe('components/search-form/SearchForm', () => { expect(wrapper.find('input').prop('name')).toEqual('search'); expect(wrapper.find('form').hasClass('search-form')).toBeTruthy(); expect(wrapper.find('input').hasClass('search-input')).toBeTruthy(); - expect( - wrapper - .find('button') - .at(0) - .hasClass('search-button'), - ).toBeTruthy(); - expect( - wrapper - .find('button') - .at(1) - .hasClass('clear-button'), - ).toBeTruthy(); + expect(wrapper.find('button').at(0).hasClass('search-button')).toBeTruthy(); + expect(wrapper.find('button').at(1).hasClass('clear-button')).toBeTruthy(); }); test('should render search-button as a div when onSubmit is not present', () => { @@ -59,7 +51,7 @@ describe('components/search-form/SearchForm', () => { }, ], }, - }; + } as const; const onSubmitMock = jest.fn(); afterEach(() => { @@ -109,8 +101,8 @@ describe('components/search-form/SearchForm', () => { test('should generate a hidden input field with correct name and value', () => { const queryParams = { token: '123', - number: 123, - }; + number: '456', + } as const; const wrapper = mount(); const inputs = wrapper.find('input'); expect(inputs.at(0).prop('name')).toEqual('query'); @@ -118,25 +110,21 @@ describe('components/search-form/SearchForm', () => { expect(inputs.at(1).prop('value')).toEqual('123'); expect(inputs.at(1).prop('type')).toEqual('hidden'); expect(inputs.at(2).prop('name')).toEqual('number'); - expect(inputs.at(2).prop('value')).toEqual(123); + expect(inputs.at(2).prop('value')).toEqual('456'); expect(inputs.at(2).prop('type')).toEqual('hidden'); }); test('should set the onClearHandler to the clear button onClick prop', () => { - const wrapper = shallow().shallow(); + const wrapper = shallow().shallow(); // Sift through the nested HOCs to find the correct element - const searchActions = wrapper - .find('LoadableSearchActions') - .dive() - .dive() - .dive(); - const { onClearHandler } = wrapper.instance(); + const searchActions = wrapper.find('LoadableSearchActions').dive().dive().dive(); + const { onClearHandler } = getSearchFormInstance(wrapper); expect(searchActions.find('.clear-button').prop('onClick')).toEqual(onClearHandler); }); describe('componentDidUpdate()', () => { test('should set isEmpty state to true when controlled input becomes empty', () => { - const wrapper = shallow().shallow(); + const wrapper = shallow().shallow(); wrapper.setState({ isEmpty: false }); wrapper.setProps({ value: '' }); @@ -148,17 +136,20 @@ describe('components/search-form/SearchForm', () => { describe('onClearHandler()', () => { test('should trigger onChange with empty string', () => { const onChange = sandbox.spy(); - const wrapper = shallow().shallow(); - wrapper.instance().searchInput = { value: 'abc' }; - wrapper.instance().onClearHandler(); + const wrapper = shallow().shallow(); + const instance = getSearchFormInstance(wrapper); + instance.searchInput = document.createElement('input'); + instance.searchInput.value = 'abc'; + instance.onClearHandler(); sinon.assert.calledWith(onChange, ''); }); test('should set isEmpty state to true', () => { - const wrapper = shallow().shallow(); - const instance = wrapper.instance(); + const wrapper = shallow().shallow(); + const instance = getSearchFormInstance(wrapper); instance.setState({ isEmpty: false }); - instance.searchInput = { value: 'abc' }; + instance.searchInput = document.createElement('input'); + instance.searchInput.value = 'abc'; instance.onClearHandler(); @@ -166,15 +157,16 @@ describe('components/search-form/SearchForm', () => { }); test('should stop propagation if stopDefaultEvent param is passed', () => { - const wrapper = shallow( - , - ).shallow(); - const instance = wrapper.instance(); + const wrapper = shallow().shallow(); + const instance = getSearchFormInstance(wrapper); const stopPropagationStub = sandbox.stub(); instance.setState({ isEmpty: false }); - instance.searchInput = { value: 'abc' }; + instance.searchInput = document.createElement('input'); + instance.searchInput.value = 'abc'; - instance.onClearHandler({ stopPropagation: stopPropagationStub }); + instance.onClearHandler({ + stopPropagation: stopPropagationStub, + } as unknown as React.SyntheticEvent); expect(stopPropagationStub.calledOnce).toBe(true); }); @@ -208,8 +200,8 @@ describe('components/search-form/SearchForm', () => { }, ].forEach(({ value, isEmpty }) => { test('should set isEmpty state correctly', () => { - const wrapper = shallow().shallow(); - const instance = wrapper.instance(); + const wrapper = shallow().shallow(); + const instance = getSearchFormInstance(wrapper); instance.onChangeHandler({ target: { @@ -225,16 +217,16 @@ describe('components/search-form/SearchForm', () => { test('should render loading indicator and not search/clear buttons when isLoading is true', () => { const wrapper = mount(); - expect(wrapper.find('.action-button').length).toEqual(0); - expect(wrapper.find('.search-form-loading-indicator').hostNodes().length).toEqual(1); + expect(wrapper.find('.action-button')).toHaveLength(0); + expect(wrapper.find('.search-form-loading-indicator').hostNodes()).toHaveLength(1); }); test('should call getSearchInput prop when it exists', () => { const getSearchInputSpy = sandbox.spy(); - const searchInputMock = () =>
; + const searchInputMock = document.createElement('input'); const wrapper = shallow().shallow(); - wrapper.instance().setInputRef(searchInputMock); + getSearchFormInstance(wrapper).setInputRef(searchInputMock); expect(getSearchInputSpy.calledOnce).toBe(true); }); @@ -244,6 +236,6 @@ describe('components/search-form/SearchForm', () => { const input = wrapper.find('input'); - expect(input.props().getSearchInput).toBeFalsy(); + expect(input.props()).not.toHaveProperty('getSearchInput'); }); }); diff --git a/src/components/search-form/index.js b/src/components/search-form/index.js.flow similarity index 100% rename from src/components/search-form/index.js rename to src/components/search-form/index.js.flow diff --git a/src/components/search-form/index.ts b/src/components/search-form/index.ts new file mode 100644 index 0000000000..bf52dfc920 --- /dev/null +++ b/src/components/search-form/index.ts @@ -0,0 +1,2 @@ +export { default } from './SearchForm'; +export type { SearchFormProps } from './SearchForm';