diff --git a/plugins/woocommerce-blocks/assets/js/components/products-control/index.js b/plugins/woocommerce-blocks/assets/js/components/products-control/index.js
index 98090ddee8a..f1477290739 100644
--- a/plugins/woocommerce-blocks/assets/js/components/products-control/index.js
+++ b/plugins/woocommerce-blocks/assets/js/components/products-control/index.js
@@ -2,110 +2,86 @@
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
-import { Component, Fragment } from '@wordpress/element';
-import { debounce, find } from 'lodash';
-import PropTypes from 'prop-types';
import { SearchListControl } from '@woocommerce/components';
+import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
-import { isLargeCatalog, getProducts } from '../utils';
+import { withSearchedProducts } from '../../hocs';
-class ProductsControl extends Component {
- constructor() {
- super( ...arguments );
- this.state = {
- list: [],
- loading: true,
- };
-
- this.debouncedOnSearch = debounce( this.onSearch.bind( this ), 400 );
- }
-
- componentDidMount() {
- const { selected } = this.props;
-
- getProducts( { selected } )
- .then( ( list ) => {
- this.setState( { list, loading: false } );
- } )
- .catch( () => {
- this.setState( { list: [], loading: false } );
- } );
- }
-
- componentWillUnmount() {
- this.debouncedOnSearch.cancel();
- }
-
- onSearch( search ) {
- const { selected } = this.props;
- getProducts( { selected, search } )
- .then( ( list ) => {
- this.setState( { list, loading: false } );
- } )
- .catch( () => {
- this.setState( { list: [], loading: false } );
- } );
- }
-
- render() {
- const { list, loading } = this.state;
- const { onChange, selected } = this.props;
-
- const messages = {
- clear: __( 'Clear all products', 'woo-gutenberg-products-block' ),
- list: __( 'Products', 'woo-gutenberg-products-block' ),
- noItems: __(
- "Your store doesn't have any products.",
- 'woo-gutenberg-products-block'
- ),
- search: __(
- 'Search for products to display',
- 'woo-gutenberg-products-block'
- ),
- selected: ( n ) =>
- sprintf(
- _n(
- '%d product selected',
- '%d products selected',
- n,
- 'woo-gutenberg-products-block'
- ),
- n
+/**
+ * The products control exposes a custom selector for searching and selecting
+ * products.
+ *
+ * @param {function} props.onChange Callback fired when the selected item
+ * changes
+ * @param {function} props.onSearch Callback fired when a search is triggered
+ * @param {array} props.selected An array of selected products.
+ * @param {array} props.products An array of products to select from.
+ * @param {boolean} props.isLoading Whether or not the products are being
+ * loaded.
+ *
+ * @return {function} A functional component.
+ */
+const ProductsControl = ( {
+ onChange,
+ onSearch,
+ selected,
+ products,
+ isLoading,
+} ) => {
+ const messages = {
+ clear: __( 'Clear all products', 'woo-gutenberg-products-block' ),
+ list: __( 'Products', 'woo-gutenberg-products-block' ),
+ noItems: __(
+ "Your store doesn't have any products.",
+ 'woo-gutenberg-products-block'
+ ),
+ search: __(
+ 'Search for products to display',
+ 'woo-gutenberg-products-block'
+ ),
+ selected: ( n ) =>
+ sprintf(
+ _n(
+ '%d product selected',
+ '%d products selected',
+ n,
+ 'woo-gutenberg-products-block'
),
- updated: __(
- 'Product search results updated.',
- 'woo-gutenberg-products-block'
+ n
),
- };
-
- return (
-
- find( list, { id } ) ).filter( Boolean ) }
- onSearch={ isLargeCatalog ? this.debouncedOnSearch : null }
- onChange={ onChange }
- messages={ messages }
- />
-
- );
- }
-}
-
-ProductsControl.propTypes = {
- /**
- * Callback to update the selected products.
- */
- onChange: PropTypes.func.isRequired,
- /**
- * The list of currently selected IDs.
- */
- selected: PropTypes.array.isRequired,
+ updated: __(
+ 'Product search results updated.',
+ 'woo-gutenberg-products-block'
+ ),
+ };
+ return (
+
+ );
};
-export default ProductsControl;
+ProductsControl.propTypes = {
+ onChange: PropTypes.func.isRequired,
+ onSearch: PropTypes.func,
+ selected: PropTypes.array,
+ products: PropTypes.array,
+ isLoading: PropTypes.bool,
+};
+
+ProductsControl.defaultProps = {
+ selected: [],
+ products: [],
+ isLoading: true,
+};
+
+export default withSearchedProducts( ProductsControl );
diff --git a/plugins/woocommerce-blocks/assets/js/hocs/index.js b/plugins/woocommerce-blocks/assets/js/hocs/index.js
index 4c58a82bb9e..5e0807a75ee 100644
--- a/plugins/woocommerce-blocks/assets/js/hocs/index.js
+++ b/plugins/woocommerce-blocks/assets/js/hocs/index.js
@@ -1,2 +1,3 @@
export { default as withComponentId } from './with-component-id';
export { default as withProduct } from './with-product';
+export { default as withSearchedProducts } from './with-searched-products';
diff --git a/plugins/woocommerce-blocks/assets/js/hocs/test/with-searched-products.js b/plugins/woocommerce-blocks/assets/js/hocs/test/with-searched-products.js
new file mode 100644
index 00000000000..ad0e9adbf29
--- /dev/null
+++ b/plugins/woocommerce-blocks/assets/js/hocs/test/with-searched-products.js
@@ -0,0 +1,88 @@
+/**
+ * External dependencies
+ */
+import TestRenderer from 'react-test-renderer';
+import _ from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import withSearchedProducts from '../with-searched-products';
+import * as mockedUtils from '../../components/utils';
+
+// Mock the getProducts and isLargeCatalog values for tests.
+mockedUtils.getProducts = jest.fn().mockImplementation(
+ () => Promise.resolve(
+ [ { id: 10, name: 'foo' }, { id: 20, name: 'bar' } ]
+ )
+);
+mockedUtils.isLargeCatalog = true;
+
+// 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;
+};
+
+describe( 'withSearchedProducts Component', () => {
+ const { getProducts } = mockedUtils;
+ afterEach( () => {
+ debouncedCancel.mockClear();
+ debouncedAction.mockClear();
+ mockedUtils.getProducts.mockClear();
+ mockedUtils.isLargeCatalog = false;
+ } );
+ const TestComponent = withSearchedProducts( ( {
+ selected,
+ products,
+ isLoading,
+ onSearch,
+ } ) => {
+ return
;
+ } );
+ 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 );
+ } );
+ it( 'has expected values for props', () => {
+ props = renderer.root.findByType( 'div' ).props;
+ expect( props.selected ).toEqual( [ { id: 10, name: 'foo' } ] );
+ expect( props.products ).toEqual(
+ [ { id: 10, name: 'foo' }, { id: 20, name: 'bar' } ]
+ );
+ } );
+ it( 'debounce and getProducts is called on search event', () => {
+ props = renderer.root.findByType( 'div' ).props;
+ props.onSearch();
+ expect( debouncedAction ).toHaveBeenCalled();
+ expect( getProducts ).toHaveBeenCalledTimes( 1 );
+ } );
+ it( 'debounce is cancelled on unmount', () => {
+ renderer.unmount();
+ expect( debouncedCancel ).toHaveBeenCalled();
+ expect( getProducts ).toHaveBeenCalledTimes( 0 );
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce-blocks/assets/js/hocs/with-searched-products.js b/plugins/woocommerce-blocks/assets/js/hocs/with-searched-products.js
new file mode 100644
index 00000000000..72130939f04
--- /dev/null
+++ b/plugins/woocommerce-blocks/assets/js/hocs/with-searched-products.js
@@ -0,0 +1,86 @@
+/**
+ * External dependencies
+ */
+import { Component } from '@wordpress/element';
+import { debounce } from 'lodash';
+import { createHigherOrderComponent } from '@wordpress/compose';
+import PropTypes from 'prop-types';
+
+/**
+ * Internal dependencies
+ */
+import { isLargeCatalog, getProducts } from '../components/utils';
+
+/**
+ * A higher order component that enhances the provided component with products
+ * from a search query.
+ */
+const withSearchedProducts = createHigherOrderComponent( ( OriginalComponent ) => {
+ /**
+ * A Component wrapping the passed in component.
+ *
+ * @class WrappedComponent
+ * @extends {Component}
+ */
+ class WrappedComponent extends Component {
+ constructor() {
+ super( ...arguments );
+ this.state = {
+ list: [],
+ loading: true,
+ };
+ this.debouncedOnSearch = debounce( this.onSearch.bind( this ), 400 );
+ }
+
+ componentDidMount() {
+ const { selected } = this.props;
+ getProducts( { selected } )
+ .then( ( list ) => {
+ this.setState( { list, loading: false } );
+ } )
+ .catch( () => {
+ this.setState( { list: [], loading: false } );
+ } );
+ }
+
+ componentWillUnmount() {
+ this.debouncedOnSearch.cancel();
+ }
+
+ onSearch( search ) {
+ const { selected } = this.props;
+ getProducts( { selected, search } )
+ .then( ( list ) => {
+ this.setState( { list, loading: false } );
+ } )
+ .catch( () => {
+ this.setState( { list: [], loading: false } );
+ } );
+ }
+
+ render() {
+ const { list, loading } = this.state;
+ const { selected } = this.props;
+ return (
+ selected.includes( id )
+ ) }
+ onSearch={ isLargeCatalog ? this.debouncedOnSearch : null }
+ />
+ );
+ }
+ }
+ WrappedComponent.propTypes = {
+ selected: PropTypes.array,
+ };
+ WrappedComponent.defaultProps = {
+ selected: [],
+ };
+ return WrappedComponent;
+}, 'withSearchedProducts' );
+
+export default withSearchedProducts;