* Add withCategories HOC and show API errors in Product Category Control

* Create withAttributes HOC and display errors in Product Attribute Control

* Refactor formatError so it handles JS errors and API errors

* Rename 'onSelectAttribute' with 'onExpandAttribute'

* Add and update tests

* Fix error in product attributes endpoint

* Refactor ProductCategoryControl into a functional component

* Refactor ProductAttributeControl into a functional component

* Refactor formatError to use a 'message' and 'type' properties

* Replace enzyme with TestRenderer

* Fix formatErrors doctype format

* Rename 'frontend' error type to 'general'
This commit is contained in:
Albert Juhé Lluveras 2019-09-04 18:07:00 +02:00 committed by GitHub
parent 4489b50774
commit dc232e87d6
20 changed files with 956 additions and 332 deletions

View File

@ -8,6 +8,7 @@ import TestRenderer from 'react-test-renderer';
*/
import withReviews from '../with-reviews';
import * as mockUtils from '../../../blocks/reviews/utils';
import * as mockBaseUtils from '../../utils/errors';
jest.mock( '../../../blocks/reviews/utils', () => ( {
getOrderArgs: () => ( {
@ -17,6 +18,10 @@ jest.mock( '../../../blocks/reviews/utils', () => ( {
getReviews: jest.fn(),
} ) );
jest.mock( '../../utils/errors', () => ( {
formatError: jest.fn(),
} ) );
const mockReviews = [
{ reviewer: 'Alice', review: 'Lorem ipsum', rating: 2 },
{ reviewer: 'Bob', review: 'Dolor sit amet', rating: 3 },
@ -109,21 +114,33 @@ describe( 'withReviews Component', () => {
} );
describe( 'when the API returns an error', () => {
const error = { message: 'There was an error.' };
const getReviewsPromise = Promise.reject( error );
const formattedError = { message: 'There was an error.', type: 'api' };
beforeEach( () => {
mockUtils.getReviews.mockImplementation(
() => Promise.reject( {
json: () => Promise.resolve( { message: 'There was an error.' } ),
} )
() => getReviewsPromise,
);
mockBaseUtils.formatError.mockImplementation(
() => formattedError,
);
renderer = render();
} );
it( 'sets the error prop', () => {
const props = renderer.root.findByType( 'div' ).props;
it( 'sets the error prop', ( done ) => {
const { formatError } = mockBaseUtils;
getReviewsPromise.catch( () => {
const props = renderer.root.findByType( 'div' ).props;
expect( props.error ).toEqual( { apiMessage: 'There was an error.' } );
expect( props.isLoading ).toBe( false );
expect( props.reviews ).toEqual( [] );
expect( formatError ).toHaveBeenCalledWith( error );
expect( formatError ).toHaveBeenCalledTimes( 1 );
expect( props.error ).toEqual( formattedError );
expect( props.isLoading ).toBe( false );
expect( props.reviews ).toEqual( [] );
done();
} );
} );
} );
} );

View File

@ -122,15 +122,13 @@ const withReviews = ( OriginalComponent ) => {
.catch( this.setError );
}
setError( errorResponse ) {
errorResponse.json().then( ( apiError ) => {
const { onReviewsLoadError } = this.props;
const error = formatError( apiError );
async setError( e ) {
const { onReviewsLoadError } = this.props;
const error = await formatError( e );
this.setState( { reviews: [], loading: false, error } );
this.setState( { reviews: [], loading: false, error } );
onReviewsLoadError();
} );
onReviewsLoadError( error );
}
render() {

View File

@ -1,9 +1,30 @@
export const formatError = ( apiError ) => {
return 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,
/**
* Given a JS error or a fetch response error, parse and format it so it can be displayed to the user.
*
* @param {Object} error Error object.
* @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.
*/
export const formatError = async ( error ) => {
if ( typeof error.json === 'function' ) {
try {
const parsedError = await error.json();
return {
message: parsedError.message,
type: parsedError.type || 'api',
};
} catch ( e ) {
return {
message: e.message,
type: 'general',
};
}
}
return {
message: error.message,
type: error.type || 'general',
};
};

View File

@ -0,0 +1,42 @@
/**
* Internal dependencies
*/
import { formatError } from '../errors';
describe( 'formatError', () => {
test( 'should format general errors', async () => {
const error = await formatError( {
message: 'Lorem Ipsum',
} );
const expectedError = {
message: 'Lorem Ipsum',
type: 'general',
};
expect( error ).toEqual( expectedError );
} );
test( 'should format API errors', async () => {
const error = await formatError( {
json: () => Promise.resolve( { message: 'Lorem Ipsum' } ),
} );
const expectedError = {
message: 'Lorem Ipsum',
type: 'api',
};
expect( error ).toEqual( expectedError );
} );
test( 'should format JSON parse errors', async () => {
const error = await formatError( {
json: () => Promise.reject( { message: 'Lorem Ipsum' } ),
} );
const expectedError = {
message: 'Lorem Ipsum',
type: 'general',
};
expect( error ).toEqual( expectedError );
} );
} );

View File

@ -5,22 +5,32 @@ import { __ } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import { escapeHTML } from '@wordpress/escape-html';
const getErrorMessage = ( { apiMessage, message } ) => {
if ( message ) {
return message;
const getErrorMessage = ( { message, type } ) => {
if ( ! message ) {
return __( 'An unknown error occurred which prevented the block from being updated.', 'woo-gutenberg-products-block' );
}
if ( apiMessage ) {
if ( type === 'general' ) {
return (
<span>
{ __( 'The following error was returned from the API', 'woo-gutenberg-products-block' ) }
{ __( 'The following error was returned', 'woo-gutenberg-products-block' ) }
<br />
<code>{ escapeHTML( apiMessage ) }</code>
<code>{ escapeHTML( message ) }</code>
</span>
);
}
return __( 'An unknown error occurred which prevented the block from being updated.', 'woo-gutenberg-products-block' );
if ( type === 'api' ) {
return (
<span>
{ __( 'The following error was returned from the API', 'woo-gutenberg-products-block' ) }
<br />
<code>{ escapeHTML( message ) }</code>
</span>
);
}
return message;
};
const ErrorMessage = ( { error } ) => (
@ -34,14 +44,14 @@ ErrorMessage.propTypes = {
* The error object.
*/
error: PropTypes.shape( {
/**
* API error message to display in case of a missing `message`.
*/
apiMessage: PropTypes.node,
/**
* Human-readable error message to display.
*/
message: PropTypes.string,
message: PropTypes.node,
/**
* Context in which the error was triggered. That will determine how the error is displayed to the user.
*/
type: PropTypes.oneOf( [ 'api', 'general' ] ),
} ),
};

View File

@ -48,14 +48,14 @@ ApiErrorPlaceholder.propTypes = {
* The error object.
*/
error: PropTypes.shape( {
/**
* API error message to display in case of a missing `message`.
*/
apiMessage: PropTypes.node,
/**
* Human-readable error message to display.
*/
message: PropTypes.string,
message: PropTypes.node,
/**
* Context in which the error was triggered. That will determine how the error is displayed to the user.
*/
type: PropTypes.oneOf( [ 'api', 'general' ] ),
} ),
/**
* Whether there is a request running, so the 'Retry' button is hidden and

View File

@ -2,104 +2,29 @@
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { Component, Fragment } from '@wordpress/element';
import { debounce, find } from 'lodash';
import { Fragment } from '@wordpress/element';
import { find } from 'lodash';
import PropTypes from 'prop-types';
import { SearchListControl, SearchListItem } from '@woocommerce/components';
import { SelectControl, Spinner } from '@wordpress/components';
import { ENDPOINTS } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import { withAttributes } from '../../hocs';
import ErrorMessage from '../api-error-placeholder/error-message.js';
import './style.scss';
class ProductAttributeControl extends Component {
constructor() {
super( ...arguments );
this.state = {
list: [],
loading: true,
attribute: 0,
termsList: {},
termsLoading: true,
};
this.debouncedGetTerms = debounce( this.getTerms.bind( this ), 200 );
this.renderItem = this.renderItem.bind( this );
this.onSelectAttribute = this.onSelectAttribute.bind( this );
}
componentDidMount() {
const { selected } = this.props;
apiFetch( {
path: addQueryArgs( `${ ENDPOINTS.products }/attributes`, { per_page: -1 } ),
} )
.then( ( list ) => {
list = list.map( ( item ) => ( { ...item, parent: 0 } ) );
this.setState( ( { attribute } ) => {
if ( ! attribute && selected.length > 0 ) {
const item = find( list, { slug: selected[ 0 ].attr_slug } );
attribute = item ? item.id : 0;
}
return { list, attribute, loading: false };
} );
} )
.catch( () => {
this.setState( { list: [], loading: false } );
} );
}
componentWillUnmount() {
this.debouncedGetTerms.cancel();
}
componentDidUpdate( prevProps, prevState ) {
if ( prevState.attribute !== this.state.attribute ) {
this.debouncedGetTerms();
}
}
getTerms() {
const { attribute, termsList } = this.state;
if ( ! attribute ) {
return;
}
if ( ! termsList[ attribute ] ) {
this.setState( { termsLoading: true } );
}
apiFetch( {
path: addQueryArgs( `${ ENDPOINTS.products }/attributes/${ attribute }/terms`, {
per_page: -1,
} ),
} )
.then( ( terms ) => {
terms = terms.map( ( term ) => ( { ...term, parent: attribute, attr_slug: term.attribute.slug } ) );
this.setState( ( prevState ) => ( {
termsList: { ...prevState.termsList, [ attribute ]: terms },
termsLoading: false,
} ) );
} )
.catch( () => {
this.setState( { termsLoading: false } );
} );
}
onSelectAttribute( item ) {
const ProductAttributeControl = ( { attributes, error, expandedAttribute, onChange, onOperatorChange, isLoading, operator, selected, termsAreLoading, termsList } ) => {
const onExpandAttribute = ( item ) => {
return () => {
this.props.onChange( [] );
this.setState( {
attribute: item.id === this.state.attribute ? 0 : item.id,
} );
onChange( [] );
onExpandAttribute( item.id );
};
}
};
renderItem( args ) {
const renderItem = ( args ) => {
const { item, search, depth = 0 } = args;
const { attribute, termsLoading } = this.state;
const classes = [
'woocommerce-product-attributes__item',
'woocommerce-search-list__item',
@ -107,7 +32,7 @@ class ProductAttributeControl extends Component {
if ( search.length ) {
classes.push( 'is-searching' );
}
if ( depth === 0 && item.parent !== 0 ) {
if ( depth === 0 && item.parent ) {
classes.push( 'is-skip-level' );
}
@ -117,11 +42,11 @@ class ProductAttributeControl extends Component {
key={ `attr-${ item.id }` }
{ ...args }
className={ classes.join( ' ' ) }
isSelected={ attribute === item.id }
onSelect={ this.onSelectAttribute }
isSelected={ expandedAttribute === item.id }
onSelect={ onExpandAttribute }
isSingle
disabled={ '0' === item.count }
aria-expanded={ attribute === item.id }
aria-expanded={ expandedAttribute === item.id }
aria-label={ sprintf(
_n(
'%s, has %d term',
@ -133,7 +58,7 @@ class ProductAttributeControl extends Component {
item.count
) }
/>,
attribute === item.id && termsLoading && (
expandedAttribute === item.id && termsAreLoading && (
<div
key="loading"
className={
@ -155,92 +80,94 @@ class ProductAttributeControl extends Component {
aria-label={ `${ item.breadcrumbs[ 0 ] }: ${ item.name }` }
/>
);
}
};
render() {
const { attribute, list, loading, termsList } = this.state;
const { onChange, onOperatorChange, operator, selected } = this.props;
const currentTerms = termsList[ attribute ] || [];
const currentList = [ ...list, ...currentTerms ];
const currentTerms = termsList[ expandedAttribute ] || [];
const currentList = [ ...attributes, ...currentTerms ];
const messages = {
clear: __( 'Clear all product attributes', 'woo-gutenberg-products-block' ),
list: __( 'Product Attributes', 'woo-gutenberg-products-block' ),
noItems: __(
"Your store doesn't have any product attributes.",
'woo-gutenberg-products-block'
),
search: __(
'Search for product attributes',
'woo-gutenberg-products-block'
),
selected: ( n ) =>
sprintf(
_n(
'%d attribute selected',
'%d attributes selected',
n,
'woo-gutenberg-products-block'
),
n
const messages = {
clear: __( 'Clear all product attributes', 'woo-gutenberg-products-block' ),
list: __( 'Product Attributes', 'woo-gutenberg-products-block' ),
noItems: __(
"Your store doesn't have any product attributes.",
'woo-gutenberg-products-block'
),
search: __(
'Search for product attributes',
'woo-gutenberg-products-block'
),
selected: ( n ) =>
sprintf(
_n(
'%d attribute selected',
'%d attributes selected',
n,
'woo-gutenberg-products-block'
),
updated: __(
'Product attribute search results updated.',
'woo-gutenberg-products-block'
n
),
};
updated: __(
'Product attribute search results updated.',
'woo-gutenberg-products-block'
),
};
if ( error ) {
return (
<Fragment>
<SearchListControl
className="woocommerce-product-attributes"
list={ currentList }
isLoading={ loading }
selected={ selected
.map( ( { id } ) => find( currentList, { id } ) )
.filter( Boolean ) }
onChange={ onChange }
renderItem={ this.renderItem }
messages={ messages }
isHierarchical
/>
{ !! onOperatorChange && (
<div className={ selected.length < 2 ? 'screen-reader-text' : '' }>
<SelectControl
className="woocommerce-product-attributes__operator"
label={ __(
'Display products matching',
'woo-gutenberg-products-block'
) }
help={ __(
'Pick at least two attributes to use this setting.',
'woo-gutenberg-products-block'
) }
value={ operator }
onChange={ onOperatorChange }
options={ [
{
label: __(
'Any selected attributes',
'woo-gutenberg-products-block'
),
value: 'any',
},
{
label: __(
'All selected attributes',
'woo-gutenberg-products-block'
),
value: 'all',
},
] }
/>
</div>
) }
</Fragment>
<ErrorMessage error={ error } />
);
}
}
return (
<Fragment>
<SearchListControl
className="woocommerce-product-attributes"
list={ currentList }
isLoading={ isLoading }
selected={ selected
.map( ( { id } ) => find( currentList, { id } ) )
.filter( Boolean ) }
onChange={ onChange }
renderItem={ renderItem }
messages={ messages }
isHierarchical
/>
{ !! onOperatorChange && (
<div className={ selected.length < 2 ? 'screen-reader-text' : '' }>
<SelectControl
className="woocommerce-product-attributes__operator"
label={ __(
'Display products matching',
'woo-gutenberg-products-block'
) }
help={ __(
'Pick at least two attributes to use this setting.',
'woo-gutenberg-products-block'
) }
value={ operator }
onChange={ onOperatorChange }
options={ [
{
label: __(
'Any selected attributes',
'woo-gutenberg-products-block'
),
value: 'any',
},
{
label: __(
'All selected attributes',
'woo-gutenberg-products-block'
),
value: 'all',
},
] }
/>
</div>
) }
</Fragment>
);
};
ProductAttributeControl.propTypes = {
/**
@ -259,10 +186,18 @@ ProductAttributeControl.propTypes = {
* The list of currently selected attribute slug/ID pairs.
*/
selected: PropTypes.array.isRequired,
// from withAttributes
attributes: PropTypes.array,
error: PropTypes.object,
expandedAttribute: PropTypes.number,
onExpandAttribute: PropTypes.func,
isLoading: PropTypes.bool,
termsAreLoading: PropTypes.bool,
termsList: PropTypes.object,
};
ProductAttributeControl.defaultProps = {
operator: 'any',
};
export default ProductAttributeControl;
export default withAttributes( ProductAttributeControl );

View File

@ -2,43 +2,21 @@
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { Component, Fragment } from '@wordpress/element';
import { Fragment } from '@wordpress/element';
import { find } from 'lodash';
import PropTypes from 'prop-types';
import { SearchListControl, SearchListItem } from '@woocommerce/components';
import { SelectControl } from '@wordpress/components';
import { ENDPOINTS } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import { withCategories } from '../../hocs';
import ErrorMessage from '../api-error-placeholder/error-message.js';
import './style.scss';
class ProductCategoryControl extends Component {
constructor() {
super( ...arguments );
this.state = {
list: [],
loading: true,
};
this.renderItem = this.renderItem.bind( this );
}
componentDidMount() {
apiFetch( {
path: addQueryArgs( `${ ENDPOINTS.products }/categories`, { per_page: -1 } ),
} )
.then( ( list ) => {
this.setState( { list, loading: false } );
} )
.catch( () => {
this.setState( { list: [], loading: false } );
} );
}
renderItem( args ) {
const ProductCategoryControl = ( { categories, error, isLoading, onChange, onOperatorChange, operator, selected, isSingle } ) => {
const renderItem = ( args ) => {
const { item, search, depth = 0 } = args;
const classes = [
'woocommerce-product-categories__item',
@ -71,77 +49,78 @@ class ProductCategoryControl extends Component {
) }
/>
);
}
};
render() {
const { list, loading } = this.state;
const { onChange, onOperatorChange, operator, selected, isSingle } = this.props;
const messages = {
clear: __( 'Clear all product categories', 'woo-gutenberg-products-block' ),
list: __( 'Product Categories', 'woo-gutenberg-products-block' ),
noItems: __(
"Your store doesn't have any product categories.",
'woo-gutenberg-products-block'
),
search: __(
'Search for product categories',
'woo-gutenberg-products-block'
),
selected: ( n ) =>
sprintf(
_n(
'%d category selected',
'%d categories selected',
n,
'woo-gutenberg-products-block'
),
n
const messages = {
clear: __( 'Clear all product categories', 'woo-gutenberg-products-block' ),
list: __( 'Product Categories', 'woo-gutenberg-products-block' ),
noItems: __(
"Your store doesn't have any product categories.",
'woo-gutenberg-products-block'
),
search: __(
'Search for product categories',
'woo-gutenberg-products-block'
),
selected: ( n ) =>
sprintf(
_n(
'%d category selected',
'%d categories selected',
n,
'woo-gutenberg-products-block'
),
updated: __(
'Category search results updated.',
'woo-gutenberg-products-block'
n
),
};
updated: __(
'Category search results updated.',
'woo-gutenberg-products-block'
),
};
if ( error ) {
return (
<Fragment>
<SearchListControl
className="woocommerce-product-categories"
list={ list }
isLoading={ loading }
selected={ selected.map( ( id ) => find( list, { id } ) ).filter( Boolean ) }
onChange={ onChange }
renderItem={ this.renderItem }
messages={ messages }
isHierarchical
isSingle={ isSingle }
/>
{ ( !! onOperatorChange ) && (
<div className={ selected.length < 2 ? 'screen-reader-text' : '' }>
<SelectControl
className="woocommerce-product-categories__operator"
label={ __( 'Display products matching', 'woo-gutenberg-products-block' ) }
help={ __( 'Pick at least two categories to use this setting.', 'woo-gutenberg-products-block' ) }
value={ operator }
onChange={ onOperatorChange }
options={ [
{
label: __( 'Any selected categories', 'woo-gutenberg-products-block' ),
value: 'any',
},
{
label: __( 'All selected categories', 'woo-gutenberg-products-block' ),
value: 'all',
},
] }
/>
</div>
) }
</Fragment>
<ErrorMessage error={ error } />
);
}
}
return (
<Fragment>
<SearchListControl
className="woocommerce-product-categories"
list={ categories }
isLoading={ isLoading }
selected={ selected.map( ( id ) => find( categories, { id } ) ).filter( Boolean ) }
onChange={ onChange }
renderItem={ renderItem }
messages={ messages }
isHierarchical
isSingle={ isSingle }
/>
{ ( !! onOperatorChange ) && (
<div className={ selected.length < 2 ? 'screen-reader-text' : '' }>
<SelectControl
className="woocommerce-product-categories__operator"
label={ __( 'Display products matching', 'woo-gutenberg-products-block' ) }
help={ __( 'Pick at least two categories to use this setting.', 'woo-gutenberg-products-block' ) }
value={ operator }
onChange={ onOperatorChange }
options={ [
{
label: __( 'Any selected categories', 'woo-gutenberg-products-block' ),
value: 'any',
},
{
label: __( 'All selected categories', 'woo-gutenberg-products-block' ),
value: 'all',
},
] }
/>
</div>
) }
</Fragment>
);
};
ProductCategoryControl.propTypes = {
/**
@ -171,4 +150,4 @@ ProductCategoryControl.defaultProps = {
isSingle: false,
};
export default ProductCategoryControl;
export default withCategories( ProductCategoryControl );

View File

@ -107,3 +107,24 @@ export const getCategory = ( categoryId ) => {
path: `${ ENDPOINTS.categories }/${ categoryId }`,
} );
};
/**
* Get a promise that resolves to an array of category objects from the API.
*/
export const getCategories = () => {
return apiFetch( {
path: addQueryArgs( `${ ENDPOINTS.products }/categories`, { per_page: -1 } ),
} );
};
export const getAttributes = () => {
return apiFetch( {
path: addQueryArgs( `${ ENDPOINTS.products }/attributes`, { per_page: -1 } ),
} );
};
export const getTerms = ( attribute ) => {
return apiFetch( {
path: addQueryArgs( `${ ENDPOINTS.products }/attributes/${ attribute }/terms`, { per_page: -1 } ),
} );
};

View File

@ -1,3 +1,5 @@
export { default as withProduct } from './with-product';
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 withSearchedProducts } from './with-searched-products';

View File

@ -0,0 +1,157 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withAttributes from '../with-attributes';
import * as mockUtils from '../../components/utils';
import * as mockBaseUtils from '../../base/utils/errors';
jest.mock( '../../components/utils', () => ( {
getAttributes: jest.fn(),
getTerms: jest.fn(),
} ) );
jest.mock( '../../base/utils/errors', () => ( {
formatError: jest.fn(),
} ) );
jest.mock( 'lodash', () => ( {
...jest.requireActual( 'lodash' ),
debounce: ( func ) => func,
} ) );
const mockAttributes = [ { id: 1, name: 'Color', slug: 'color' }, { id: 2, name: 'Size', slug: 'size' } ];
const mockAttributesWithParent = [ { id: 1, name: 'Color', slug: 'color', parent: 0 }, { id: 2, name: 'Size', slug: 'size', parent: 0 } ];
const selected = [ { id: 11, attr_slug: 'color' } ];
const TestComponent = withAttributes( ( props ) => {
return <div
attributes={ props.attributes }
error={ props.error }
expandedAttribute={ props.expandedAttribute }
onExpandAttribute={ props.onExpandAttribute }
isLoading={ props.isLoading }
termsAreLoading={ props.termsAreLoading }
termsList={ props.termsList }
/>;
} );
describe( 'withAttributes Component', () => {
afterEach( () => {
mockUtils.getAttributes.mockReset();
mockUtils.getTerms.mockReset();
mockBaseUtils.formatError.mockReset();
} );
describe( 'lifecycle events', () => {
let getAttributesPromise;
beforeEach( () => {
getAttributesPromise = Promise.resolve( mockAttributes );
mockUtils.getAttributes.mockImplementation(
() => getAttributesPromise
);
mockUtils.getTerms.mockImplementation(
() => Promise.resolve( [] )
);
} );
it( 'getAttributes is called on mount', () => {
TestRenderer.create(
<TestComponent />
);
const { getAttributes } = mockUtils;
expect( getAttributes ).toHaveBeenCalledTimes( 1 );
} );
it( 'getTerms is called on component update', () => {
const renderer = TestRenderer.create(
<TestComponent />
);
let props = renderer.root.findByType( 'div' ).props;
props.onExpandAttribute( 1 );
const { getTerms } = mockUtils;
props = renderer.root.findByType( 'div' ).props;
expect( getTerms ).toHaveBeenCalledWith( 1 );
expect( getTerms ).toHaveBeenCalledTimes( 1 );
expect( props.expandedAttribute ).toBe( 1 );
} );
it( 'getTerms is called on mount if there was an attribute selected', ( done ) => {
const renderer = TestRenderer.create(
<TestComponent selected={ selected } />
);
getAttributesPromise.then( () => {
const { getTerms } = mockUtils;
const props = renderer.root.findByType( 'div' ).props;
expect( getTerms ).toHaveBeenCalledWith( 1 );
expect( getTerms ).toHaveBeenCalledTimes( 1 );
expect( props.expandedAttribute ).toBe( 1 );
done();
} );
} );
} );
describe( 'when the API returns attributes data', () => {
let renderer;
beforeEach( () => {
mockUtils.getAttributes.mockImplementation(
() => Promise.resolve( mockAttributes )
);
renderer = TestRenderer.create(
<TestComponent />
);
} );
it( 'sets the attributes props', () => {
const props = renderer.root.findByType( 'div' ).props;
expect( props.error ).toBeNull();
expect( props.isLoading ).toBe( false );
expect( props.attributes ).toEqual( mockAttributesWithParent );
} );
} );
describe( 'when the API returns an error', () => {
const error = { message: 'There was an error.' };
const getAttributesPromise = Promise.reject( error );
const formattedError = { message: 'There was an error.', type: 'api' };
let renderer;
beforeEach( () => {
mockUtils.getAttributes.mockImplementation(
() => getAttributesPromise
);
mockBaseUtils.formatError.mockImplementation(
() => formattedError,
);
renderer = TestRenderer.create(
<TestComponent />
);
} );
it( 'sets the error prop', ( done ) => {
const { formatError } = mockBaseUtils;
getAttributesPromise.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.attributes ).toEqual( [] );
done();
} );
} );
} );
} );

View File

@ -0,0 +1,102 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withCategories from '../with-categories';
import * as mockUtils from '../../components/utils';
import * as mockBaseUtils from '../../base/utils/errors';
jest.mock( '../../components/utils', () => ( {
getCategories: jest.fn(),
} ) );
jest.mock( '../../base/utils/errors', () => ( {
formatError: jest.fn(),
} ) );
const mockCategories = [ { id: 1, name: 'Clothing' }, { id: 2, name: 'Food' } ];
const TestComponent = withCategories( ( props ) => {
return <div
error={ props.error }
isLoading={ props.isLoading }
categories={ props.categories }
/>;
} );
const render = () => {
return TestRenderer.create(
<TestComponent />
);
};
describe( 'withCategories Component', () => {
let renderer;
afterEach( () => {
mockUtils.getCategories.mockReset();
} );
describe( 'lifecycle events', () => {
beforeEach( () => {
mockUtils.getCategories.mockImplementation( () => Promise.resolve() );
renderer = render();
} );
it( 'getCategories is called on mount', () => {
const { getCategories } = mockUtils;
expect( getCategories ).toHaveBeenCalledWith();
expect( getCategories ).toHaveBeenCalledTimes( 1 );
} );
} );
describe( 'when the API returns categories data', () => {
beforeEach( () => {
mockUtils.getCategories.mockImplementation(
() => Promise.resolve( mockCategories )
);
renderer = render();
} );
it( 'sets the categories props', () => {
const props = renderer.root.findByType( 'div' ).props;
expect( props.error ).toBeNull();
expect( props.isLoading ).toBe( false );
expect( props.categories ).toEqual( mockCategories );
} );
} );
describe( 'when the API returns an error', () => {
const error = { message: 'There was an error.' };
const getCategoriesPromise = Promise.reject( error );
const formattedError = { message: 'There was an error.', type: 'api' };
beforeEach( () => {
mockUtils.getCategories.mockImplementation(
() => getCategoriesPromise
);
mockBaseUtils.formatError.mockImplementation(
() => formattedError,
);
renderer = render();
} );
it( 'sets the error prop', ( done ) => {
const { formatError } = mockBaseUtils;
getCategoriesPromise.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.categories ).toBeNull();
done();
} );
} );
} );
} );

View File

@ -0,0 +1,130 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withCategory from '../with-category';
import * as mockUtils from '../../components/utils';
import * as mockBaseUtils from '../../base/utils/errors';
jest.mock( '../../components/utils', () => ( {
getCategory: jest.fn(),
} ) );
jest.mock( '../../base/utils/errors', () => ( {
formatError: jest.fn(),
} ) );
const mockCategory = { name: 'Clothing' };
const attributes = { categoryId: 1 };
const TestComponent = withCategory( ( props ) => {
return <div
error={ props.error }
getCategory={ props.getCategory }
isLoading={ props.isLoading }
category={ props.category }
/>;
} );
const render = () => {
return TestRenderer.create(
<TestComponent
attributes={ attributes }
/>
);
};
describe( 'withCategory Component', () => {
let renderer;
afterEach( () => {
mockUtils.getCategory.mockReset();
} );
describe( 'lifecycle events', () => {
beforeEach( () => {
mockUtils.getCategory.mockImplementation( () => Promise.resolve() );
renderer = render();
} );
it( 'getCategory is called on mount with passed in category id', () => {
const { getCategory } = mockUtils;
expect( getCategory ).toHaveBeenCalledWith( attributes.categoryId );
expect( getCategory ).toHaveBeenCalledTimes( 1 );
} );
it( 'getCategory is called on component update', () => {
const { getCategory } = mockUtils;
const newAttributes = { ...attributes, categoryId: 2 };
renderer.update(
<TestComponent
attributes={ newAttributes }
/>
);
expect( getCategory ).toHaveBeenNthCalledWith( 2, newAttributes.categoryId );
expect( getCategory ).toHaveBeenCalledTimes( 2 );
} );
it( 'getCategory is hooked to the prop', () => {
const { getCategory } = mockUtils;
const props = renderer.root.findByType( 'div' ).props;
props.getCategory();
expect( getCategory ).toHaveBeenCalledTimes( 2 );
} );
} );
describe( 'when the API returns category data', () => {
beforeEach( () => {
mockUtils.getCategory.mockImplementation(
( categoryId ) => Promise.resolve( { ...mockCategory, id: categoryId } )
);
renderer = render();
} );
it( 'sets the category props', () => {
const props = renderer.root.findByType( 'div' ).props;
expect( props.error ).toBeNull();
expect( typeof props.getCategory ).toBe( 'function' );
expect( props.isLoading ).toBe( false );
expect( props.category ).toEqual( { ...mockCategory, id: attributes.categoryId } );
} );
} );
describe( 'when the API returns an error', () => {
const error = { message: 'There was an error.' };
const getCategoryPromise = Promise.reject( error );
const formattedError = { message: 'There was an error.', type: 'api' };
beforeEach( () => {
mockUtils.getCategory.mockImplementation(
() => getCategoryPromise
);
mockBaseUtils.formatError.mockImplementation(
() => formattedError,
);
renderer = render();
} );
it( 'sets the error prop', ( done ) => {
const { formatError } = mockBaseUtils;
getCategoryPromise.catch( () => {
const props = renderer.root.findByType( 'div' ).props;
expect( formatError ).toHaveBeenCalledWith( error );
expect( formatError ).toHaveBeenCalledTimes( 1 );
expect( props.error ).toEqual( formattedError );
expect( typeof props.getCategory ).toBe( 'function' );
expect( props.isLoading ).toBe( false );
expect( props.category ).toBeNull();
done();
} );
} );
} );
} );

View File

@ -8,12 +8,16 @@ import TestRenderer from 'react-test-renderer';
*/
import withProduct from '../with-product';
import * as mockUtils from '../../components/utils';
import * as mockBaseUtils from '../../base/utils/errors';
// Mock the getProduct functions for tests.
jest.mock( '../../components/utils', () => ( {
getProduct: jest.fn(),
} ) );
jest.mock( '../../base/utils/errors', () => ( {
formatError: jest.fn(),
} ) );
const mockProduct = { name: 'T-Shirt' };
const attributes = { productId: 1 };
const TestComponent = withProduct( ( props ) => {
@ -44,24 +48,33 @@ describe( 'withProduct Component', () => {
renderer = render();
} );
describe( 'test', () => {
it( 'getProduct is called on mount with passed in product id', () => {
const { getProduct } = mockUtils;
it( 'getProduct is called on mount with passed in product id', () => {
const { getProduct } = mockUtils;
expect( getProduct ).toHaveBeenCalledWith( attributes.productId );
expect( getProduct ).toHaveBeenCalledTimes( 1 );
} );
expect( getProduct ).toHaveBeenCalledWith( attributes.productId );
expect( getProduct ).toHaveBeenCalledTimes( 1 );
} );
describe( 'test', () => {
it( 'getProduct is hooked to the prop', () => {
const { getProduct } = mockUtils;
const props = renderer.root.findByType( 'div' ).props;
it( 'getProduct is called on component update', () => {
const { getProduct } = mockUtils;
const newAttributes = { ...attributes, productId: 2 };
renderer.update(
<TestComponent
attributes={ newAttributes }
/>
);
props.getProduct();
expect( getProduct ).toHaveBeenNthCalledWith( 2, newAttributes.productId );
expect( getProduct ).toHaveBeenCalledTimes( 2 );
} );
expect( getProduct ).toHaveBeenCalledTimes( 2 );
} );
it( 'getProduct is hooked to the prop', () => {
const { getProduct } = mockUtils;
const props = renderer.root.findByType( 'div' ).props;
props.getProduct();
expect( getProduct ).toHaveBeenCalledTimes( 2 );
} );
} );
@ -84,20 +97,34 @@ describe( 'withProduct Component', () => {
} );
describe( 'when the API returns an error', () => {
const error = { message: 'There was an error.' };
const getProductPromise = Promise.reject( error );
const formattedError = { message: 'There was an error.', type: 'api' };
beforeEach( () => {
mockUtils.getProduct.mockImplementation(
() => Promise.reject( { message: 'There was an error.' } )
() => getProductPromise,
);
mockBaseUtils.formatError.mockImplementation(
() => formattedError,
);
renderer = render();
} );
it( 'sets the error prop', () => {
const props = renderer.root.findByType( 'div' ).props;
it( 'sets the error prop', ( done ) => {
const { formatError } = mockBaseUtils;
getProductPromise.catch( () => {
const props = renderer.root.findByType( 'div' ).props;
expect( props.error ).toEqual( { apiMessage: 'There was an error.' } );
expect( typeof props.getProduct ).toBe( 'function' );
expect( props.isLoading ).toBe( false );
expect( props.product ).toBeNull();
expect( formatError ).toHaveBeenCalledWith( error );
expect( formatError ).toHaveBeenCalledTimes( 1 );
expect( props.error ).toEqual( formattedError );
expect( typeof props.getProduct ).toBe( 'function' );
expect( props.isLoading ).toBe( false );
expect( props.product ).toBeNull();
done();
} );
} );
} );
} );

View File

@ -0,0 +1,128 @@
/**
* External dependencies
*/
import { Component } from '@wordpress/element';
import { createHigherOrderComponent } from '@wordpress/compose';
import PropTypes from 'prop-types';
import { debounce } from 'lodash';
/**
* Internal dependencies
*/
import { getAttributes, getTerms } from '../components/utils';
import { formatError } from '../base/utils/errors.js';
const withAttributes = createHigherOrderComponent(
( OriginalComponent ) => {
class WrappedComponent extends Component {
constructor() {
super( ...arguments );
this.state = {
attributes: [],
error: null,
expandedAttribute: null,
loading: false,
termsList: {},
termsLoading: false,
};
this.loadAttributes = this.loadAttributes.bind( this );
this.onExpandAttribute = this.onExpandAttribute.bind( this );
this.debouncedLoadTerms = debounce( this.loadTerms.bind( this ), 200 );
}
componentDidMount() {
this.loadAttributes();
}
componentWillUnmount() {
this.debouncedLoadTerms.cancel();
}
componentDidUpdate( prevProps, prevState ) {
if ( prevState.expandedAttribute !== this.state.expandedAttribute ) {
this.debouncedLoadTerms();
}
}
loadAttributes() {
const { selected } = this.props;
const { expandedAttribute } = this.state;
this.setState( { loading: true } );
getAttributes().then( ( attributes ) => {
attributes = attributes.map( ( item ) => ( { ...item, parent: 0 } ) );
let newExpandedAttribute = expandedAttribute;
if ( ! expandedAttribute && selected.length > 0 ) {
const attr = attributes.find( ( item ) => item.slug === selected[ 0 ].attr_slug );
if ( attr ) {
newExpandedAttribute = attr.id;
}
}
this.setState( { attributes, expandedAttribute: newExpandedAttribute, loading: false, error: null } );
} ).catch( async ( e ) => {
const error = await formatError( e );
this.setState( { attributes: [], expandedAttribute: null, loading: false, error } );
} );
}
loadTerms() {
const { expandedAttribute, termsList } = this.state;
if ( ! expandedAttribute ) {
return;
}
if ( ! termsList[ expandedAttribute ] ) {
this.setState( { termsLoading: true } );
}
getTerms( expandedAttribute )
.then( ( terms ) => {
terms = terms.map( ( term ) => ( { ...term, parent: expandedAttribute, attr_slug: term.attribute.slug } ) );
this.setState( ( prevState ) => ( {
termsList: { ...prevState.termsList, [ expandedAttribute ]: terms },
termsLoading: false,
} ) );
} )
.catch( async ( e ) => {
const error = await formatError( e );
this.setState( { termsList: {}, termsLoading: false, error } );
} );
}
onExpandAttribute( attributeId ) {
const { expandedAttribute } = this.state;
this.setState( {
expandedAttribute: attributeId === expandedAttribute ? null : attributeId,
} );
}
render() {
const { error, expandedAttribute, loading, attributes, termsList, termsLoading } = this.state;
return <OriginalComponent
{ ...this.props }
attributes={ attributes }
error={ error }
expandedAttribute={ expandedAttribute }
onExpandAttribute={ this.onExpandAttribute }
isLoading={ loading }
termsAreLoading={ termsLoading }
termsList={ termsList }
/>;
}
}
WrappedComponent.propTypes = {
selected: PropTypes.array,
};
WrappedComponent.defaultProps = {
selected: [],
};
return WrappedComponent;
},
'withAttributes'
);
export default withAttributes;

View File

@ -0,0 +1,57 @@
/**
* External dependencies
*/
import { Component } from '@wordpress/element';
import { createHigherOrderComponent } from '@wordpress/compose';
/**
* Internal dependencies
*/
import { getCategories } from '../components/utils';
import { formatError } from '../base/utils/errors.js';
const withCategories = createHigherOrderComponent(
( OriginalComponent ) => {
return class WrappedComponent extends Component {
constructor() {
super( ...arguments );
this.state = {
error: null,
loading: false,
categories: null,
};
this.loadCategories = this.loadCategories.bind( this );
}
componentDidMount() {
this.loadCategories();
}
loadCategories() {
this.setState( { loading: true } );
getCategories().then( ( categories ) => {
this.setState( { categories, loading: false, error: null } );
} ).catch( async ( e ) => {
const error = await formatError( e );
this.setState( { categories: null, loading: false, error } );
} );
}
render() {
const { error, loading, categories } = this.state;
return <OriginalComponent
{ ...this.props }
error={ error }
isLoading={ loading }
categories={ categories }
/>;
}
};
},
'withCategories'
);
export default withCategories;

View File

@ -45,8 +45,8 @@ const withCategory = createHigherOrderComponent(
getCategory( categoryId ).then( ( category ) => {
this.setState( { category, loading: false, error: null } );
} ).catch( ( apiError ) => {
const error = formatError( apiError );
} ).catch( async ( e ) => {
const error = await formatError( e );
this.setState( { category: null, loading: false, error } );
} );

View File

@ -45,8 +45,8 @@ const withProduct = createHigherOrderComponent(
getProduct( productId ).then( ( product ) => {
this.setState( { product, loading: false, error: null } );
} ).catch( ( apiError ) => {
const error = formatError( apiError );
} ).catch( async ( e ) => {
const error = await formatError( e );
this.setState( { product: null, loading: false, error } );
} );

View File

@ -57,12 +57,10 @@ const withSearchedProducts = createHigherOrderComponent( ( OriginalComponent ) =
.catch( this.setError );
}
setError( errorResponse ) {
errorResponse.json().then( ( apiError ) => {
const error = formatError( apiError );
async setError( e ) {
const error = await formatError( e );
this.setState( { list: [], loading: false, error } );
} );
this.setState( { list: [], loading: false, error } );
}
render() {

View File

@ -79,7 +79,7 @@ class ProductAttributes extends WC_REST_Product_Attributes_Controller {
*/
public function get_items_permissions_check( $request ) {
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woo-gutenberg-products-block' ), array( 'status' => rest_authorization_required_code() ) );
return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woo-gutenberg-products-block' ), array( 'status' => \rest_authorization_required_code() ) );
}
return true;
}