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

Create withProduct HOC #779

Merged
merged 19 commits into from
Aug 2, 2019
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions assets/js/blocks/featured-product/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
getImageSrcFromProduct,
getImageIdFromProduct,
} from '../../utils/products';
import withProduct from '../../utils/with-product';
import withProduct from '../../hocs/with-product';
Aljullu marked this conversation as resolved.
Show resolved Hide resolved

/**
* The min-height for the block content.
Expand Down Expand Up @@ -77,7 +77,7 @@ const FeaturedProduct = ( { attributes, debouncedSpeak, error, getProduct, isLoa

return (
<Fragment>
{ this.getBlockControls() }
{ getBlockControls() }
<Placeholder
icon="star-filled"
label={ __( 'Featured Product', 'woo-gutenberg-products-block' ) }
Expand Down
21 changes: 17 additions & 4 deletions assets/js/components/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ export const isLargeCatalog = wc_product_block_data.isLargeCatalog || false;
export const limitTags = wc_product_block_data.limitTags || false;
export const hasTags = wc_product_block_data.hasTags || false;

const NAMESPACE = '/wc/blocks/products';

const getProductsRequests = ( { selected = [], search } ) => {
const requests = [
addQueryArgs( '/wc/blocks/products', {
addQueryArgs( NAMESPACE, {
per_page: isLargeCatalog ? 100 : -1,
catalog_visibility: 'visible',
status: 'publish',
Expand All @@ -22,7 +24,7 @@ const getProductsRequests = ( { selected = [], search } ) => {
// If we have a large catalog, we might not get all selected products in the first page.
if ( isLargeCatalog && selected.length ) {
requests.push(
addQueryArgs( '/wc/blocks/products', {
addQueryArgs( NAMESPACE, {
Aljullu marked this conversation as resolved.
Show resolved Hide resolved
catalog_visibility: 'visible',
status: 'publish',
include: selected,
Expand All @@ -46,9 +48,20 @@ export const getProducts = ( { selected = [], search } ) => {
} );
};

/**
* Get a promise that resolves to a product object from the API.
*
* @param {object} - Id of the product to retrieve.
*/
export const getProduct = ( productId ) => {
return apiFetch( {
path: `${ NAMESPACE }/${ productId }`,
} );
};

const getProductTagsRequests = ( { selected = [], search } ) => {
const requests = [
addQueryArgs( '/wc/blocks/products/tags', {
addQueryArgs( `${ NAMESPACE }/tags`, {
per_page: limitTags ? 100 : -1,
orderby: limitTags ? 'count' : 'name',
order: limitTags ? 'desc' : 'asc',
Expand All @@ -59,7 +72,7 @@ const getProductTagsRequests = ( { selected = [], search } ) => {
// If we have a large catalog, we might not get all selected products in the first page.
if ( limitTags && selected.length ) {
requests.push(
addQueryArgs( '/wc/blocks/products/tags', {
addQueryArgs( `${ NAMESPACE }/tags`, {
include: selected,
} )
);
Expand Down
95 changes: 95 additions & 0 deletions assets/js/hocs/test/with-product.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';

/**
* Internal dependencies
*/
import withProduct from '../with-product';
import * as mockUtils from '../../components/utils';

// Mock the getProduct functions for tests.
jest.mock( '../../components/utils', () => ( {
getProduct: jest.fn().mockImplementation(
() => Promise.resolve()
),
} ) );

const mockProduct = { name: 'T-Shirt' };
const attributes = { productId: 1 };
const TestComponent = withProduct( ( props ) => {
return <div
error={ props.error }
getProduct={ props.getProduct }
isLoading={ props.isLoading }
product={ props.product }
/>;
} );
const render = () => {
return TestRenderer.create(
<TestComponent
attributes={ attributes }
/>
);
};

describe( 'withProduct Component', () => {
afterEach( () => {
mockUtils.getProduct.mockClear();
} );

describe( 'lifecycle events', () => {
const renderer = render();

it( 'getProduct is called on mount with passed in product id', () => {
const { getProduct } = mockUtils;

expect( getProduct ).toHaveBeenCalledWith( attributes.productId );
expect( getProduct ).toHaveBeenCalledTimes( 1 );
} );

it( 'getProduct is hooked to the prop', () => {
const { getProduct } = mockUtils;
const props = renderer.root.findByType( 'div' ).props;

props.getProduct();

expect( getProduct ).toHaveBeenCalledTimes( 1 );
} );
} );

describe( 'when the API returns product data', () => {
mockUtils.getProduct.mockImplementation(
( productId ) => Promise.resolve( { ...mockProduct, id: productId } )
);
const renderer = render();

it( 'sets the product props', () => {
const props = renderer.root.findByType( 'div' ).props;

expect( props.error ).toEqual( null );
Aljullu marked this conversation as resolved.
Show resolved Hide resolved
expect( typeof props.getProduct ).toEqual( 'function' );
expect( props.isLoading ).toEqual( false );
expect( props.product ).toEqual( { ...mockProduct, id: attributes.productId } );
} );

mockUtils.getProduct.mockReset();
Aljullu marked this conversation as resolved.
Show resolved Hide resolved
} );

describe( 'when the API returns an error', () => {
mockUtils.getProduct.mockImplementation(
() => Promise.reject( { message: 'There was an error.' } )
);
const renderer = render();

it( 'sets the error prop', () => {
const props = renderer.root.findByType( 'div' ).props;

expect( props.error ).toEqual( { apiMessage: 'There was an error.' } );
expect( typeof props.getProduct ).toEqual( 'function' );
expect( props.isLoading ).toEqual( false );
expect( props.product ).toEqual( null );
} );
} );
} );
50 changes: 25 additions & 25 deletions assets/js/utils/with-product.js → assets/js/hocs/with-product.js
Original file line number Diff line number Diff line change
@@ -1,60 +1,60 @@
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
import { Component } from '@wordpress/element';
import { createHigherOrderComponent } from '@wordpress/compose';

/**
* Internal dependencies
*/
import { getProduct } from '../components/utils';

const withProduct = createHigherOrderComponent(
( OriginalComponent ) => {
return class WrappedComponent extends Component {
constructor() {
super( ...arguments );
this.state = {
error: false,
error: null,
loading: false,
product: false,
product: null,
};
this.getProduct = this.getProduct.bind( this );
this.loadProduct = this.loadProduct.bind( this );
}

componentDidMount() {
this.getProduct();
this.loadProduct();
}

componentDidUpdate( prevProps ) {
if ( prevProps.attributes.productId !== this.props.attributes.productId ) {
this.getProduct();
this.loadProduct();
}
}

getProduct() {
loadProduct() {
const { productId } = this.props.attributes;

if ( ! productId ) {
this.setState( { product: false, loading: false, error: false } );
this.setState( { product: null, loading: false, error: null } );
return;
}

this.setState( { loading: true } );

apiFetch( {
path: `/wc/blocks/products/${ productId }`,
} )
.then( ( product ) => {
this.setState( { product, loading: false, error: false } );
} )
.catch( ( apiError ) => {
const error = typeof apiError === 'object' && apiError.hasOwnProperty( 'message' ) ? {
apiMessage: apiError.message,
} : {
// If we can't get any message from the API, set it to null and
// let <ApiErrorPlaceholder /> handle the message to display.
apiMessage: null,
};
getProduct( productId ).then( ( product ) => {
Aljullu marked this conversation as resolved.
Show resolved Hide resolved
this.setState( { product, loading: false, error: null } );
} ).catch( ( apiError ) => {
const error = typeof apiError === 'object' && apiError.hasOwnProperty( 'message' ) ? {
apiMessage: apiError.message,
} : {
// If we can't get any message from the API, set it to null and
// let <ApiErrorPlaceholder /> handle the message to display.
apiMessage: null,
};

this.setState( { product: false, loading: false, error } );
} );
this.setState( { product: null, loading: false, error } );
} );
}

render() {
Expand All @@ -63,7 +63,7 @@ const withProduct = createHigherOrderComponent(
return <OriginalComponent
{ ...this.props }
error={ error }
getProduct={ this.getProduct }
getProduct={ this.loadProduct }
isLoading={ loading }
product={ product }
/>;
Expand Down
24 changes: 14 additions & 10 deletions assets/js/utils/products.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
/**
* Get the src of the first image attached to a product (the featured image).
*
* @param {array} images The array of images, destructured from the product object.
* @param {object} product The product object to get the images from.
* @param {array} product.images The array of images, destructured from the product object.
* @return {string} The full URL to the image.
*/
export function getImageSrcFromProduct( { images = [] } ) {
if ( images.length ) {
return images[ 0 ].src || '';
export function getImageSrcFromProduct( product ) {
if ( ! product || ! product.images || ! product.images.length ) {
Aljullu marked this conversation as resolved.
Show resolved Hide resolved
return 0;
}
return '';

return product.images[ 0 ].src || '';
}

/**
* Get the ID of the first image attached to a product (the featured image).
*
* @param {array} images The array of images, destructured from the product object.
* @param {object} product The product object to get the images from.
* @param {array} product.images The array of images, destructured from the product object.
* @return {number} The ID of the image.
*/
export function getImageIdFromProduct( { images = [] } ) {
if ( images.length ) {
return images[ 0 ].id || 0;
export function getImageIdFromProduct( product ) {
if ( ! product || ! product.images || ! product.images.length ) {
return 0;
}
return 0;

return product.images[ 0 ].id || 0;
}