diff --git a/package.json b/package.json index a42986d3..e1455edf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-storefront", - "version": "8.14.1", + "version": "8.15.0", "description": "Build and deploy e-commerce progressive web apps (PWAs) in record time.", "module": "./index.js", "license": "Apache-2.0", diff --git a/src/search/SearchProvider.js b/src/search/SearchProvider.js index d121b0d5..2cf3704c 100644 --- a/src/search/SearchProvider.js +++ b/src/search/SearchProvider.js @@ -2,25 +2,28 @@ import React, { useState, useEffect } from 'react' import PropTypes from 'prop-types' import SearchContext from './SearchContext' import _fetch from '../fetch' -import debounce from 'lodash/debounce' +import useDebounce from '../utils/useDebounce' import { fetchLatest, StaleResponseError } from '../utils/fetchLatest' import getAPIURL from '../api/getAPIURL' const fetch = fetchLatest(_fetch) -export default function SearchProvider({ children, query, initialGroups, active }) { +export default function SearchProvider({ + children, + query, + initialGroups, + active, + minQueryLength, + debounceTime, +}) { const [state, setState] = useState({ groups: initialGroups, loading: true, }) - useEffect(() => { - if (active) { - fetchSuggestions(query) - } - }, [active, query]) + const debouncedQuery = useDebounce(query, debounceTime) - const fetchSuggestions = debounce(async text => { + const fetchSuggestions = async text => { try { setState(state => ({ ...state, @@ -43,10 +46,16 @@ export default function SearchProvider({ children, query, initialGroups, active })) } } - }, 250) + } + + useEffect(() => { + if (active && (debouncedQuery.length >= minQueryLength || !debouncedQuery)) { + fetchSuggestions(debouncedQuery) + } + }, [active, debouncedQuery]) const context = { - query, + query: debouncedQuery, state, setState, fetchSuggestions, @@ -58,4 +67,17 @@ export default function SearchProvider({ children, query, initialGroups, active SearchProvider.propTypes = { open: PropTypes.bool, initialGroups: PropTypes.array, + /** + * Minimum length of search query to fetch. Default is 3 + */ + minQueryLength: PropTypes.number, + /** + * Default is 250 + */ + debounceTime: PropTypes.number, +} + +SearchProvider.defaultProps = { + minQueryLength: 3, + debounceTime: 250, } diff --git a/src/utils/useDebounce.js b/src/utils/useDebounce.js new file mode 100644 index 00000000..e9b6b626 --- /dev/null +++ b/src/utils/useDebounce.js @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react' + +export default function useDebounce(value, delay) { + // State and setters for debounced value + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect( + () => { + // Update debounced value after delay + const handler = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + // Cancel the timeout if value changes (also on delay change or unmount) + // This is how we prevent debounced value from updating if value is changed ... + // .. within the delay period. Timeout gets cleared and restarted. + return () => { + clearTimeout(handler) + } + }, + [value, delay], // Only re-call effect if value or delay changes + ) + + return debouncedValue +} diff --git a/test/search/SearchProvider.test.js b/test/search/SearchProvider.test.js index d9943ef3..cce1661d 100644 --- a/test/search/SearchProvider.test.js +++ b/test/search/SearchProvider.test.js @@ -60,7 +60,7 @@ describe('SearchProvider', () => { fetchMock.mockResponseOnce(JSON.stringify({ groups: 'test2' })) await act(async () => { - wrapper.setProps({ query: 'a' }) + wrapper.setProps({ query: 'abc' }) await sleep(300) // to trigger debounce await wrapper.update() }) @@ -68,6 +68,34 @@ describe('SearchProvider', () => { expect(context.state.groups).toBe('test2') // check that suggestions are fetched only after active is set to true }) + it('should not fetch suggestions if query length is less than minimum', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ groups: 'test' })) + + wrapper = 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 () => { + // Using a query less than default min length + wrapper.setProps({ query: 'a' }) + await sleep(300) // to trigger debounce + await wrapper.update() + }) + + expect(context.state.groups).toBe('test') + }) + it('should catch fetch errors and set loading to false', async () => { fetchMock.mockRejectOnce(new Error('test error'))