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;