2019-06-20 19:37:28 +07:00
|
|
|
import React, { KeyboardEvent, Component, ReactElement } from 'react';
|
|
|
|
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
2019-06-30 05:39:56 +07:00
|
|
|
import { css } from 'emotion';
|
2019-02-03 17:23:33 +07:00
|
|
|
|
|
|
|
import { default as IconSearch } from '@material-ui/icons/Search';
|
|
|
|
import InputAdornment from '@material-ui/core/InputAdornment';
|
|
|
|
import debounce from 'lodash/debounce';
|
|
|
|
|
|
|
|
import API from '../../utils/api';
|
|
|
|
import AutoComplete from '../AutoComplete';
|
|
|
|
import colors from '../../utils/styles/colors';
|
|
|
|
|
2019-06-20 19:37:28 +07:00
|
|
|
export interface State {
|
|
|
|
search: string;
|
2019-06-25 05:54:32 +07:00
|
|
|
suggestions: unknown[];
|
2019-06-20 19:37:28 +07:00
|
|
|
loading: boolean;
|
|
|
|
loaded: boolean;
|
|
|
|
error: boolean;
|
|
|
|
}
|
2019-06-25 05:54:32 +07:00
|
|
|
interface AbortControllerInterface {
|
|
|
|
signal: () => void;
|
|
|
|
abort: () => void;
|
|
|
|
}
|
2019-06-20 19:37:28 +07:00
|
|
|
|
|
|
|
export type cancelAllSearchRequests = () => void;
|
|
|
|
export type handlePackagesClearRequested = () => void;
|
|
|
|
export type handleSearch = (event: KeyboardEvent<HTMLInputElement>, { newValue, method }: { newValue: string; method: string }) => void;
|
|
|
|
export type handleClickSearch = (event: KeyboardEvent<HTMLInputElement>, { suggestionValue, method }: { suggestionValue: object[]; method: string }) => void;
|
|
|
|
export type handleFetchPackages = ({ value: string }) => Promise<void>;
|
|
|
|
export type onBlur = (event: KeyboardEvent<HTMLInputElement>) => void;
|
2019-02-03 17:23:33 +07:00
|
|
|
|
|
|
|
const CONSTANTS = {
|
|
|
|
API_DELAY: 300,
|
|
|
|
PLACEHOLDER_TEXT: 'Search Packages',
|
|
|
|
ABORT_ERROR: 'AbortError',
|
|
|
|
};
|
|
|
|
|
2019-06-20 19:37:28 +07:00
|
|
|
export class Search extends Component<RouteComponentProps<{}>, State> {
|
|
|
|
constructor(props: RouteComponentProps<{}>) {
|
2019-02-03 17:23:33 +07:00
|
|
|
super(props);
|
|
|
|
this.state = {
|
|
|
|
search: '',
|
|
|
|
suggestions: [],
|
|
|
|
// loading: A boolean value to indicate that request is in pending state.
|
|
|
|
loading: false,
|
|
|
|
// loaded: A boolean value to indicate that result has been loaded.
|
|
|
|
loaded: false,
|
|
|
|
// error: A boolean value to indicate API error.
|
|
|
|
error: false,
|
|
|
|
};
|
|
|
|
this.requestList = [];
|
|
|
|
}
|
|
|
|
|
2019-06-30 05:39:56 +07:00
|
|
|
public render(): ReactElement<HTMLElement> {
|
|
|
|
const { suggestions, search, loaded, loading, error } = this.state;
|
|
|
|
|
|
|
|
return (
|
|
|
|
<AutoComplete
|
|
|
|
color={colors.white}
|
|
|
|
onBlur={this.handleOnBlur}
|
|
|
|
onChange={this.handleSearch}
|
|
|
|
onCleanSuggestions={this.handlePackagesClearRequested}
|
|
|
|
onClick={this.handleClickSearch}
|
|
|
|
onSuggestionsFetch={debounce(this.handleFetchPackages, CONSTANTS.API_DELAY)}
|
|
|
|
placeholder={CONSTANTS.PLACEHOLDER_TEXT}
|
|
|
|
startAdornment={this.getAdorment()}
|
|
|
|
suggestions={suggestions}
|
|
|
|
suggestionsError={error}
|
|
|
|
suggestionsLoaded={loaded}
|
|
|
|
suggestionsLoading={loading}
|
|
|
|
value={search}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2019-02-03 17:23:33 +07:00
|
|
|
/**
|
|
|
|
* Cancel all the requests which are in pending state.
|
|
|
|
*/
|
2019-06-20 19:37:28 +07:00
|
|
|
private cancelAllSearchRequests: cancelAllSearchRequests = () => {
|
2019-02-03 17:23:33 +07:00
|
|
|
this.requestList.forEach(request => request.abort());
|
|
|
|
this.requestList = [];
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Cancel all the request from list and make request list empty.
|
|
|
|
*/
|
2019-06-20 19:37:28 +07:00
|
|
|
private handlePackagesClearRequested: handlePackagesClearRequested = () => {
|
2019-02-03 17:23:33 +07:00
|
|
|
this.setState({
|
|
|
|
suggestions: [],
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* onChange method for the input element.
|
|
|
|
*/
|
2019-06-20 19:37:28 +07:00
|
|
|
private handleSearch: handleSearch = (event, { newValue, method }) => {
|
2019-02-03 17:23:33 +07:00
|
|
|
// stops event bubbling
|
|
|
|
event.stopPropagation();
|
|
|
|
if (method === 'type') {
|
|
|
|
const value = newValue.trim();
|
|
|
|
this.setState(
|
|
|
|
{
|
|
|
|
search: value,
|
|
|
|
loading: true,
|
|
|
|
loaded: false,
|
|
|
|
error: false,
|
|
|
|
},
|
|
|
|
() => {
|
|
|
|
/**
|
|
|
|
* A use case where User keeps adding and removing value in input field,
|
|
|
|
* so we cancel all the existing requests when input is empty.
|
|
|
|
*/
|
|
|
|
if (value.length === 0) {
|
|
|
|
this.cancelAllSearchRequests();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* When an user select any package by clicking or pressing return key.
|
|
|
|
*/
|
2019-06-25 05:54:32 +07:00
|
|
|
private handleClickSearch = (
|
|
|
|
event: React.KeyboardEvent<HTMLInputElement>,
|
|
|
|
{ suggestionValue, method }: { suggestionValue: string[]; method: string }
|
|
|
|
): void | undefined => {
|
2019-02-03 17:23:33 +07:00
|
|
|
const { history } = this.props;
|
|
|
|
// stops event bubbling
|
|
|
|
event.stopPropagation();
|
|
|
|
switch (method) {
|
|
|
|
case 'click':
|
|
|
|
case 'enter':
|
|
|
|
this.setState({ search: '' });
|
2019-03-28 05:39:06 +07:00
|
|
|
history.push(`/-/web/detail/${suggestionValue}`);
|
2019-02-03 17:23:33 +07:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Fetch packages from API.
|
|
|
|
* For AbortController see: https://developer.mozilla.org/en-US/docs/Web/API/AbortController
|
|
|
|
*/
|
2019-06-20 19:37:28 +07:00
|
|
|
private handleFetchPackages: handleFetchPackages = async ({ value }) => {
|
2019-02-03 17:23:33 +07:00
|
|
|
try {
|
2019-06-20 19:37:28 +07:00
|
|
|
// @ts-ignore
|
2019-02-03 17:23:33 +07:00
|
|
|
const controller = new window.AbortController();
|
|
|
|
const signal = controller.signal;
|
|
|
|
// Keep track of search requests.
|
|
|
|
this.requestList.push(controller);
|
|
|
|
const suggestions = await API.request(`search/${encodeURIComponent(value)}`, 'GET', { signal });
|
2019-06-20 19:37:28 +07:00
|
|
|
// @ts-ignore
|
2019-02-03 17:23:33 +07:00
|
|
|
this.setState({
|
|
|
|
suggestions,
|
|
|
|
loaded: true,
|
|
|
|
});
|
|
|
|
} catch (error) {
|
|
|
|
/**
|
|
|
|
* AbortError is not the API error.
|
|
|
|
* It means browser has cancelled the API request.
|
|
|
|
*/
|
|
|
|
if (error.name === CONSTANTS.ABORT_ERROR) {
|
|
|
|
this.setState({ error: false, loaded: false });
|
|
|
|
} else {
|
|
|
|
this.setState({ error: true, loaded: false });
|
|
|
|
}
|
|
|
|
} finally {
|
|
|
|
this.setState({ loading: false });
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2019-06-25 05:54:32 +07:00
|
|
|
private requestList: AbortControllerInterface[];
|
|
|
|
|
|
|
|
public getAdorment(): JSX.Element {
|
2019-06-20 19:37:28 +07:00
|
|
|
return (
|
2019-06-30 05:39:56 +07:00
|
|
|
<InputAdornment
|
|
|
|
className={css`
|
|
|
|
color: ${colors.white};
|
|
|
|
`}
|
|
|
|
position={'start'}>
|
2019-06-20 19:37:28 +07:00
|
|
|
<IconSearch />
|
|
|
|
</InputAdornment>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2019-02-03 17:23:33 +07:00
|
|
|
/**
|
|
|
|
* As user focuses out from input, we cancel all the request from requestList
|
|
|
|
* and set the API state parameters to default boolean values.
|
|
|
|
*/
|
2019-06-20 19:37:28 +07:00
|
|
|
private handleOnBlur: onBlur = event => {
|
2019-02-03 17:23:33 +07:00
|
|
|
// stops event bubbling
|
|
|
|
event.stopPropagation();
|
|
|
|
this.setState(
|
|
|
|
{
|
|
|
|
loaded: false,
|
|
|
|
loading: false,
|
|
|
|
error: false,
|
|
|
|
},
|
|
|
|
() => this.cancelAllSearchRequests()
|
|
|
|
);
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
export default withRouter(Search);
|