Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Commit

Permalink
Fix handpicked product selections when a store has over 100 products. (
Browse files Browse the repository at this point in the history
…#4534)

* Convert withSearchedProducts to typescript

* isLargeCatalog query needs no limit

* Pass tests
  • Loading branch information
mikejolley committed Aug 6, 2021
1 parent 26108fc commit 5f34c86
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 149 deletions.
2 changes: 1 addition & 1 deletion assets/js/base/utils/errors.js
Expand Up @@ -10,7 +10,7 @@ import { __ } from '@wordpress/i18n';
* @param {Function} [error.json] If a json method is specified, it will try parsing the error first.
* @param {string} [error.message] If a message is specified, it will be shown to the user.
* @param {string} [error.type] The context in which the error was triggered.
* @return {Object} Error object containing a message and type.
* @return {Promise<{message:string;type:string;}>} Error object containing a message and type.
*/
export const formatError = async ( error ) => {
if ( typeof error.json === 'function' ) {
Expand Down
19 changes: 11 additions & 8 deletions assets/js/editor-components/utils/index.js
Expand Up @@ -12,14 +12,14 @@ import { blocksConfig } from '@woocommerce/block-settings';
* Get product query requests for the Store API.
*
* @param {Object} request A query object with the list of selected products and search term.
* @param {Array} request.selected Currently selected products.
* @param {string} request.search Search string.
* @param {Array} request.queryArgs Query args to pass in.
* @param {number[]} request.selected Currently selected products.
* @param {string=} request.search Search string.
* @param {(Record<string, unknown>)=} request.queryArgs Query args to pass in.
*/
const getProductsRequests = ( {
selected = [],
search = '',
queryArgs = [],
queryArgs = {},
} ) => {
const isLargeCatalog = blocksConfig.productCount > 100;
const defaultArgs = {
Expand All @@ -39,6 +39,7 @@ const getProductsRequests = ( {
addQueryArgs( '/wc/store/products', {
catalog_visibility: 'any',
include: selected,
per_page: 0,
} )
);
}
Expand All @@ -50,14 +51,16 @@ const getProductsRequests = ( {
* Get a promise that resolves to a list of products from the Store API.
*
* @param {Object} request A query object with the list of selected products and search term.
* @param {Array} request.selected Currently selected products.
* @param {string} request.search Search string.
* @param {Array} request.queryArgs Query args to pass in.
* @param {number[]} request.selected Currently selected products.
* @param {string=} request.search Search string.
* @param {(Record<string, unknown>)=} request.queryArgs Query args to pass in.
* @return {Promise<unknown>} Promise resolving to a Product list.
* @throws Exception if there is an error.
*/
export const getProducts = ( {
selected = [],
search = '',
queryArgs = [],
queryArgs = {},
} ) => {
const requests = getProductsRequests( { selected, search, queryArgs } );

Expand Down
60 changes: 25 additions & 35 deletions assets/js/hocs/test/with-searched-products.js
@@ -1,9 +1,9 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
import _ from 'lodash';
import TestRenderer, { act } from 'react-test-renderer';
import * as mockUtils from '@woocommerce/editor-components/utils';
import * as mockUseDebounce from 'use-debounce';

/**
* Internal dependencies
Expand All @@ -25,23 +25,17 @@ mockUtils.getProducts = jest.fn().mockImplementation( () =>
] )
);

// 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;
};
// Add a mock implementation of debounce for testing so we can spy on the onSearch call.
mockUseDebounce.useDebouncedCallback = jest
.fn()
.mockImplementation( ( search ) => [
() => mockUtils.getProducts( search ),
] );

describe( 'withSearchedProducts Component', () => {
const { getProducts } = mockUtils;
afterEach( () => {
debouncedCancel.mockClear();
debouncedAction.mockClear();
mockUseDebounce.useDebouncedCallback.mockClear();
mockUtils.getProducts.mockClear();
} );
const TestComponent = withSearchedProducts(
Expand All @@ -58,18 +52,14 @@ describe( 'withSearchedProducts Component', () => {
);
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 );
}
);
let props, renderer;

act( () => {
renderer = TestRenderer.create(
<TestComponent selected={ selected } />
);
} );

it( 'has expected values for props', () => {
props = renderer.root.findByType( 'div' ).props;
expect( props.selected ).toEqual( selected );
Expand All @@ -78,16 +68,16 @@ describe( 'withSearchedProducts Component', () => {
{ id: 20, name: 'bar', parent: 0 },
] );
} );
it( 'debounce and getProducts is called on search event', () => {

it( 'debounce and getProducts is called on search event', async () => {
props = renderer.root.findByType( 'div' ).props;
props.onSearch();
expect( debouncedAction ).toHaveBeenCalled();

act( () => {
props.onSearch();
} );

expect( mockUseDebounce.useDebouncedCallback ).toHaveBeenCalled();
expect( getProducts ).toHaveBeenCalledTimes( 1 );
} );
it( 'debounce is cancelled on unmount', () => {
renderer.unmount();
expect( debouncedCancel ).toHaveBeenCalled();
expect( getProducts ).toHaveBeenCalledTimes( 0 );
} );
} );
} );
105 changes: 0 additions & 105 deletions assets/js/hocs/with-searched-products.js

This file was deleted.

86 changes: 86 additions & 0 deletions assets/js/hocs/with-searched-products.tsx
@@ -0,0 +1,86 @@
/**
* External dependencies
*/
import { useEffect, useState, useCallback } from '@wordpress/element';
import { blocksConfig } from '@woocommerce/block-settings';
import { getProducts } from '@woocommerce/editor-components/utils';
import { useDebouncedCallback } from 'use-debounce';
import type { ProductResponseItem } from '@woocommerce/types';

/**
* Internal dependencies
*/
import { formatError } from '../base/utils/errors.js';

/**
* A higher order component that enhances the provided component with products from a search query.
*/
const withSearchedProducts = (
OriginalComponent: React.FunctionComponent< Record< string, unknown > >
) => {
return ( { selected, ...props }: { selected: number[] } ): JSX.Element => {
const [ isLoading, setIsLoading ] = useState( true );
const [ error, setError ] = useState< {
message: string;
type: string;
} | null >( null );
const [ productsList, setProductsList ] = useState<
ProductResponseItem[]
>( [] );
const isLargeCatalog = blocksConfig.productCount > 100;

const setErrorState = async ( e: {
message: string;
type: string;
} ) => {
const formattedError = ( await formatError( e ) ) as {
message: string;
type: string;
};
setError( formattedError );
setIsLoading( false );
};

useEffect( () => {
getProducts( { selected } )
.then( ( results ) => {
setProductsList( results as ProductResponseItem[] );
setIsLoading( false );
} )
.catch( setErrorState );
}, [ selected ] );

const [ debouncedSearch ] = useDebouncedCallback(
( search: string ) => {
getProducts( { selected, search } )
.then( ( results ) => {
setProductsList( results as ProductResponseItem[] );
setIsLoading( false );
} )
.catch( setErrorState );
},
400
);

const onSearch = useCallback(
( search: string ) => {
setIsLoading( true );
debouncedSearch( search );
},
[ setIsLoading, debouncedSearch ]
);

return (
<OriginalComponent
{ ...props }
selected={ selected }
error={ error }
products={ productsList }
isLoading={ isLoading }
onSearch={ isLargeCatalog ? onSearch : null }
/>
);
};
};

export default withSearchedProducts;

0 comments on commit 5f34c86

Please sign in to comment.