diff --git a/assets/js/base/utils/errors.js b/assets/js/base/utils/errors.js index b74e65e98c3..17b49c53215 100644 --- a/assets/js/base/utils/errors.js +++ b/assets/js/base/utils/errors.js @@ -10,7 +10,7 @@ import { __ } from '@wordpress/i18n'; * @param {Function} [error.json] If a json method is specified, it will try parsing the error first. * @param {string} [error.message] If a message is specified, it will be shown to the user. * @param {string} [error.type] The context in which the error was triggered. - * @return {Object} Error object containing a message and type. + * @return {Promise<{message:string;type:string;}>} Error object containing a message and type. */ export const formatError = async ( error ) => { if ( typeof error.json === 'function' ) { diff --git a/assets/js/editor-components/utils/index.js b/assets/js/editor-components/utils/index.js index ffd42305eee..234ca4e462e 100644 --- a/assets/js/editor-components/utils/index.js +++ b/assets/js/editor-components/utils/index.js @@ -12,14 +12,14 @@ import { blocksConfig } from '@woocommerce/block-settings'; * Get product query requests for the Store API. * * @param {Object} request A query object with the list of selected products and search term. - * @param {Array} request.selected Currently selected products. - * @param {string} request.search Search string. - * @param {Array} request.queryArgs Query args to pass in. + * @param {number[]} request.selected Currently selected products. + * @param {string=} request.search Search string. + * @param {(Record)=} request.queryArgs Query args to pass in. */ const getProductsRequests = ( { selected = [], search = '', - queryArgs = [], + queryArgs = {}, } ) => { const isLargeCatalog = blocksConfig.productCount > 100; const defaultArgs = { @@ -39,6 +39,7 @@ const getProductsRequests = ( { addQueryArgs( '/wc/store/products', { catalog_visibility: 'any', include: selected, + per_page: 0, } ) ); } @@ -50,14 +51,16 @@ const getProductsRequests = ( { * Get a promise that resolves to a list of products from the Store API. * * @param {Object} request A query object with the list of selected products and search term. - * @param {Array} request.selected Currently selected products. - * @param {string} request.search Search string. - * @param {Array} request.queryArgs Query args to pass in. + * @param {number[]} request.selected Currently selected products. + * @param {string=} request.search Search string. + * @param {(Record)=} request.queryArgs Query args to pass in. + * @return {Promise} Promise resolving to a Product list. + * @throws Exception if there is an error. */ export const getProducts = ( { selected = [], search = '', - queryArgs = [], + queryArgs = {}, } ) => { const requests = getProductsRequests( { selected, search, queryArgs } ); diff --git a/assets/js/hocs/test/with-searched-products.js b/assets/js/hocs/test/with-searched-products.js index bc00f7e525d..8f034816ffe 100644 --- a/assets/js/hocs/test/with-searched-products.js +++ b/assets/js/hocs/test/with-searched-products.js @@ -1,9 +1,9 @@ /** * External dependencies */ -import TestRenderer from 'react-test-renderer'; -import _ from 'lodash'; +import TestRenderer, { act } from 'react-test-renderer'; import * as mockUtils from '@woocommerce/editor-components/utils'; +import * as mockUseDebounce from 'use-debounce'; /** * Internal dependencies @@ -25,23 +25,17 @@ mockUtils.getProducts = jest.fn().mockImplementation( () => ] ) ); -// Add a mock implementation of debounce for testing so we can spy on -// the onSearch call. -const debouncedCancel = jest.fn(); -const debouncedAction = jest.fn(); -_.debounce = ( onSearch ) => { - const debounced = debouncedAction.mockImplementation( () => { - onSearch(); - } ); - debounced.cancel = debouncedCancel; - return debounced; -}; +// Add a mock implementation of debounce for testing so we can spy on the onSearch call. +mockUseDebounce.useDebouncedCallback = jest + .fn() + .mockImplementation( ( search ) => [ + () => mockUtils.getProducts( search ), + ] ); describe( 'withSearchedProducts Component', () => { const { getProducts } = mockUtils; afterEach( () => { - debouncedCancel.mockClear(); - debouncedAction.mockClear(); + mockUseDebounce.useDebouncedCallback.mockClear(); mockUtils.getProducts.mockClear(); } ); const TestComponent = withSearchedProducts( @@ -58,18 +52,14 @@ describe( 'withSearchedProducts Component', () => { ); describe( 'lifecycle tests', () => { const selected = [ 10 ]; - const renderer = TestRenderer.create( - - ); - let props; - it( - 'getProducts is called on mount with passed in selected ' + - 'values', - () => { - expect( getProducts ).toHaveBeenCalledWith( { selected } ); - expect( getProducts ).toHaveBeenCalledTimes( 1 ); - } - ); + let props, renderer; + + act( () => { + renderer = TestRenderer.create( + + ); + } ); + it( 'has expected values for props', () => { props = renderer.root.findByType( 'div' ).props; expect( props.selected ).toEqual( selected ); @@ -78,16 +68,16 @@ describe( 'withSearchedProducts Component', () => { { id: 20, name: 'bar', parent: 0 }, ] ); } ); - it( 'debounce and getProducts is called on search event', () => { + + it( 'debounce and getProducts is called on search event', async () => { props = renderer.root.findByType( 'div' ).props; - props.onSearch(); - expect( debouncedAction ).toHaveBeenCalled(); + + act( () => { + props.onSearch(); + } ); + + expect( mockUseDebounce.useDebouncedCallback ).toHaveBeenCalled(); expect( getProducts ).toHaveBeenCalledTimes( 1 ); } ); - it( 'debounce is cancelled on unmount', () => { - renderer.unmount(); - expect( debouncedCancel ).toHaveBeenCalled(); - expect( getProducts ).toHaveBeenCalledTimes( 0 ); - } ); } ); } ); diff --git a/assets/js/hocs/with-searched-products.js b/assets/js/hocs/with-searched-products.js deleted file mode 100644 index 2853135c2de..00000000000 --- a/assets/js/hocs/with-searched-products.js +++ /dev/null @@ -1,105 +0,0 @@ -/** - * External dependencies - */ -import { Component } from '@wordpress/element'; -import { debounce } from 'lodash'; -import { createHigherOrderComponent } from '@wordpress/compose'; -import PropTypes from 'prop-types'; -import { blocksConfig } from '@woocommerce/block-settings'; -import { getProducts } from '@woocommerce/editor-components/utils'; - -/** - * Internal dependencies - */ -import { formatError } from '../base/utils/errors.js'; - -/** - * A higher order component that enhances the provided component with products - * from a search query. - * - * @param {Function} OriginalComponent Component being wrapped. - */ -const withSearchedProducts = createHigherOrderComponent( - ( OriginalComponent ) => { - /** - * A Component wrapping the passed in component. - * - * @class WrappedComponent - * @augments {Component} - */ - class WrappedComponent extends Component { - constructor() { - super( ...arguments ); - this.state = { - list: [], - loading: true, - }; - this.setError = this.setError.bind( this ); - this.debouncedOnSearch = debounce( - this.onSearch.bind( this ), - 400 - ); - } - - componentDidMount() { - const { selected } = this.props; - getProducts( { selected } ) - .then( ( list ) => { - this.setState( { list, loading: false } ); - } ) - .catch( this.setError ); - } - - componentWillUnmount() { - this.debouncedOnSearch.cancel(); - } - - onSearch( search ) { - const { selected } = this.props; - - getProducts( { selected, search } ) - .then( ( list ) => { - this.setState( { list, loading: false } ); - } ) - .catch( this.setError ); - } - - async setError( e ) { - const error = await formatError( e ); - - this.setState( { list: [], loading: false, error } ); - } - - render() { - const { error, list, loading } = this.state; - - return ( - 100 - ? ( search ) => { - this.setState( { loading: true } ); - this.debouncedOnSearch( search ); - } - : null - } - /> - ); - } - } - WrappedComponent.propTypes = { - selected: PropTypes.array, - }; - WrappedComponent.defaultProps = { - selected: [], - }; - return WrappedComponent; - }, - 'withSearchedProducts' -); - -export default withSearchedProducts; diff --git a/assets/js/hocs/with-searched-products.tsx b/assets/js/hocs/with-searched-products.tsx new file mode 100644 index 00000000000..49a71eb40b3 --- /dev/null +++ b/assets/js/hocs/with-searched-products.tsx @@ -0,0 +1,86 @@ +/** + * External dependencies + */ +import { useEffect, useState, useCallback } from '@wordpress/element'; +import { blocksConfig } from '@woocommerce/block-settings'; +import { getProducts } from '@woocommerce/editor-components/utils'; +import { useDebouncedCallback } from 'use-debounce'; +import type { ProductResponseItem } from '@woocommerce/types'; + +/** + * Internal dependencies + */ +import { formatError } from '../base/utils/errors.js'; + +/** + * A higher order component that enhances the provided component with products from a search query. + */ +const withSearchedProducts = ( + OriginalComponent: React.FunctionComponent< Record< string, unknown > > +) => { + return ( { selected, ...props }: { selected: number[] } ): JSX.Element => { + const [ isLoading, setIsLoading ] = useState( true ); + const [ error, setError ] = useState< { + message: string; + type: string; + } | null >( null ); + const [ productsList, setProductsList ] = useState< + ProductResponseItem[] + >( [] ); + const isLargeCatalog = blocksConfig.productCount > 100; + + const setErrorState = async ( e: { + message: string; + type: string; + } ) => { + const formattedError = ( await formatError( e ) ) as { + message: string; + type: string; + }; + setError( formattedError ); + setIsLoading( false ); + }; + + useEffect( () => { + getProducts( { selected } ) + .then( ( results ) => { + setProductsList( results as ProductResponseItem[] ); + setIsLoading( false ); + } ) + .catch( setErrorState ); + }, [ selected ] ); + + const [ debouncedSearch ] = useDebouncedCallback( + ( search: string ) => { + getProducts( { selected, search } ) + .then( ( results ) => { + setProductsList( results as ProductResponseItem[] ); + setIsLoading( false ); + } ) + .catch( setErrorState ); + }, + 400 + ); + + const onSearch = useCallback( + ( search: string ) => { + setIsLoading( true ); + debouncedSearch( search ); + }, + [ setIsLoading, debouncedSearch ] + ); + + return ( + + ); + }; +}; + +export default withSearchedProducts;