Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions src/components/search-form/SearchForm.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>,
'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<HTMLDivElement>;
/** 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<HTMLFormElement>) => void;
/** Extra query parameters in addition to the form data */
queryParams: Record<string, string>;
/** 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<SearchFormProps, 'action' | 'method' | 'name' | 'queryParams' | 'useClearButton'>;

type SearchFormConfig = Omit<SearchFormProps, keyof SearchFormDefaultProps | 'intl'> & Partial<SearchFormDefaultProps>;

class SearchFormBase extends React.Component<SearchFormProps, SearchFormState> {
static readonly defaultProps: SearchFormDefaultProps = {
action: '',
method: 'get',
name: 'search',
queryParams: {},
useClearButton: false,
};

state = {
isEmpty: true,
};

static getDerivedStateFromProps(props: SearchFormProps): Partial<SearchFormState> | null {
const { value } = props;

if (value && !!value.trim()) {
return {
isEmpty: true,
};
}

return null;
}

onClearHandler = (event: React.SyntheticEvent<HTMLButtonElement>) => {
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<HTMLFormElement>) => {
const { value } = event.target as HTMLInputElement;
const { onChange } = this.props;
this.setState({ isEmpty: !value?.trim().length });

if (onChange) {
onChange(value);
}
};

onSubmitHandler = (event: React.FormEvent<HTMLFormElement>) => {
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) => (
<input key={index} name={param} type="hidden" value={queryParams[param]} />
));

// @NOTE Prevent errors from React about controlled inputs
const onChangeStub = () => undefined;

return (
<div ref={innerRef} className={classes}>
<form
action={action}
className={formClassNames}
method={method}
onChange={this.onChangeHandler}
onSubmit={this.onSubmitHandler}
role="search"
>
<input
ref={this.setInputRef}
aria-label={formatMessage(messages.searchLabel)}
autoComplete="off"
className="search-input"
name={name}
onChange={onChangeStub}
type="search"
{...inputProps}
/>
<SearchActions
hasSubmitAction={!!onSubmit}
isLoading={isLoading}
loadingIndicatorProps={{
className: 'search-form-loading-indicator',
}}
onClear={this.onClearHandler}
/>
{hiddenInputs}
</form>
</div>
);
}
}

const SearchFormBaseIntl = injectIntl(SearchFormBase) as React.ComponentType<SearchFormConfig>;
export { SearchFormBaseIntl };

const SearchForm = React.forwardRef<HTMLDivElement, SearchFormConfig>((props, ref) => (
<SearchFormBaseIntl {...props} innerRef={ref} />
));
SearchForm.displayName = 'SearchForm';

export default SearchForm;
Original file line number Diff line number Diff line change
Expand Up @@ -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<Partial<Omit<SearchFormProps, 'intl'>>>;

describe('components/search-form/SearchForm', () => {
beforeEach(() => {
clock = sinon.useFakeTimers();
});
interface SearchFormInstance extends React.Component<SearchFormProps, { isEmpty: boolean }> {
onChangeHandler: (event: { target: { value: string | null } }) => void;
onClearHandler: (event?: React.SyntheticEvent<HTMLButtonElement>) => 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', () => {
Expand All @@ -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', () => {
Expand All @@ -59,7 +51,7 @@ describe('components/search-form/SearchForm', () => {
},
],
},
};
} as const;
const onSubmitMock = jest.fn();

afterEach(() => {
Expand Down Expand Up @@ -109,34 +101,30 @@ 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(<SearchForm name="query" placeholder="search" queryParams={queryParams} />);
const inputs = wrapper.find('input');
expect(inputs.at(0).prop('name')).toEqual('query');
expect(inputs.at(1).prop('name')).toEqual('token');
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(<SearchForm intl={intlShape} />).shallow();
const wrapper = shallow(<SearchForm />).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(<SearchForm intl={intlShape} value="test" />).shallow();
const wrapper = shallow(<SearchForm value="test" />).shallow();
wrapper.setState({ isEmpty: false });

wrapper.setProps({ value: '' });
Expand All @@ -148,33 +136,37 @@ describe('components/search-form/SearchForm', () => {
describe('onClearHandler()', () => {
test('should trigger onChange with empty string', () => {
const onChange = sandbox.spy();
const wrapper = shallow(<SearchForm intl={intlShape} onChange={onChange} />).shallow();
wrapper.instance().searchInput = { value: 'abc' };
wrapper.instance().onClearHandler();
const wrapper = shallow(<SearchForm onChange={onChange} />).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(<SearchForm intl={intlShape} name="query" />).shallow();
const instance = wrapper.instance();
const wrapper = shallow(<SearchForm name="query" />).shallow();
const instance = getSearchFormInstance(wrapper);
instance.setState({ isEmpty: false });
instance.searchInput = { value: 'abc' };
instance.searchInput = document.createElement('input');
instance.searchInput.value = 'abc';

instance.onClearHandler();

expect(wrapper.state('isEmpty')).toBe(true);
});

test('should stop propagation if stopDefaultEvent param is passed', () => {
const wrapper = shallow(
<SearchForm intl={intlShape} name="query" shouldPreventClearEventPropagation />,
).shallow();
const instance = wrapper.instance();
const wrapper = shallow(<SearchForm name="query" shouldPreventClearEventPropagation />).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<HTMLButtonElement>);

expect(stopPropagationStub.calledOnce).toBe(true);
});
Expand Down Expand Up @@ -208,8 +200,8 @@ describe('components/search-form/SearchForm', () => {
},
].forEach(({ value, isEmpty }) => {
test('should set isEmpty state correctly', () => {
const wrapper = shallow(<SearchForm intl={intlShape} name="query" />).shallow();
const instance = wrapper.instance();
const wrapper = shallow(<SearchForm name="query" />).shallow();
const instance = getSearchFormInstance(wrapper);

instance.onChangeHandler({
target: {
Expand All @@ -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(<SearchForm isLoading placeholder="search" />);

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 = () => <div className="search-input" />;
const searchInputMock = document.createElement('input');
const wrapper = shallow(<SearchForm getSearchInput={getSearchInputSpy} />).shallow();

wrapper.instance().setInputRef(searchInputMock);
getSearchFormInstance(wrapper).setInputRef(searchInputMock);

expect(getSearchInputSpy.calledOnce).toBe(true);
});
Expand All @@ -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');
});
});
Loading
Loading