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:
parent
918f822128
commit
2d5b06f07a
|
@ -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 );
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 );
|
||||
} );
|
||||
} );
|
||||
} );
|
|
@ -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;
|
Loading…
Reference in New Issue