From 6ba721446be039b8676dbaab7eb1f5e7530bab7a Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 4 Dec 2019 17:09:02 +0100 Subject: [PATCH] Search Component - Replaced class by func. comp (#339) --- .eslintrc | 7 +- jest/unit/components/__mocks__/.eslintrc | 5 - package.json | 1 + src/App/App.tsx | 1 - src/App/AppContextProvider.tsx | 1 - src/components/AutoComplete/AutoComplete.tsx | 106 +++--- src/components/Header/Header.tsx | 1 - src/components/Search/Search.test.tsx | 333 ++++++------------ src/components/Search/Search.tsx | 310 +++++++--------- src/components/Search/SearchAdornment.tsx | 18 + .../Search/__snapshots__/Search.test.tsx.snap | 86 ++++- src/muiComponents/.eslintrc | 1 - src/utils/test-enzyme.ts | 2 - src/utils/test-react-testing-library.tsx | 1 - test/e2e/.eslintrc | 1 - tsconfig.json | 2 +- types/jest-dom.d.ts | 1 + yarn.lock | 55 ++- 18 files changed, 449 insertions(+), 483 deletions(-) delete mode 100644 jest/unit/components/__mocks__/.eslintrc create mode 100644 src/components/Search/SearchAdornment.tsx create mode 100644 types/jest-dom.d.ts diff --git a/.eslintrc b/.eslintrc index 3340ea9..7d4da4a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -45,11 +45,8 @@ } } ], - "@typescript-eslint/explicit-function-return-type": ["warn", - { - "allowExpressions": true, - "allowTypedFunctionExpressions": true - }], + "@typescript-eslint/explicit-function-return-type": 0, + "react/display-name": 0, "react/no-deprecated": 1, "react/jsx-no-target-blank": 1, "react/destructuring-assignment": ["error", "always"], diff --git a/jest/unit/components/__mocks__/.eslintrc b/jest/unit/components/__mocks__/.eslintrc deleted file mode 100644 index b491bd7..0000000 --- a/jest/unit/components/__mocks__/.eslintrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rules": { - "@typescript-eslint/explicit-function-return-type": 0 - } -} \ No newline at end of file diff --git a/package.json b/package.json index fdc9a52..7adea90 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@material-ui/core": "4.7.1", "@material-ui/icons": "4.5.1", "@octokit/rest": "16.35.0", + "@testing-library/jest-dom": "4.2.4", "@testing-library/react": "9.3.2", "@types/autosuggest-highlight": "3.1.0", "@types/enzyme": "3.10.3", diff --git a/src/App/App.tsx b/src/App/App.tsx index e32b812..8eff4b5 100644 --- a/src/App/App.tsx +++ b/src/App/App.tsx @@ -27,7 +27,6 @@ const StyledBoxContent = styled(Box)({ }); /* eslint-disable react/jsx-max-depth */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable react/jsx-no-bind */ /* eslint-disable react-hooks/exhaustive-deps */ const App: React.FC = () => { diff --git a/src/App/AppContextProvider.tsx b/src/App/AppContextProvider.tsx index 260609f..2cf5351 100644 --- a/src/App/AppContextProvider.tsx +++ b/src/App/AppContextProvider.tsx @@ -9,7 +9,6 @@ interface Props { user?: User; } -/* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable react-hooks/exhaustive-deps */ const AppContextProvider: React.FC = ({ children, packages, user }) => { const [state, setState] = useState({ diff --git a/src/components/AutoComplete/AutoComplete.tsx b/src/components/AutoComplete/AutoComplete.tsx index 987e6a0..ecb3f8e 100644 --- a/src/components/AutoComplete/AutoComplete.tsx +++ b/src/components/AutoComplete/AutoComplete.tsx @@ -1,4 +1,4 @@ -import React, { KeyboardEvent } from 'react'; +import React, { KeyboardEvent, memo } from 'react'; import styled from '@emotion/styled'; import Autosuggest, { SuggestionSelectedEventData, InputProps, ChangeEvent } from 'react-autosuggest'; import match from 'autosuggest-highlight/match'; @@ -90,64 +90,66 @@ const SUGGESTIONS_RESPONSE = { NO_RESULT: 'No results found.', }; -const AutoComplete = ({ - suggestions, - startAdornment, - onChange, - onSuggestionsFetch, - onCleanSuggestions, - value = '', - placeholder = '', - disableUnderline = false, - onClick, - onKeyDown, - onBlur, - suggestionsLoading = false, - suggestionsLoaded = false, - suggestionsError = false, -}: Props): JSX.Element => { - const autosuggestProps = { - renderInputComponent, +const AutoComplete = memo( + ({ suggestions, - getSuggestionValue, - renderSuggestion, - onSuggestionsFetchRequested: onSuggestionsFetch, - onSuggestionsClearRequested: onCleanSuggestions, - }; - const inputProps: InputProps = { - value, - onChange, - placeholder, - // material-ui@4.5.1 introduce better types for TextInput, check readme - // @ts-ignore startAdornment, - disableUnderline, + onChange, + onSuggestionsFetch, + onCleanSuggestions, + value = '', + placeholder = '', + disableUnderline = false, + onClick, onKeyDown, onBlur, - }; + suggestionsLoading = false, + suggestionsLoaded = false, + suggestionsError = false, + }: Props) => { + const autosuggestProps = { + renderInputComponent, + suggestions, + getSuggestionValue, + renderSuggestion, + onSuggestionsFetchRequested: onSuggestionsFetch, + onSuggestionsClearRequested: onCleanSuggestions, + }; + const inputProps: InputProps = { + value, + onChange, + placeholder, + // material-ui@4.5.1 introduce better types for TextInput, check readme + // @ts-ignore + startAdornment, + disableUnderline, + onKeyDown, + onBlur, + }; + + // this format avoid arrow function eslint rule + function renderSuggestionsContainer({ containerProps, children, query }): JSX.Element { + return ( + + {suggestionsLoaded && children === null && query && renderMessage(SUGGESTIONS_RESPONSE.NO_RESULT)} + {suggestionsLoading && query && renderMessage(SUGGESTIONS_RESPONSE.LOADING)} + {suggestionsError && renderMessage(SUGGESTIONS_RESPONSE.FAILURE)} + {children} + + ); + } - // this format avoid arrow function eslint rule - function renderSuggestionsContainer({ containerProps, children, query }): JSX.Element { return ( - - {suggestionsLoaded && children === null && query && renderMessage(SUGGESTIONS_RESPONSE.NO_RESULT)} - {suggestionsLoading && query && renderMessage(SUGGESTIONS_RESPONSE.LOADING)} - {suggestionsError && renderMessage(SUGGESTIONS_RESPONSE.FAILURE)} - {children} - + + + ); } - - return ( - - - - ); -}; +); export default AutoComplete; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 7c93a64..c72a2ef 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -19,7 +19,6 @@ interface Props { /* eslint-disable react/jsx-max-depth */ /* eslint-disable react/jsx-no-bind*/ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ const Header: React.FC = ({ withoutSearch }) => { const appContext = useContext(AppContext); const [isInfoDialogOpen, setOpenInfoDialog] = useState(); diff --git a/src/components/Search/Search.test.tsx b/src/components/Search/Search.test.tsx index 0382005..9c786c6 100644 --- a/src/components/Search/Search.test.tsx +++ b/src/components/Search/Search.test.tsx @@ -1,272 +1,147 @@ import React from 'react'; -import { BrowserRouter } from 'react-router-dom'; +import { BrowserRouter as Router } from 'react-router-dom'; +import '@testing-library/jest-dom/extend-expect'; -import { mount, shallow } from '../../utils/test-enzyme'; +import { render, fireEvent, waitForElement } from '../../utils/test-react-testing-library'; +import api from '../../utils/api'; import Search from './Search'; -const SEARCH_FILE_PATH = './Search'; -const API_FILE_PATH = '../../utils/calls'; -const URL_FILE_PATH = '../../utils/url'; +/* eslint-disable verdaccio/jsx-spread */ +const ComponentToBeRendered: React.FC = () => ( + + + +); -// Global mocks -const event = { - stopPropagation: jest.fn(), -}; -window.location.assign = jest.fn(); - -describe(' component test', () => { - let routerWrapper; - let wrapper; +describe(' component', () => { beforeEach(() => { - routerWrapper = mount( - - - + jest.resetModules(); + jest.resetAllMocks(); + jest.spyOn(api, 'request').mockImplementation(() => + Promise.resolve([ + { + name: '@verdaccio/types', + version: '8.4.2', + }, + { + name: 'verdaccio', + version: '4.3.5', + }, + ]) ); }); test('should load the component in default state', () => { - expect(routerWrapper.html()).toMatchSnapshot(); + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('handleSearch: when user type package name in search component, show suggestions', async () => { + const { getByPlaceholderText, getAllByText } = render(); + + const autoCompleteInput = getByPlaceholderText('Search Packages'); + + fireEvent.focus(autoCompleteInput); + fireEvent.change(autoCompleteInput, { target: { value: 'verdaccio' } }); + + expect(autoCompleteInput).toHaveAttribute('value', 'verdaccio'); + + const suggestionsElements = await waitForElement(() => getAllByText('verdaccio', { exact: true })); + + expect(suggestionsElements).toHaveLength(2); + expect(api.request).toHaveBeenCalledTimes(1); }); test('onBlur: should cancel all search requests', async () => { - const Search = require(SEARCH_FILE_PATH).Search; + const { getByPlaceholderText, getByRole, getAllByText } = render(); - const routerWrapper = shallow( - - - - ); + const autoCompleteInput = getByPlaceholderText('Search Packages'); - wrapper = routerWrapper.find(Search).dive(); - const { handleOnBlur, requestList, setState } = wrapper.instance(); - const spyCancelAllSearchRequests = jest.spyOn(wrapper.instance(), 'cancelAllSearchRequests'); - setState({ search: 'verdaccio' }); + fireEvent.focus(autoCompleteInput); + fireEvent.change(autoCompleteInput, { target: { value: 'verdaccio' } }); + expect(autoCompleteInput).toHaveAttribute('value', 'verdaccio'); - const request = { - abort: jest.fn(), - }; - // adds a request for AbortController - wrapper.instance().requestList = [request]; + const suggestionsElements = await waitForElement(() => getAllByText('verdaccio', { exact: true })); + expect(suggestionsElements).toHaveLength(2); + expect(api.request).toHaveBeenCalledTimes(1); - handleOnBlur(event); - - expect(request.abort).toHaveBeenCalled(); - expect(event.stopPropagation).toHaveBeenCalled(); - expect(wrapper.state('error')).toBeFalsy(); - expect(wrapper.state('loaded')).toBeFalsy(); - expect(wrapper.state('loading')).toBeFalsy(); - expect(spyCancelAllSearchRequests).toHaveBeenCalled(); - expect(requestList).toEqual([]); + fireEvent.blur(autoCompleteInput); + const listBoxElement = await waitForElement(() => getByRole('listbox')); + expect(listBoxElement).toBeEmpty(); }); - test('handleSearch: when user type package name in search component and set loading to true', () => { - const Search = require(SEARCH_FILE_PATH).Search; + test('handleSearch: cancel all search requests when there is no value in search component with type method', async () => { + const { getByPlaceholderText, getByRole } = render(); - const routerWrapper = shallow( - - - - ); + const autoCompleteInput = getByPlaceholderText('Search Packages'); - wrapper = routerWrapper.find(Search).dive(); - - const { handleSearch } = wrapper.instance(); - const newValue = 'verdaccio'; - - handleSearch(event, { newValue, method: 'type' }); - - expect(event.stopPropagation).toHaveBeenCalled(); - expect(wrapper.state('error')).toBeFalsy(); - expect(wrapper.state('loaded')).toBeFalsy(); - expect(wrapper.state('loading')).toBeTruthy(); - expect(wrapper.state('search')).toEqual(newValue); + fireEvent.focus(autoCompleteInput); + fireEvent.change(autoCompleteInput, { target: { value: ' ', method: 'type' } }); + expect(autoCompleteInput).toHaveAttribute('value', ''); + const listBoxElement = await waitForElement(() => getByRole('listbox')); + expect(listBoxElement).toBeEmpty(); + expect(api.request).toHaveBeenCalledTimes(0); }); - test('handleSearch: cancel all search requests when there is no value in search component with type method', () => { - const Search = require(SEARCH_FILE_PATH).Search; + test('handleSearch: when method is not type method', async () => { + const { getByPlaceholderText, getByRole } = render(); - const routerWrapper = shallow( - - - - ); + const autoCompleteInput = getByPlaceholderText('Search Packages'); - wrapper = routerWrapper.find(Search).dive(); - - const { handleSearch, requestList } = wrapper.instance(); - const spy = jest.spyOn(wrapper.instance(), 'cancelAllSearchRequests'); - const newValue = ''; - - handleSearch(event, { newValue, method: 'type' }); - - expect(event.stopPropagation).toHaveBeenCalled(); - expect(wrapper.state('error')).toBeFalsy(); - expect(wrapper.state('loaded')).toBeFalsy(); - expect(wrapper.state('loading')).toBeTruthy(); - expect(wrapper.state('search')).toEqual(newValue); - expect(spy).toHaveBeenCalled(); - expect(requestList).toEqual([]); + fireEvent.focus(autoCompleteInput); + fireEvent.change(autoCompleteInput, { target: { value: ' ', method: 'click' } }); + expect(autoCompleteInput).toHaveAttribute('value', ''); + const listBoxElement = await waitForElement(() => getByRole('listbox')); + expect(listBoxElement).toBeEmpty(); + expect(api.request).toHaveBeenCalledTimes(0); }); - test('handleSearch: when method is not type method', () => { - const Search = require(SEARCH_FILE_PATH).Search; + test('handleSearch: loading is been displayed', async () => { + const { getByPlaceholderText, getByRole, getByText } = render(); + const autoCompleteInput = getByPlaceholderText('Search Packages'); - const routerWrapper = shallow( - - - - ); + fireEvent.focus(autoCompleteInput); + fireEvent.change(autoCompleteInput, { target: { value: 'verdaccio' } }); + expect(autoCompleteInput).toHaveAttribute('value', 'verdaccio'); - wrapper = routerWrapper.find(Search).dive(); - - const { handleSearch } = wrapper.instance(); - const newValue = ''; - - handleSearch(event, { newValue, method: 'click' }); - - expect(event.stopPropagation).toHaveBeenCalled(); - expect(wrapper.state('error')).toBeFalsy(); - expect(wrapper.state('loaded')).toBeFalsy(); - expect(wrapper.state('loading')).toBeFalsy(); - expect(wrapper.state('search')).toEqual(newValue); + const loadingElement = await waitForElement(() => getByText('Loading...')); + expect(loadingElement).toBeTruthy(); }); - test('handlePackagesClearRequested: should clear suggestions', () => { - const Search = require(SEARCH_FILE_PATH).Search; + test('handlePackagesClearRequested: should clear suggestions', async () => { + const { getByPlaceholderText, getAllByText, getByRole } = render(); + const autoCompleteInput = getByPlaceholderText('Search Packages'); - const routerWrapper = shallow( - - - - ); + fireEvent.focus(autoCompleteInput); + fireEvent.change(autoCompleteInput, { target: { value: 'verdaccio' } }); + expect(autoCompleteInput).toHaveAttribute('value', 'verdaccio'); - wrapper = routerWrapper.find(Search).dive(); + const suggestionsElements = await waitForElement(() => getAllByText('verdaccio', { exact: true })); + expect(suggestionsElements).toHaveLength(2); - const { handlePackagesClearRequested } = wrapper.instance(); + fireEvent.change(autoCompleteInput, { target: { value: ' ' } }); + const listBoxElement = await waitForElement(() => getByRole('listbox')); + expect(listBoxElement).toBeEmpty(); - handlePackagesClearRequested(); - - expect(wrapper.state('suggestions')).toEqual([]); + expect(api.request).toHaveBeenCalledTimes(2); }); - describe(' component: mocks specific tests ', () => { - beforeEach(() => { - jest.resetModules(); - jest.doMock('lodash/debounce', () => { - return function debounceMock(fn) { - return fn; - }; - }); - }); + test('handleClickSearch: should change the window location on click or return key', async () => { + const { getByPlaceholderText, getAllByText, getByRole } = render(); + const autoCompleteInput = getByPlaceholderText('Search Packages'); - test('handleFetchPackages: should load the packages from API', async () => { - const apiResponse = [{ name: 'verdaccio' }, { name: 'verdaccio-htpasswd' }]; - const suggestions = [{ name: 'verdaccio' }, { name: 'verdaccio-htpasswd' }]; + fireEvent.focus(autoCompleteInput); + fireEvent.change(autoCompleteInput, { target: { value: 'verdaccio' } }); + expect(autoCompleteInput).toHaveAttribute('value', 'verdaccio'); - jest.doMock(API_FILE_PATH, () => ({ - callSearch(url: string) { - return Promise.resolve(apiResponse); - }, - })); + const suggestionsElements = await waitForElement(() => getAllByText('verdaccio', { exact: true })); + expect(suggestionsElements).toHaveLength(2); - const Search = require(SEARCH_FILE_PATH).Search; - - const routerWrapper = shallow( - - - - ); - - wrapper = routerWrapper.find(Search).dive(); - wrapper.setState({ search: 'verdaccio' }); - const { handleFetchPackages } = wrapper.instance(); - - await handleFetchPackages({ value: 'verdaccio' }); - - expect(wrapper.state('suggestions')).toEqual(suggestions); - expect(wrapper.state('error')).toBeFalsy(); - expect(wrapper.state('loaded')).toBeTruthy(); - expect(wrapper.state('loading')).toBeFalsy(); - }); - - test('handleFetchPackages: when browser cancel a request', async () => { - const apiResponse = { name: 'AbortError' }; - - jest.doMock(API_FILE_PATH, () => ({ callSearch: jest.fn(() => Promise.reject(apiResponse)) })); - - const Search = require(SEARCH_FILE_PATH).Search; - - const routerWrapper = shallow( - - - - ); - - wrapper = routerWrapper.find(Search).dive(); - - const { handleFetchPackages, setState } = wrapper.instance(); - setState({ search: 'verdaccio' }); - await handleFetchPackages({ value: 'verdaccio' }); - - expect(wrapper.state('error')).toBeFalsy(); - expect(wrapper.state('loaded')).toBeFalsy(); - expect(wrapper.state('loading')).toBeFalsy(); - }); - - test('handleFetchPackages: when API server failed request', async () => { - const apiResponse = { name: 'BAD_REQUEST' }; - - jest.doMock(API_FILE_PATH, () => ({ - callSearch(url) { - return Promise.reject(apiResponse); - }, - })); - - const Search = require(SEARCH_FILE_PATH).Search; - const routerWrapper = shallow( - - - - ); - - wrapper = routerWrapper.find(Search).dive(); - wrapper.setState({ search: 'verdaccio' }); - const { handleFetchPackages } = wrapper.instance(); - - await handleFetchPackages({ value: 'verdaccio' }); - - expect(wrapper.state('error')).toBeTruthy(); - expect(wrapper.state('loaded')).toBeFalsy(); - expect(wrapper.state('loading')).toBeFalsy(); - }); - - test('handleClickSearch: should change the window location on click or return key', () => { - const getDetailPageURL = jest.fn(() => 'detail/page/url'); - jest.doMock(URL_FILE_PATH, () => ({ getDetailPageURL })); - - const suggestionValue = []; - const Search = require(SEARCH_FILE_PATH).Search; - const pushHandler = jest.fn(); - const routerWrapper = shallow( - - - - ); - - wrapper = routerWrapper.find(Search).dive(); - const { handleClickSearch } = wrapper.instance(); - - // click - handleClickSearch(event, { suggestionValue, method: 'click' }); - expect(event.stopPropagation).toHaveBeenCalled(); - expect(pushHandler).toHaveBeenCalledTimes(1); - - // return key - handleClickSearch(event, { suggestionValue, method: 'enter' }); - expect(event.stopPropagation).toHaveBeenCalled(); - expect(pushHandler).toHaveBeenCalledTimes(2); - }); + // click on the second suggestion + fireEvent.click(suggestionsElements[1]); + const listBoxElement = await waitForElement(() => getByRole('listbox')); + // when the page redirects, the list box should be empty again + expect(listBoxElement).toBeEmpty(); }); }); diff --git a/src/components/Search/Search.tsx b/src/components/Search/Search.tsx index 6368946..56cb632 100644 --- a/src/components/Search/Search.tsx +++ b/src/components/Search/Search.tsx @@ -1,32 +1,12 @@ -import React, { KeyboardEvent, Component, ReactElement } from 'react'; -import { withRouter, RouteComponentProps } from 'react-router-dom'; -import { SuggestionSelectedEventData, ChangeEvent } from 'react-autosuggest'; -import styled from '@emotion/styled'; -import { default as IconSearch } from '@material-ui/icons/Search'; +import React, { useState, FormEvent, useCallback } from 'react'; import debounce from 'lodash/debounce'; +import { RouteComponentProps, withRouter } from 'react-router'; +import { SuggestionSelectedEventData } from 'react-autosuggest'; -import InputAdornment from '../../muiComponents/InputAdornment'; import AutoComplete from '../AutoComplete'; import { callSearch } from '../../utils/calls'; -import { Theme } from '../../design-tokens/theme'; -export interface State { - search: string; - suggestions: unknown[]; - loading: boolean; - loaded: boolean; - error: boolean; -} - -export type cancelAllSearchRequests = () => void; -export type handlePackagesClearRequested = () => void; -export type handleSearch = (event: React.FormEvent, { newValue, method }: ChangeEvent) => void; -export type handleClickSearch = ( - event: KeyboardEvent, - { suggestionValue, method }: { suggestionValue: object[]; method: string } -) => void; -export type handleFetchPackages = ({ value: string }) => Promise; -export type onBlur = (event: React.FormEvent) => void; +import SearchAdornment from './SearchAdornment'; const CONSTANTS = { API_DELAY: 300, @@ -34,168 +14,142 @@ const CONSTANTS = { ABORT_ERROR: 'AbortError', }; -const StyledInputAdornment = styled(InputAdornment)<{ theme?: Theme }>(props => ({ - color: props.theme && props.theme.palette.white, -})); - -export class Search extends Component, State> { - constructor(props: RouteComponentProps<{}>) { - 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 = []; - } - - public render(): ReactElement { - const { suggestions, search, loaded, loading, error } = this.state; - - return ( - - ); - } +const Search: React.FC = ({ history }) => { + const [suggestions, setSuggestions] = useState([]); + const [loaded, setLoaded] = useState(false); + const [search, setSearch] = useState(''); + const [error, setError] = useState(false); + const [loading, setLoading] = useState(false); + const [requestList, setRequestList] = useState void }>>([]); /** * Cancel all the requests which are in pending state. */ - private cancelAllSearchRequests: cancelAllSearchRequests = () => { - this.requestList.forEach(request => request.abort()); - this.requestList = []; - }; - - /** - * Cancel all the request from list and make request list empty. - */ - private handlePackagesClearRequested: handlePackagesClearRequested = () => { - this.setState({ - suggestions: [], - }); - }; - - /** - * onChange method for the input element. - */ - private handleSearch: handleSearch = (event, { newValue, method }) => { - // 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. - */ - private handleClickSearch = ( - event: React.FormEvent, - { suggestionValue, method }: SuggestionSelectedEventData - ): void | undefined => { - const { history } = this.props; - // stops event bubbling - event.stopPropagation(); - switch (method) { - case 'click': - case 'enter': - this.setState({ search: '' }); - history.push(`/-/web/detail/${suggestionValue}`); - break; - } - }; - - /** - * Fetch packages from API. - * For AbortController see: https://developer.mozilla.org/en-US/docs/Web/API/AbortController - */ - private handleFetchPackages: handleFetchPackages = async ({ value }) => { - try { - const controller = new window.AbortController(); - const signal = controller.signal; - // Keep track of search requests. - this.requestList.push(controller); - const suggestions = await callSearch(value, signal); - // @ts-ignore - 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 }); - } - }; - - private requestList: AbortController[]; - - public getAdorment(): JSX.Element { - return ( - - - - ); - } + const cancelAllSearchRequests = useCallback(() => { + requestList.forEach(request => request.abort()); + setRequestList([]); + }, [requestList, setRequestList]); /** * As user focuses out from input, we cancel all the request from requestList * and set the API state parameters to default boolean values. */ - private handleOnBlur: onBlur = event => { - // stops event bubbling - event.stopPropagation(); - this.setState( - { - loaded: false, - loading: false, - error: false, - }, - () => this.cancelAllSearchRequests() - ); - }; -} + const handleOnBlur = useCallback( + (event: FormEvent) => { + // stops event bubbling + event.stopPropagation(); + setLoaded(false); + setLoading(false); + setError(false); + cancelAllSearchRequests(); + }, + [setLoaded, setLoading, cancelAllSearchRequests, setError] + ); + + /** + * onChange method for the input element. + */ + const handleSearch = useCallback( + (event: FormEvent, { newValue, method }) => { + // stops event bubbling + event.stopPropagation(); + if (method === 'type') { + const value = newValue.trim(); + + setLoading(true); + setError(false); + setSearch(value); + setLoaded(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) { + cancelAllSearchRequests(); + } + } + }, + [cancelAllSearchRequests] + ); + + /** + * Cancel all the request from list and make request list empty. + */ + const handlePackagesClearRequested = useCallback(() => { + setSuggestions([]); + }, [setSuggestions]); + + /** + * When an user select any package by clicking or pressing return key. + */ + const handleClickSearch = useCallback( + ( + event: FormEvent, + { suggestionValue, method }: SuggestionSelectedEventData + ): void | undefined => { + // stops event bubbling + event.stopPropagation(); + switch (method) { + case 'click': + case 'enter': + setSearch(''); + history.push(`/-/web/detail/${suggestionValue}`); + break; + } + }, + [history] + ); + + /** + * Fetch packages from API. + * For AbortController see: https://developer.mozilla.org/en-US/docs/Web/API/AbortController + */ + const handleFetchPackages = useCallback( + async ({ value }: { value: string }) => { + try { + const controller = new window.AbortController(); + const signal = controller.signal; + // Keep track of search requests. + setRequestList([...requestList, controller]); + const suggestions = await callSearch(value, signal); + // @ts-ignore FIXME: Argument of type 'unknown' is not assignable to parameter of type 'SetStateAction' + setSuggestions(suggestions); + setLoaded(true); + } catch (error) { + /** + * AbortError is not the API error. + * It means browser has cancelled the API request. + */ + if (error.name === CONSTANTS.ABORT_ERROR) { + setError(false); + setLoaded(false); + } else { + setError(true); + setLoaded(false); + } + } finally { + setLoading(false); + } + }, + [requestList, setRequestList, setSuggestions, setLoaded, setError, setLoading] + ); + + return ( + } + suggestions={suggestions} + suggestionsError={error} + suggestionsLoaded={loaded} + suggestionsLoading={loading} + value={search} + /> + ); +}; export default withRouter(Search); diff --git a/src/components/Search/SearchAdornment.tsx b/src/components/Search/SearchAdornment.tsx new file mode 100644 index 0000000..4a3f51c --- /dev/null +++ b/src/components/Search/SearchAdornment.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import Search from '@material-ui/icons/Search'; +import styled from '@emotion/styled'; + +import InputAdornment from '../../muiComponents/InputAdornment'; +import { Theme } from '../../design-tokens/theme'; + +const StyledInputAdornment = styled(InputAdornment)<{ theme?: Theme }>(props => ({ + color: props.theme && props.theme.palette.white, +})); + +const SearchAdornment: React.FC = () => ( + + + +); + +export default SearchAdornment; diff --git a/src/components/Search/__snapshots__/Search.test.tsx.snap b/src/components/Search/__snapshots__/Search.test.tsx.snap index e719a3a..4811337 100644 --- a/src/components/Search/__snapshots__/Search.test.tsx.snap +++ b/src/components/Search/__snapshots__/Search.test.tsx.snap @@ -1,3 +1,87 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` component test should load the component in default state 1`] = `"
"`; +exports[` component should load the component in default state 1`] = ` +.emotion-6 { + width: 100%; + height: 32px; + position: relative; + z-index: 1; +} + +.emotion-2 .MuiInputBase-root:before { + content: ''; + border: none; +} + +.emotion-2 .MuiInputBase-root:after { + border-color: #fff; +} + +.emotion-2 .MuiInputBase-root:hover:before { + content: none; +} + +.emotion-2 .MuiInputBase-input { + color: #fff; +} + +.emotion-0 { + color: #fff; +} + +.emotion-4 { + max-height: 500px; + overflow-y: auto; +} + +
+ +`; diff --git a/src/muiComponents/.eslintrc b/src/muiComponents/.eslintrc index 8db7e06..0f0f443 100644 --- a/src/muiComponents/.eslintrc +++ b/src/muiComponents/.eslintrc @@ -1,7 +1,6 @@ { "rules": { "verdaccio/jsx-spread": 0, - "react/display-name": 0, "react/jsx-sort-props": 0 } } diff --git a/src/utils/test-enzyme.ts b/src/utils/test-enzyme.ts index 1b6ab2f..53570e1 100644 --- a/src/utils/test-enzyme.ts +++ b/src/utils/test-enzyme.ts @@ -2,8 +2,6 @@ import { mount, shallow } from 'enzyme'; import ThemeProvider from '../design-tokens/ThemeProvider'; -/* eslint-disable @typescript-eslint/explicit-function-return-type */ - const shallowWithTheme = (element: React.ReactElement, ...props): any => shallow(element, { wrappingComponent: ThemeProvider, diff --git a/src/utils/test-react-testing-library.tsx b/src/utils/test-react-testing-library.tsx index a07cca3..ad99581 100644 --- a/src/utils/test-react-testing-library.tsx +++ b/src/utils/test-react-testing-library.tsx @@ -3,7 +3,6 @@ import React from 'react'; import ThemeProvider from '../design-tokens/ThemeProvider'; -/* eslint-disable @typescript-eslint/explicit-function-return-type */ const customRender = (node: React.ReactElement, ...options: Array) => { return render({node}, ...options); }; diff --git a/test/e2e/.eslintrc b/test/e2e/.eslintrc index ada2d5b..b7966ac 100644 --- a/test/e2e/.eslintrc +++ b/test/e2e/.eslintrc @@ -1,7 +1,6 @@ { "rules": { "@typescript-eslint/no-var-requires": 0, - "@typescript-eslint/explicit-function-return-type": 0, "@typescript-eslint/no-explicit-any": 0, "@typescript-eslint/explicit-member-accessibility": 0, "no-console": 0, diff --git a/tsconfig.json b/tsconfig.json index 66cd22a..4ecb5eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,7 @@ "src", "types/*.d.ts", "scripts/lib", - "node_modules/config" + "node_modules/config", ], "exclude": [ "node_modules" diff --git a/types/jest-dom.d.ts b/types/jest-dom.d.ts new file mode 100644 index 0000000..666127a --- /dev/null +++ b/types/jest-dom.d.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/extend-expect'; diff --git a/yarn.lock b/yarn.lock index 414f732..e623869 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1071,6 +1071,13 @@ dependencies: regenerator-runtime "^0.13.2" +"@babel/runtime@^7.5.1": + version "7.7.4" + resolved "https://registry.verdaccio.org/@babel%2fruntime/-/runtime-7.7.4.tgz#b23a856751e4bf099262f867767889c0e3fe175b" + integrity sha512-r24eVUUr0QqNZa+qrImUk8fn5SPhHq+IfYvIoIMg0do3GdK9sMdiLKP3GYVVaxpPKORgm8KRKaNTEhAjgIpLMw== + dependencies: + regenerator-runtime "^0.13.2" + "@babel/template@^7.1.0", "@babel/template@^7.4.0", "@babel/template@^7.4.4", "@babel/template@^7.6.0": version "7.6.0" resolved "https://registry.verdaccio.org/@babel%2ftemplate/-/template-7.6.0.tgz#7f0159c7f5012230dad64cca42ec9bdb5c9536e6" @@ -1739,6 +1746,21 @@ pretty-format "^24.8.0" wait-for-expect "^1.3.0" +"@testing-library/jest-dom@4.2.4": + version "4.2.4" + resolved "https://registry.verdaccio.org/@testing-library%2fjest-dom/-/jest-dom-4.2.4.tgz#00dfa0cbdd837d9a3c2a7f3f0a248ea6e7b89742" + integrity sha512-j31Bn0rQo12fhCWOUWy9fl7wtqkp7In/YP2p5ZFyRuiiB9Qs3g+hS4gAmDWONbAHcRmVooNJ5eOHQDCOmUFXHg== + dependencies: + "@babel/runtime" "^7.5.1" + chalk "^2.4.1" + css "^2.2.3" + css.escape "^1.5.1" + jest-diff "^24.0.0" + jest-matcher-utils "^24.0.0" + lodash "^4.17.11" + pretty-format "^24.0.0" + redent "^3.0.0" + "@testing-library/react@9.3.2": version "9.3.2" resolved "https://registry.verdaccio.org/@testing-library%2freact/-/react-9.3.2.tgz#418000daa980dafd2d9420cc733d661daece9aa0" @@ -4549,7 +4571,12 @@ css-what@2.1, css-what@^2.1.2: resolved "https://registry.verdaccio.org/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2" integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== -css@^2.0.0, css@^2.2.1: +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.verdaccio.org/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s= + +css@^2.0.0, css@^2.2.1, css@^2.2.3: version "2.2.4" resolved "https://registry.verdaccio.org/css/-/css-2.2.4.tgz#c646755c73971f2bba6a601e2cf2fd71b1298929" integrity sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw== @@ -7944,7 +7971,7 @@ jest-config@^24.9.0: pretty-format "^24.9.0" realpath-native "^1.1.0" -jest-diff@^24.3.0, jest-diff@^24.9.0: +jest-diff@^24.0.0, jest-diff@^24.3.0, jest-diff@^24.9.0: version "24.9.0" resolved "https://registry.verdaccio.org/jest-diff/-/jest-diff-24.9.0.tgz#931b7d0d5778a1baf7452cb816e325e3724055da" integrity sha512-qMfrTs8AdJE2iqrTp0hzh7kTd2PQWrsFyj9tORoKmu32xjPjeE4NyjVRDz8ybYwqS2ik8N4hsIpiVTyFeo2lBQ== @@ -8072,7 +8099,7 @@ jest-leak-detector@^24.9.0: jest-get-type "^24.9.0" pretty-format "^24.9.0" -jest-matcher-utils@^24.9.0: +jest-matcher-utils@^24.0.0, jest-matcher-utils@^24.9.0: version "24.9.0" resolved "https://registry.verdaccio.org/jest-matcher-utils/-/jest-matcher-utils-24.9.0.tgz#f5b3661d5e628dffe6dd65251dfdae0e87c3a073" integrity sha512-OZz2IXsu6eaiMAwe67c1T+5tUAtQyQx27/EMEkbFAGiw52tB9em+uGbzpcgYVpA8wl0hlxKPZxrly4CXU/GjHA== @@ -9434,6 +9461,11 @@ min-document@^2.19.0: dependencies: dom-walk "^0.1.0" +min-indent@^1.0.0: + version "1.0.0" + resolved "https://registry.verdaccio.org/min-indent/-/min-indent-1.0.0.tgz#cfc45c37e9ec0d8f0a0ec3dd4ef7f7c3abe39256" + integrity sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY= + mini-create-react-context@^0.3.0: version "0.3.2" resolved "https://registry.verdaccio.org/mini-create-react-context/-/mini-create-react-context-0.3.2.tgz#79fc598f283dd623da8e088b05db8cddab250189" @@ -11178,7 +11210,7 @@ pretty-error@^2.0.2: renderkid "^2.0.1" utila "~0.4" -pretty-format@^24.3.0, pretty-format@^24.8.0, pretty-format@^24.9.0: +pretty-format@^24.0.0, pretty-format@^24.3.0, pretty-format@^24.8.0, pretty-format@^24.9.0: version "24.9.0" resolved "https://registry.verdaccio.org/pretty-format/-/pretty-format-24.9.0.tgz#12fac31b37019a4eea3c11aa9a959eb7628aa7c9" integrity sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA== @@ -11715,6 +11747,14 @@ redent@^2.0.0: indent-string "^3.0.0" strip-indent "^2.0.0" +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.verdaccio.org/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + reflect.ownkeys@^0.2.0: version "0.2.0" resolved "https://registry.verdaccio.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460" @@ -13029,6 +13069,13 @@ strip-indent@^2.0.0: resolved "https://registry.verdaccio.org/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68" integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g= +strip-indent@^3.0.0: + version "3.0.0" + resolved "https://registry.verdaccio.org/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" + integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== + dependencies: + min-indent "^1.0.0" + strip-json-comments@2.0.1, strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.verdaccio.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"