diff --git a/src/search/SearchDrawer.js b/src/search/SearchDrawer.js index 4f301e0c..ce4f8742 100644 --- a/src/search/SearchDrawer.js +++ b/src/search/SearchDrawer.js @@ -2,7 +2,7 @@ import React from 'react' import Drawer from '../drawer/Drawer' import { makeStyles } from '@material-ui/core/styles' import PropTypes from 'prop-types' -import SearchProvider from './SearchProvider' +import useNavigationEvent from 'react-storefront/hooks/useNavigationEvent' export const styles = theme => ({ /** @@ -24,12 +24,18 @@ const useStyles = makeStyles(styles, { name: 'RSFSearch' }) export default function SearchDrawer({ DrawerComponent, classes, open, onClose, children }) { classes = useStyles({ classes }) + const handleNavigation = () => { + if (onClose) { + onClose() + } + } + + useNavigationEvent(handleNavigation) + return ( - - - {children} - - + + {children} + ) } @@ -52,7 +58,7 @@ SearchDrawer.propTypes = { /** * A function that is called when the user closes the drawer. */ - onClose: PropTypes.func, + onClose: PropTypes.func.isRequired, /** * A component type to use for the drawer. diff --git a/src/search/SearchField.js b/src/search/SearchField.js index d4028c79..da3a200a 100644 --- a/src/search/SearchField.js +++ b/src/search/SearchField.js @@ -1,8 +1,6 @@ -import React, { useState, useRef, useContext } from 'react' -import makeStyles from '@material-ui/core/styles/makeStyles' +import React, { useRef, forwardRef } from 'react' +import { makeStyles, fade } from '@material-ui/core/styles' import PropTypes from 'prop-types' -import withDefaultHandler from '../utils/withDefaultHandler' -import SearchContext from './SearchContext' import { IconButton } from '@material-ui/core' import ClearIcon from '@material-ui/icons/Clear' import SearchSubmitButton from './SearchSubmitButton' @@ -36,12 +34,34 @@ export const styles = theme => ({ border: 'none', background: 'none', flex: 1, - padding: '0 0 0 20px', + padding: theme.spacing(0, 2.5, 0, 2.5), ...theme.typography.body1, '&:focus': { outline: 'none', }, + [theme.breakpoints.up('sm')]: { + border: '1px solid', + borderColor: theme.palette.divider, + borderRadius: theme.spacing(1), + margin: theme.spacing(0.5, 0, 0.5, 0), + zIndex: 9999, + transition: 'border-color linear 0.1s', + '&:hover': { + borderColor: fade(theme.palette.divider, 0.25), + }, + '&:focus': { + borderColor: theme.palette.primary.main, + }, + }, + }, + + /** + * Styles applied to the input if showClearnButton prop is true. + */ + inputClearIcon: { + paddingRight: 0, }, + /** * Styles applied to the submit button element if [submitButtonVariant](#prop-submitButtonVariant) * is `'fab'`. @@ -67,92 +87,93 @@ const useStyles = makeStyles(styles, { name: 'RSFSearchField' }) * A search text field. Additional props are spread to the underlying * [Input](https://material-ui.com/api/input/). */ -export default function SearchField({ - classes, - onChange, - submitButtonVariant, - showClearButton, - SubmitButtonComponent, - clearButtonProps, - inputProps, - submitButtonProps, - ...others -}) { - classes = useStyles({ classes }) - const inputRef = useRef(null) - const { fetchSuggestions } = useContext(SearchContext) - const [text, setText] = useState('') - const empty = text.trim().length === 0 +const SearchField = forwardRef( + ( + { + classes, + onChange, + submitButtonVariant, + showClearButton, + SubmitButtonComponent, + clearButtonProps, + inputProps, + value, + onFocus, + submitButtonProps, + ...others + }, + ref, + ) => { + classes = useStyles({ classes }) + const inputRef = ref || useRef(null) + const empty = value.trim().length === 0 - const handleInputFocus = () => { - inputRef.current.setSelectionRange(0, inputRef.current.value.length) - } + const handleInputFocus = () => { + if (onFocus) { + onFocus() + } - const handleChange = withDefaultHandler(onChange, e => { - const text = e.target.value - setText(text) - fetchSuggestions(text) - }) + inputRef.current.setSelectionRange(0, inputRef.current.value.length) + } - const handleClearClick = () => { - const text = '' - setText(text) - fetchSuggestions(text) - } + const handleClearClick = () => { + onChange('') + } - return ( -
-
- - {showClearButton ? ( - - - - ) : ( - submitButtonVariant === 'icon' && ( - +
+ onChange(e.target.value)} + onFocus={handleInputFocus} + ref={inputRef} + className={clsx(classes.input, showClearButton && classes.inputClearIcon)} + {...inputProps} + /> + {showClearButton ? ( + - ) + > + + + ) : ( + submitButtonVariant === 'icon' && ( + + ) + )} +
+ {submitButtonVariant === 'fab' && ( + )}
- {submitButtonVariant === 'fab' && ( - - )} -
- ) -} + ) + }, +) SearchField.propTypes = { /** @@ -166,7 +187,7 @@ SearchField.propTypes = { /** * The type of submit button to display. */ - submitButtonVariant: PropTypes.oneOf(['icon', 'fab']), + submitButtonVariant: PropTypes.oneOf(['icon', 'fab', 'none']), /** * If `true`, show the clear button when text is entered. */ @@ -187,6 +208,14 @@ SearchField.propTypes = { * A function to call when the search query value is changed. */ onChange: PropTypes.func, + /** + * Input value. + */ + value: PropTypes.string, + /** + * A function to call when input is focused. + */ + onFocus: PropTypes.func, } SearchField.defaultProps = { @@ -195,4 +224,7 @@ SearchField.defaultProps = { showClearButton: true, placeholder: 'Search...', name: 'q', + value: '', } + +export default SearchField diff --git a/src/search/SearchForm.js b/src/search/SearchForm.js index 415d1fce..fd067385 100644 --- a/src/search/SearchForm.js +++ b/src/search/SearchForm.js @@ -20,7 +20,7 @@ const useStyles = makeStyles(styles, { name: 'RSFSearchForm' }) /** * A form used to submit a search query. */ -export default function SearchForm({ classes, children, action }) { +export default function SearchForm({ classes, children, action, autoComplete }) { classes = useStyles({ classes }) const ref = useRef() @@ -42,7 +42,14 @@ export default function SearchForm({ classes, children, action }) { } return ( -
+ {children}
) @@ -63,8 +70,14 @@ SearchForm.propTypes = { * An `action` attribute to use for the `
` element. */ action: PropTypes.string, + + /** + * Form auto complete + */ + autoComplete: PropTypes.bool, } SearchForm.defaultProps = { action: '/search', + autoComplete: false, } diff --git a/src/search/SearchPopover.js b/src/search/SearchPopover.js new file mode 100644 index 00000000..9fbf41fb --- /dev/null +++ b/src/search/SearchPopover.js @@ -0,0 +1,91 @@ +import React, { ElementType } from 'react' +import PropTypes from 'prop-types' +import { makeStyles } from '@material-ui/core/styles' +import { Popover } from '@material-ui/core' +import useNavigationEvent from 'react-storefront/hooks/useNavigationEvent' + +export const styles = theme => ({ + /** + * Styles applied to the popover paper + */ + popoverPaper: { + boxShadow: '0px 5px 5px -3px rgba(0,0,0,0.2), 0px 8px 10px 1px rgba(0,0,0,0.14)', + minWidth: theme.spacing(84), + minHeight: theme.spacing(75), + border: `1px solid ${theme.palette.divider}`, + }, +}) + +const useStyles = makeStyles(styles, { name: 'RSFSearchPopover' }) + +export default function SearchPopover({ classes, children, open, onClose, anchor, setQuery }) { + classes = useStyles({ classes }) + + const handleNavigation = () => { + if (onClose) { + onClose() + } + + if (setQuery) { + setQuery('') + } + + anchor.current.blur() + } + + useNavigationEvent(handleNavigation) + + return ( + + {children} + + ) +} + +SearchPopover.propTypes = { + /** + * Override or extend the styles applied to the component. See [CSS API](#css) below for more details. + */ + classes: PropTypes.object, + /** + * A list of `ExpandableSection`s that will be controlled. + */ + children: PropTypes.node, + /** + * Boolean, which controls if popover is open. + */ + open: PropTypes.bool, + /** + * Function, which is triggered on navigation and popover close. + */ + onClose: PropTypes.func, + /** + * Popover anchor + */ + anchor: PropTypes.shape({ current: PropTypes.any }), + /** + * Function, for setting query to empty after navigation + */ + setQuery: PropTypes.func, +} diff --git a/src/search/SearchProvider.js b/src/search/SearchProvider.js index f4bb0547..221a432a 100644 --- a/src/search/SearchProvider.js +++ b/src/search/SearchProvider.js @@ -4,23 +4,20 @@ import SearchContext from './SearchContext' import _fetch from '../fetch' import debounce from 'lodash/debounce' import { fetchLatest, StaleResponseError } from '../utils/fetchLatest' -import useNavigationEvent from '../hooks/useNavigationEvent' const fetch = fetchLatest(_fetch) -export default function SearchProvider({ children, initialGroups, onClose, open }) { +export default function SearchProvider({ children, query, initialGroups, active }) { const [state, setState] = useState({ groups: initialGroups, loading: true, }) useEffect(() => { - if (open && state.groups == null) { - fetchSuggestions('') + if (active) { + fetchSuggestions(query) } - }, [open]) - - useNavigationEvent(onClose) + }, [active, query]) const fetchSuggestions = debounce(async text => { try { @@ -51,7 +48,6 @@ export default function SearchProvider({ children, initialGroups, onClose, open state, setState, fetchSuggestions, - onClose, } return {children} @@ -60,5 +56,4 @@ export default function SearchProvider({ children, initialGroups, onClose, open SearchProvider.propTypes = { open: PropTypes.bool, initialGroups: PropTypes.array, - onClose: PropTypes.func, } diff --git a/src/search/SearchSuggestions.js b/src/search/SearchSuggestions.js index f5e47063..bfbc9565 100644 --- a/src/search/SearchSuggestions.js +++ b/src/search/SearchSuggestions.js @@ -23,21 +23,24 @@ export const styles = theme => ({ }) const useStyles = makeStyles(styles, { name: 'RSFSearchSuggestions' }) -export default function SearchSuggestions({ classes }) { +export default function SearchSuggestions({ classes, render }) { classes = useStyles({ classes }) const { state } = useContext(SearchContext) return ( -
+ <> - - {state.groups && - state.groups.map((group, i) => ( -
- -
- ))} -
+
+ {render + ? render(state) + : state.groups && + state.groups.map(group => ( +
+ +
+ ))} +
+ ) } diff --git a/test/hooks/useNavigationEvent.test.js b/test/hooks/useNavigationEvent.test.js index 9584fa46..4ccdacd5 100644 --- a/test/hooks/useNavigationEvent.test.js +++ b/test/hooks/useNavigationEvent.test.js @@ -1,6 +1,6 @@ import React from 'react' import { mount } from 'enzyme' -import { Router, goBack, navigate } from '../mocks/mockRouter' +import { navigate } from '../mocks/mockRouter' import useNavigationEvent from 'react-storefront/hooks/useNavigationEvent' describe('useNavigationEvent', () => { diff --git a/test/search/SearchDrawer.test.js b/test/search/SearchDrawer.test.js index caea4727..9e08f84d 100644 --- a/test/search/SearchDrawer.test.js +++ b/test/search/SearchDrawer.test.js @@ -1,6 +1,7 @@ import React from 'react' import { mount } from 'enzyme' import SearchDrawer from 'react-storefront/search/SearchDrawer' +import { navigate } from '../mocks/mockRouter' describe('SearchDrawer', () => { let wrapper @@ -18,4 +19,31 @@ describe('SearchDrawer', () => { expect(wrapper.find('#test')).toExist() }) + + it('should work without onClose', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(null) + wrapper = mount( + +
+ , + ) + navigate('/foo') + expect(wrapper.find('#test')).toExist() + + consoleSpy.mockRestore() + }) + + it('should call onClose on navigation', async () => { + const onCloseMock = jest.fn() + + wrapper = mount( + +
+
, + ) + + expect(onCloseMock).not.toHaveBeenCalled() + navigate('/foo') + expect(onCloseMock).toHaveBeenCalled() + }) }) diff --git a/test/search/SearchField.test.js b/test/search/SearchField.test.js index f89fb896..29b95982 100644 --- a/test/search/SearchField.test.js +++ b/test/search/SearchField.test.js @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useState } from 'react' import { mount } from 'enzyme' import SearchField from 'react-storefront/search/SearchField' import SearchContext from 'react-storefront/search/SearchContext' @@ -7,46 +7,42 @@ import { Fab, Button } from '@material-ui/core' import { IconButton } from '@material-ui/core' describe('SearchField', () => { - let wrapper + let wrapper, getQuery const suggestionsSpy = jest.fn() afterEach(() => { wrapper.unmount() suggestionsSpy.mockReset() + getQuery = undefined }) - it('should fetch suggestions on input change', async () => { - wrapper = mount( - - - , - ) + const TestComponent = props => { + const [query, setQuery] = useState('') + getQuery = query + + return setQuery(value)} value={query} {...props} /> + } + + it('should change query on input change', async () => { + wrapper = mount() wrapper.find('input').simulate('change', { target: { value: 'test' } }) - expect(suggestionsSpy).toHaveBeenCalledTimes(1) + expect(getQuery).toBe('test') wrapper.find('input').simulate('change', { target: { value: 'test2' } }) - expect(suggestionsSpy).toHaveBeenCalledTimes(2) + expect(getQuery).toBe('test2') }) it('should spread props to the search field', async () => { - wrapper = mount( - - - , - ) + wrapper = mount() expect(wrapper.find(SearchField).prop('spreadprops')).toBe('spread') }) it('should reset input value on clear click', async () => { - wrapper = mount( - - - , - ) + wrapper = mount() wrapper.find('input').simulate('change', { target: { value: 'test' } }) @@ -60,11 +56,7 @@ describe('SearchField', () => { it('should select whole text on focus', async () => { const selectionSpy = jest.spyOn(HTMLInputElement.prototype, 'setSelectionRange') - wrapper = mount( - - - , - ) + wrapper = mount() wrapper.find('input').simulate('change', { target: { value: 'test' } }) wrapper.find('input').simulate('focus') @@ -74,11 +66,7 @@ describe('SearchField', () => { }) it('should hide clear button when input has no value', async () => { - wrapper = mount( - - - , - ) + wrapper = mount() expect(wrapper.find(SearchSubmitButton).prop('className')).toContain('hidden') @@ -88,24 +76,31 @@ describe('SearchField', () => { }) it('should by default render submit button as fab', async () => { - wrapper = mount( - - - , - ) + wrapper = mount() expect(wrapper.find(Fab)).toExist() expect(wrapper.find(Button)).not.toExist() }) it('should by render icon button when submitButtonVariant prop is icon', async () => { - wrapper = mount( - - - , - ) + wrapper = mount() expect(wrapper.find(Fab)).not.toExist() expect(wrapper.find(Button)).toExist() }) + + it('should call onFocus method if provided, when focusing', async () => { + const onFocusSpy = jest.fn() + wrapper = mount() + + wrapper.find('input').simulate('focus') + + expect(onFocusSpy).toBeCalled() + }) + + it('should not display any submit buttons when submitButtonVariant prop value is none', async () => { + wrapper = mount() + + expect(wrapper.find('button')).not.toExist() + }) }) diff --git a/test/search/SearchForm.test.js b/test/search/SearchForm.test.js index 0619ab93..1a0f0d51 100644 --- a/test/search/SearchForm.test.js +++ b/test/search/SearchForm.test.js @@ -33,6 +33,16 @@ describe('SearchForm', () => { expect(wrapper.find('#test')).toExist() }) + it('should have auto complete on when passed as a prop', () => { + wrapper = mount( + +
test
+
, + ) + + expect(wrapper.find('form').prop('autoComplete')).toBe('on') + }) + it('should be able to join query params from action prop on fetch call', async () => { wrapper = mount() diff --git a/test/search/SearchPopover.test.js b/test/search/SearchPopover.test.js new file mode 100644 index 00000000..04ad4aad --- /dev/null +++ b/test/search/SearchPopover.test.js @@ -0,0 +1,62 @@ +import React, { useRef, useEffect, useState } from 'react' +import { mount } from 'enzyme' +import SearchPopover from 'react-storefront/search/SearchPopover' +import { navigate } from '../mocks/mockRouter' + +describe('SearchPopover', () => { + let wrapper, getRef + + afterEach(() => { + wrapper.unmount() + getRef = undefined + }) + + const TestComponent = props => { + const testRef = useRef(null) + const [open, setOpen] = useState(false) + + useEffect(() => { + getRef = testRef + setOpen(true) + }, []) + + return ( + <> +
+ null} anchor={testRef} {...props}> +
+ +
+ + ) + } + + it('should render children', () => { + wrapper = mount() + + expect(wrapper.find('#test')).toExist() + }) + + it('should work when useQuery and onClose not provided', () => { + wrapper = mount() + + navigate('/foo') + expect(wrapper.find('#test')).toExist() + }) + + it('should call onClose, reset query and blur on navigation event', async () => { + const onCloseMock = jest.fn() + const setQueryMock = jest.fn() + const blurMock = jest.fn() + + wrapper = mount() + + getRef.current.blur = blurMock + + expect(onCloseMock).not.toHaveBeenCalled() + navigate('/foo') + expect(onCloseMock).toHaveBeenCalled() + expect(setQueryMock).toHaveBeenCalledWith('') + expect(blurMock).toHaveBeenCalled() + }) +}) diff --git a/test/search/SearchProvider.test.js b/test/search/SearchProvider.test.js index 2a94ece5..d9943ef3 100644 --- a/test/search/SearchProvider.test.js +++ b/test/search/SearchProvider.test.js @@ -2,16 +2,11 @@ import React, { useContext } from 'react' import { mount } from 'enzyme' import SearchProvider from 'react-storefront/search/SearchProvider' import SearchContext from 'react-storefront/search/SearchContext' -import * as useNavigationEvent from 'react-storefront/hooks/useNavigationEvent' import { StaleResponseError } from 'react-storefront/utils/fetchLatest' import { act } from 'react-dom/test-utils' describe('SearchProvider', () => { - let wrapper, context, navigationSpy - - beforeEach(() => { - navigationSpy = jest.spyOn(useNavigationEvent, 'default').mockImplementation(options => options) - }) + let wrapper, context afterEach(() => { wrapper.unmount() @@ -26,47 +21,51 @@ describe('SearchProvider', () => { return null } - it('should call navigation event and context should provide onClose', async () => { - fetchMock.mockResponseOnce(JSON.stringify({})) - const onCloseMock = jest.fn() + it('should fetch suggestions if active is set to true', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ groups: 'test' })) wrapper = mount( - + , ) + expect(context.state.groups).toBe(undefined) // check that suggestions aren't fetched on mount + await act(async () => { - await sleep(250) // to trigger debounce + wrapper.setProps({ active: true }) + await sleep(300) // to trigger debounce await wrapper.update() }) - expect(navigationSpy).toHaveBeenCalledWith(onCloseMock) - expect(context.onClose).toBe(onCloseMock) - act(() => { - context.onClose() - }) - expect(onCloseMock).toHaveBeenCalled() + expect(context.state.groups).toBe('test') // check that suggestions are fetched only after active is set to true }) - it('should fetch suggestions the first time open is set to true', async () => { + it('should fetch suggestions if query is changed', async () => { fetchMock.mockResponseOnce(JSON.stringify({ groups: 'test' })) wrapper = mount( - + , ) - expect(context.state.groups).toBe(undefined) // check that suggestions aren't fetched on mount + await act(async () => { + await sleep(300) // to trigger debounce + await wrapper.update() + }) + + expect(context.state.groups).toBe('test') // check that suggestions aren't fetched on mount + + fetchMock.mockResponseOnce(JSON.stringify({ groups: 'test2' })) await act(async () => { - wrapper.setProps({ open: true }) + wrapper.setProps({ query: 'a' }) await sleep(300) // to trigger debounce await wrapper.update() }) - expect(context.state.groups).toBe('test') // check that suggestions are fetched only after open is set to true + expect(context.state.groups).toBe('test2') // check that suggestions are fetched only after active is set to true }) it('should catch fetch errors and set loading to false', async () => { diff --git a/test/search/SearchSuggetions.test.js b/test/search/SearchSuggetions.test.js index 19bda945..5c7ddb1e 100644 --- a/test/search/SearchSuggetions.test.js +++ b/test/search/SearchSuggetions.test.js @@ -22,6 +22,16 @@ describe('SearchSuggestions', () => { expect(wrapper.find(LoadMask).prop('show')).toBe(true) }) + it('should render custom children when render prop provided', () => { + wrapper = mount( + +
{state.groups}
} /> +
, + ) + + expect(wrapper.find('#test').text()).toBe('testContent') + }) + it('should render search suggestion groups', () => { wrapper = mount(