Introduce withSearchedProducts higher order component and refactor ProductsControl Component (https://github.com/woocommerce/woocommerce-blocks/pull/791)

* add new withSearchedProducts hoc

includes tests

* convert to functional component and wrap with new hoc

* remove dependency between tests

* Fix typo with PropTypes bool

Co-Authored-By: Albert Juhé Lluveras <contact@albertjuhe.com>

* remove unnecessary specific import

* fix bug introduced on refactor for selected prop recalc

* fix tests that were testing the wrong expectation

- also improved mocks a bit so they are a bit more realistic for the purpose of the test coverage.

* remove unnecessary Fragment

* tweak propType definitions and remove defaults

- this keeps argument expectations in sync with wrapped component and reduces chance of unexpected behaviour if `SearchListControl` logic changes.

* remove lodash.find dependency.
This commit is contained in:
Darren Ethier 2019-08-06 11:34:13 -04:00 committed by GitHub
parent 918f822128
commit 2d5b06f07a
4 changed files with 248 additions and 97 deletions

View File

@ -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 (
<Fragment>
<SearchListControl
className="woocommerce-products"
list={ list }
isLoading={ loading }
selected={ selected.map( ( id ) => find( list, { id } ) ).filter( Boolean ) }
onSearch={ isLargeCatalog ? this.debouncedOnSearch : null }
onChange={ onChange }
messages={ messages }
/>
</Fragment>
);
}
}
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 (
<SearchListControl
className="woocommerce-products"
list={ products }
isLoading={ isLoading }
selected={ selected }
onSearch={ onSearch }
onChange={ onChange }
messages={ messages }
/>
);
};
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 );

View File

@ -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';

View File

@ -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 <div
products={ products }
selected={ selected }
isLoading={ isLoading }
onSearch={ onSearch }
/>;
} );
describe( 'lifecycle tests', () => {
const selected = [ 10 ];
const renderer = TestRenderer.create(
<TestComponent
selected={ selected }
/>
);
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 );
} );
} );
} );

View File

@ -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 (
<OriginalComponent
{ ...this.props }
products={ list }
isLoading={ loading }
selected={ list.filter(
( { id } ) => selected.includes( id )
) }
onSearch={ isLargeCatalog ? this.debouncedOnSearch : null }
/>
);
}
}
WrappedComponent.propTypes = {
selected: PropTypes.array,
};
WrappedComponent.defaultProps = {
selected: [],
};
return WrappedComponent;
}, 'withSearchedProducts' );
export default withSearchedProducts;