* Update ProductControl to use HOCs

* Only show variations in certain ProductControls

* Cleanup

* Refactor withSingleSelected selected prop logic

* Fix selected values not being respected

* Fix wrong propTypes notation

* Set parent: 0 in getProducts util func

* Use static properties in withProductVariations

* Move messages outside of the functional component

* Check that variations is an array

* Fix debounced search

* Fix broken tests

* Rename withSingleSelected to withTransformSingleSelectToMultipleSelect
This commit is contained in:
Albert Juhé Lluveras 2019-09-25 16:22:36 +02:00 committed by GitHub
parent c86114d2cb
commit 01412e6af0
11 changed files with 590 additions and 206 deletions

View File

@ -97,6 +97,7 @@ const FeaturedProduct = ( {
<div className="wc-block-featured-product__selection">
<ProductControl
selected={ attributes.productId || 0 }
showVariations
onChange={ ( value = [] ) => {
const id = value[ 0 ] ? value[ 0 ].id : 0;
setAttributes( {

View File

@ -2,21 +2,22 @@
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { Component, Fragment } from '@wordpress/element';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { debounce, find, escapeRegExp, isEmpty } from 'lodash';
import { escapeRegExp, isEmpty } from 'lodash';
import PropTypes from 'prop-types';
import { SearchListControl, SearchListItem } from '@woocommerce/components';
import { Spinner, MenuItem } from '@wordpress/components';
import classnames from 'classnames';
import { ENDPOINTS, IS_LARGE_CATALOG } from '@woocommerce/block-settings';
import {
withProductVariations,
withSearchedProducts,
withTransformSingleSelectToMultipleSelect,
} from '../../hocs';
/**
* Internal dependencies
*/
import { getProducts } from '../utils';
import { IconRadioSelected, IconRadioUnselected } from '../icons';
import ErrorMessage from '../error-placeholder/error-message.js';
import './style.scss';
function getHighlightedName( name, search ) {
@ -31,133 +32,41 @@ const getInteractionIcon = ( isSelected = false ) => {
return isSelected ? <IconRadioSelected /> : <IconRadioUnselected />;
};
class ProductControl extends Component {
constructor() {
super( ...arguments );
this.state = {
products: [],
product: 0,
variationsList: {},
variationsLoading: false,
loading: true,
};
const messages = {
list: __( 'Products', 'woo-gutenberg-products-block' ),
noItems: __(
"Your store doesn't have any products.",
'woo-gutenberg-products-block'
),
search: __(
'Search for a product to display',
'woo-gutenberg-products-block'
),
updated: __(
'Product search results updated.',
'woo-gutenberg-products-block'
),
};
this.debouncedOnSearch = debounce( this.onSearch.bind( this ), 400 );
this.debouncedGetVariations = debounce(
this.getVariations.bind( this ),
200
);
this.renderItem = this.renderItem.bind( this );
this.onProductSelect = this.onProductSelect.bind( this );
}
componentWillUnmount() {
this.debouncedOnSearch.cancel();
this.debouncedGetVariations.cancel();
}
componentDidMount() {
const { selected, queryArgs } = this.props;
getProducts( { selected, queryArgs } )
.then( ( products ) => {
products = products.map( ( product ) => {
const count = product.variations
? product.variations.length
: 0;
return {
...product,
parent: 0,
count,
};
} );
this.setState( { products, loading: false } );
} )
.catch( () => {
this.setState( { products: [], loading: false } );
} );
}
componentDidUpdate( prevProps, prevState ) {
if ( prevState.product !== this.state.product ) {
this.debouncedGetVariations();
}
}
getVariations() {
const { product, products, variationsList } = this.state;
if ( ! product ) {
this.setState( {
variationsList: {},
variationsLoading: false,
} );
return;
}
const productDetails = products.find(
( findProduct ) => findProduct.id === product
);
if (
! productDetails.variations ||
productDetails.variations.length === 0
) {
return;
}
if ( ! variationsList[ product ] ) {
this.setState( { variationsLoading: true } );
}
apiFetch( {
path: addQueryArgs(
`${ ENDPOINTS.products }/${ product }/variations`,
{
per_page: -1,
}
),
} )
.then( ( variations ) => {
variations = variations.map( ( variation ) => ( {
...variation,
parent: product,
} ) );
this.setState( ( prevState ) => ( {
variationsList: {
...prevState.variationsList,
[ product ]: variations,
},
variationsLoading: false,
} ) );
} )
.catch( () => {
this.setState( { termsLoading: false } );
} );
}
onSearch( search ) {
const { selected, queryArgs } = this.props;
getProducts( { selected, search, queryArgs } )
.then( ( products ) => {
this.setState( { products, loading: false } );
} )
.catch( () => {
this.setState( { products: [], loading: false } );
} );
}
onProductSelect( item, isSelected ) {
return () => {
this.setState( {
product: isSelected ? 0 : item.id,
} );
};
}
renderItem( args ) {
const ProductControl = ( {
expandedProduct,
error,
isLoading,
onChange,
onSearch,
products,
renderItem,
selected,
showVariations,
variations,
variationsLoading,
} ) => {
const renderItemWithVariations = ( args ) => {
const { item, search, depth = 0, isSelected, onSelect } = args;
const { product, variationsLoading } = this.state;
const variationsCount =
item.variations && Array.isArray( item.variations )
? item.variations.length
: 0;
const classes = classnames(
'woocommerce-search-product__item',
'woocommerce-search-list__item',
@ -165,7 +74,7 @@ class ProductControl extends Component {
{
'is-searching': search.length > 0,
'is-skip-level': depth === 0 && item.parent !== 0,
'is-variable': item.count > 0,
'is-variable': variationsCount > 0,
}
);
@ -182,8 +91,8 @@ class ProductControl extends Component {
}`;
}
if ( item.count ) {
a11yProps[ 'aria-expanded' ] = item.id === product;
if ( variationsCount ) {
a11yProps[ 'aria-expanded' ] = item.id === expandedProduct;
}
// Top level items custom rendering based on SearchListItem.
@ -197,7 +106,6 @@ class ProductControl extends Component {
className={ classes }
onClick={ () => {
onSelect( item )();
this.onProductSelect( item, isSelected )();
} }
>
<span className="woocommerce-search-list__item-state">
@ -213,31 +121,33 @@ class ProductControl extends Component {
/>
</span>
{ item.count ? (
{ variationsCount ? (
<span className="woocommerce-search-list__item-variation-count">
{ sprintf(
_n(
'%d variation',
'%d variations',
item.count,
variationsCount,
'woo-gutenberg-products-block'
),
item.count
variationsCount
) }
</span>
) : null }
</MenuItem>,
product === item.id && item.count > 0 && variationsLoading && (
<div
key="loading"
className={
'woocommerce-search-list__item woocommerce-search-product__item' +
'depth-1 is-loading is-not-active'
}
>
<Spinner />
</div>
),
expandedProduct === item.id &&
variationsCount > 0 &&
variationsLoading && (
<div
key="loading"
className={
'woocommerce-search-list__item woocommerce-search-product__item' +
'depth-1 is-loading is-not-active'
}
>
<Spinner />
</div>
),
];
}
@ -252,52 +162,44 @@ class ProductControl extends Component {
{ ...a11yProps }
/>
);
};
const getRenderItemFunc = () => {
if ( renderItem ) {
return renderItem;
} else if ( showVariations ) {
return renderItemWithVariations;
}
return null;
};
if ( error ) {
return <ErrorMessage error={ error } />;
}
render() {
const { products, loading, product, variationsList } = this.state;
const { onChange, renderItem, selected } = this.props;
const currentVariations = variationsList[ product ] || [];
const currentList = [ ...products, ...currentVariations ];
const messages = {
list: __( 'Products', 'woo-gutenberg-products-block' ),
noItems: __(
"Your store doesn't have any products.",
'woo-gutenberg-products-block'
),
search: __(
'Search for a product to display',
'woo-gutenberg-products-block'
),
updated: __(
'Product search results updated.',
'woo-gutenberg-products-block'
),
};
const selectedListItems = selected
? [ find( currentList, { id: selected } ) ]
const currentVariations =
variations && variations[ expandedProduct ]
? variations[ expandedProduct ]
: [];
const currentList = [ ...products, ...currentVariations ];
return (
<Fragment>
<SearchListControl
className="woocommerce-products"
list={ currentList }
isLoading={ loading }
isSingle
selected={ selectedListItems }
onChange={ onChange }
renderItem={ renderItem }
onSearch={
IS_LARGE_CATALOG ? this.debouncedOnSearch : null
}
messages={ messages }
isHierarchical
/>
</Fragment>
);
}
}
return (
<SearchListControl
className="woocommerce-products"
list={ currentList }
isLoading={ isLoading }
isSingle
selected={ currentList.filter( ( { id } ) =>
selected.includes( id )
) }
onChange={ onChange }
renderItem={ getRenderItemFunc() }
onSearch={ onSearch }
messages={ messages }
isHierarchical
/>
);
};
ProductControl.propTypes = {
/**
@ -305,17 +207,37 @@ ProductControl.propTypes = {
*/
onChange: PropTypes.func.isRequired,
/**
* Callback to render each item in the selection list, allows any custom object-type rendering.
* The ID of the currently expanded product.
*/
renderItem: PropTypes.func.isRequired,
expandedProduct: PropTypes.number,
/**
* The ID of the currently selected product.
* Callback to search products by their name.
*/
selected: PropTypes.number.isRequired,
onSearch: PropTypes.func,
/**
* Query args to pass to getProducts.
*/
queryArgs: PropTypes.object,
/**
* Callback to render each item in the selection list, allows any custom object-type rendering.
*/
renderItem: PropTypes.func,
/**
* The ID of the currently selected item (product or variation).
*/
selected: PropTypes.arrayOf( PropTypes.number ),
/**
* Whether to show variations in the list of items available.
*/
showVariations: PropTypes.bool,
};
export default ProductControl;
ProductControl.defaultProps = {
expandedProduct: null,
selected: [],
showVariations: false,
};
export default withTransformSingleSelectToMultipleSelect(
withSearchedProducts( withProductVariations( ProductControl ) )
);

View File

@ -68,7 +68,9 @@ const ProductsControl = ( {
className="woocommerce-products"
list={ products }
isLoading={ isLoading }
selected={ selected }
selected={ products.filter( ( { id } ) =>
selected.includes( id )
) }
onSearch={ onSearch }
onChange={ onChange }
messages={ messages }

View File

@ -54,7 +54,14 @@ export const getProducts = ( {
const requests = getProductsRequests( { selected, search, queryArgs } );
return Promise.all( requests.map( ( path ) => apiFetch( { path } ) ) )
.then( ( data ) => uniqBy( flatten( data ), 'id' ) )
.then( ( data ) => {
const products = uniqBy( flatten( data ), 'id' );
const list = products.map( ( product ) => ( {
...product,
parent: 0,
} ) );
return list;
} )
.catch( ( e ) => {
throw e;
} );
@ -130,6 +137,14 @@ export const getCategories = () => {
} );
};
export const getProductVariations = ( product ) => {
return apiFetch( {
path: addQueryArgs( `${ ENDPOINTS.products }/${ product }/variations`, {
per_page: -1,
} ),
} );
};
export const getAttributes = () => {
return apiFetch( {
path: addQueryArgs( `${ ENDPOINTS.products }/attributes`, {

View File

@ -2,4 +2,8 @@ export { default as withAttributes } from './with-attributes';
export { default as withCategories } from './with-categories';
export { default as withCategory } from './with-category';
export { default as withProduct } from './with-product';
export { default as withProductVariations } from './with-product-variations';
export { default as withSearchedProducts } from './with-searched-products';
export {
default as withTransformSingleSelectToMultipleSelect,
} from './with-transform-single-select-to-multiple-select';

View File

@ -0,0 +1,181 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withProductVariations from '../with-product-variations';
import * as mockUtils from '../../components/utils';
import * as mockBaseUtils from '../../base/utils/errors';
jest.mock( '../../components/utils', () => ( {
getProductVariations: jest.fn(),
} ) );
jest.mock( '../../base/utils/errors', () => ( {
formatError: jest.fn(),
} ) );
const mockProducts = [
{ id: 1, name: 'Hoodie', variations: [ 3, 4 ] },
{ id: 2, name: 'Backpack' },
];
const mockVariations = [ { id: 3, name: 'Blue' }, { id: 4, name: 'Red' } ];
const TestComponent = withProductVariations( ( props ) => {
return (
<div
error={ props.error }
expandedProduct={ props.expandedProduct }
isLoading={ props.isLoading }
variations={ props.variations }
variationsLoading={ props.variationsLoading }
/>
);
} );
const render = () => {
return TestRenderer.create(
<TestComponent
error={ null }
isLoading={ false }
products={ mockProducts }
selected={ [ 1 ] }
showVariations={ true }
/>
);
};
describe( 'withProductVariations Component', () => {
let renderer;
afterEach( () => {
mockUtils.getProductVariations.mockReset();
} );
describe( 'lifecycle events', () => {
beforeEach( () => {
mockUtils.getProductVariations.mockImplementation( () =>
Promise.resolve( mockVariations )
);
} );
it( 'getProductVariations is called on mount', () => {
renderer = render();
const { getProductVariations } = mockUtils;
expect( getProductVariations ).toHaveBeenCalledWith( 1 );
expect( getProductVariations ).toHaveBeenCalledTimes( 1 );
} );
it( 'getProductVariations is called on component update', () => {
renderer = TestRenderer.create(
<TestComponent
error={ null }
isLoading={ false }
products={ mockProducts }
/>
);
const { getProductVariations } = mockUtils;
expect( getProductVariations ).toHaveBeenCalledTimes( 0 );
renderer.update(
<TestComponent
error={ null }
isLoading={ false }
products={ mockProducts }
selected={ [ 1 ] }
showVariations={ true }
/>
);
expect( getProductVariations ).toHaveBeenCalledWith( 1 );
expect( getProductVariations ).toHaveBeenCalledTimes( 1 );
} );
it( 'getProductVariations is not called if selected product has no variations', () => {
TestRenderer.create(
<TestComponent
error={ null }
isLoading={ false }
products={ mockProducts }
selected={ [ 2 ] }
showVariations={ true }
/>
);
const { getProductVariations } = mockUtils;
expect( getProductVariations ).toHaveBeenCalledTimes( 0 );
} );
it( 'getProductVariations is called if selected product is a variation', () => {
TestRenderer.create(
<TestComponent
error={ null }
isLoading={ false }
products={ mockProducts }
selected={ [ 3 ] }
showVariations={ true }
/>
);
const { getProductVariations } = mockUtils;
expect( getProductVariations ).toHaveBeenCalledWith( 1 );
expect( getProductVariations ).toHaveBeenCalledTimes( 1 );
} );
} );
describe( 'when the API returns variations data', () => {
beforeEach( () => {
mockUtils.getProductVariations.mockImplementation( () =>
Promise.resolve( mockVariations )
);
renderer = render();
} );
it( 'sets the variations props', () => {
const props = renderer.root.findByType( 'div' ).props;
const expectedVariations = {
1: [
{ id: 3, name: 'Blue', parent: 1 },
{ id: 4, name: 'Red', parent: 1 },
],
};
expect( props.error ).toBeNull();
expect( props.isLoading ).toBe( false );
expect( props.variations ).toEqual( expectedVariations );
} );
} );
describe( 'when the API returns an error', () => {
const error = { message: 'There was an error.' };
const getProductVariationsPromise = Promise.reject( error );
const formattedError = { message: 'There was an error.', type: 'api' };
beforeEach( () => {
mockUtils.getProductVariations.mockImplementation(
() => getProductVariationsPromise
);
mockBaseUtils.formatError.mockImplementation(
() => formattedError
);
renderer = render();
} );
it( 'sets the error prop', ( done ) => {
const { formatError } = mockBaseUtils;
getProductVariationsPromise.catch( () => {
const props = renderer.root.findByType( 'div' ).props;
expect( formatError ).toHaveBeenCalledWith( error );
expect( formatError ).toHaveBeenCalledTimes( 1 );
expect( props.error ).toEqual( formattedError );
expect( props.isLoading ).toBe( false );
expect( props.variations ).toEqual( { 1: null } );
done();
} );
} );
} );
} );

View File

@ -18,7 +18,10 @@ jest.mock( '@woocommerce/block-settings', () => ( {
mockedUtils.getProducts = jest
.fn()
.mockImplementation( () =>
Promise.resolve( [ { id: 10, name: 'foo' }, { id: 20, name: 'bar' } ] )
Promise.resolve( [
{ id: 10, name: 'foo', parent: 0 },
{ id: 20, name: 'bar', parent: 0 },
] )
);
// Add a mock implementation of debounce for testing so we can spy on
@ -68,10 +71,10 @@ describe( 'withSearchedProducts Component', () => {
);
it( 'has expected values for props', () => {
props = renderer.root.findByType( 'div' ).props;
expect( props.selected ).toEqual( [ { id: 10, name: 'foo' } ] );
expect( props.selected ).toEqual( selected );
expect( props.products ).toEqual( [
{ id: 10, name: 'foo' },
{ id: 20, name: 'bar' },
{ id: 10, name: 'foo', parent: 0 },
{ id: 20, name: 'bar', parent: 0 },
] );
} );
it( 'debounce and getProducts is called on search event', () => {

View File

@ -0,0 +1,34 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withTransformSingleSelectToMultipleSelect from '../with-transform-single-select-to-multiple-select';
const TestComponent = withTransformSingleSelectToMultipleSelect( ( props ) => {
return <div selected={ props.selected } />;
} );
describe( 'withTransformSingleSelectToMultipleSelect Component', () => {
describe( 'when the API returns an error', () => {
it( 'converts the selected value into an array', () => {
const selected = 123;
const renderer = TestRenderer.create(
<TestComponent selected={ selected } />
);
const props = renderer.root.findByType( 'div' ).props;
expect( props.selected ).toEqual( [ selected ] );
} );
it( 'passes an empty array as the selected prop if selected was null', () => {
const renderer = TestRenderer.create(
<TestComponent selected={ null } />
);
const props = renderer.root.findByType( 'div' ).props;
expect( props.selected ).toEqual( [] );
} );
} );
} );

View File

@ -0,0 +1,188 @@
/**
* External dependencies
*/
import { Component } from '@wordpress/element';
import { createHigherOrderComponent } from '@wordpress/compose';
import PropTypes from 'prop-types';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* Internal dependencies
*/
import { getProductVariations } from '../components/utils';
import { formatError } from '../base/utils/errors.js';
const withProductVariations = createHigherOrderComponent(
( OriginalComponent ) => {
class WrappedComponent extends Component {
state = {
error: null,
loading: false,
variations: {},
};
componentDidMount() {
const { selected, showVariations } = this.props;
if ( selected && showVariations ) {
this.loadVariations();
}
}
componentDidUpdate( prevProps ) {
const { isLoading, selected, showVariations } = this.props;
if (
showVariations &&
( ! isShallowEqual( prevProps.selected, selected ) ||
( prevProps.isLoading && ! isLoading ) )
) {
this.loadVariations();
}
}
loadVariations = () => {
const { products } = this.props;
const { loading, variations } = this.state;
if ( loading ) {
return;
}
const expandedProduct = this.getExpandedProduct();
if ( ! expandedProduct || variations[ expandedProduct ] ) {
return;
}
const productDetails = products.find(
( findProduct ) => findProduct.id === expandedProduct
);
if (
! productDetails.variations ||
productDetails.variations.length === 0
) {
this.setState( {
variations: {
...this.state.variations,
[ expandedProduct ]: null,
},
loading: false,
error: null,
} );
return;
}
this.setState( { loading: true } );
getProductVariations( expandedProduct )
.then( ( expandedProductVariations ) => {
const newVariations = expandedProductVariations.map(
( variation ) => ( {
...variation,
parent: expandedProduct,
} )
);
this.setState( {
variations: {
...this.state.variations,
[ expandedProduct ]: newVariations,
},
loading: false,
error: null,
} );
} )
.catch( async ( e ) => {
const error = await formatError( e );
this.setState( {
variations: {
...this.state.variations,
[ expandedProduct ]: null,
},
loading: false,
error,
} );
} );
};
isProductId( itemId ) {
const { products } = this.props;
return products.some( ( p ) => p.id === itemId );
}
findParentProduct( variationId ) {
const { products } = this.props;
const parentProduct = products.filter(
( p ) =>
p.variations && p.variations.includes( variationId )
);
return parentProduct[ 0 ].id;
}
getExpandedProduct() {
const { isLoading, selected, showVariations } = this.props;
if ( ! showVariations ) {
return null;
}
let selectedItem =
selected && selected.length ? selected[ 0 ] : null;
// If there is no selected item, check if there was one in the past, so we
// can keep the same product expanded.
if ( selectedItem ) {
this.prevSelectedItem = selectedItem;
} else if ( this.prevSelectedItem ) {
// If previous selected item was a variation
if (
! isLoading &&
! this.isProductId( this.prevSelectedItem )
) {
selectedItem = this.prevSelectedItem;
}
}
if ( ! isLoading && selectedItem ) {
return this.isProductId( selectedItem )
? selectedItem
: this.findParentProduct( selectedItem );
}
return null;
}
render() {
const { error: propsError, isLoading } = this.props;
const { error, loading, variations } = this.state;
return (
<OriginalComponent
{ ...this.props }
error={ error || propsError }
expandedProduct={ this.getExpandedProduct() }
isLoading={ isLoading }
variations={ variations }
variationsLoading={ loading }
/>
);
}
static propTypes = {
selected: PropTypes.array,
showVariations: PropTypes.bool,
};
static defaultProps = {
selected: [],
showVariations: false,
};
}
return WrappedComponent;
},
'withProductVariations'
);
export default withProductVariations;

View File

@ -69,16 +69,13 @@ const withSearchedProducts = createHigherOrderComponent(
render() {
const { error, list, loading } = this.state;
const { selected } = this.props;
return (
<OriginalComponent
{ ...this.props }
error={ error }
products={ list }
isLoading={ loading }
selected={ list.filter( ( { id } ) =>
selected.includes( id )
) }
onSearch={
IS_LARGE_CATALOG ? this.debouncedOnSearch : null
}

View File

@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { Component } from '@wordpress/element';
import { createHigherOrderComponent } from '@wordpress/compose';
import PropTypes from 'prop-types';
import { isNil } from 'lodash';
const withTransformSingleSelectToMultipleSelect = createHigherOrderComponent(
( OriginalComponent ) => {
class WrappedComponent extends Component {
render() {
const { selected } = this.props;
return (
<OriginalComponent
{ ...this.props }
selected={ isNil( selected ) ? [] : [ selected ] }
/>
);
}
}
WrappedComponent.propTypes = {
selected: PropTypes.oneOfType( [
PropTypes.number,
PropTypes.string,
] ),
};
WrappedComponent.defaultProps = {
selected: null,
};
return WrappedComponent;
},
'withTransformSingleSelectToMultipleSelect'
);
export default withTransformSingleSelectToMultipleSelect;