* Create withReviews base HOC

* Add tests

* Make Reviews by Category use withReviews HOC

* Move componentDidUpdate and debounce dependency to decouple HOC from components

* Spaces

* Rename 'delayMethod' to 'delayFunction'

* Refactor withReviews HOC

* Update tests

* Minor fixes

* Undo fix being handled in woocommerce/woocommerce-blocks#884

* Remove hardcoded from withReviews

* Update delay comment

* Use callbacks instead of announceUpdates prop

* Move props check to a 'shouldReplaceReviews' method

* Fix productId propType

* Move per_page and offset args to 'getArgs'

* Update withReviews displayName

* Fix tests

* Add callback propsTypes

* Use is-shallow-equal
This commit is contained in:
Albert Juhé Lluveras 2019-08-22 13:36:20 +02:00 committed by GitHub
parent 0b9559e2db
commit 46934d2946
14 changed files with 536 additions and 458 deletions

View File

@ -9,7 +9,7 @@ import PropTypes from 'prop-types';
*/ */
import './style.scss'; import './style.scss';
const ReviewOrderSelect = ( { componentId, onChange, readOnly, value } ) => { const ReviewOrderSelect = ( { componentId, defaultValue, onChange, readOnly, value } ) => {
const selectId = `wc-block-review-order-select__select-${ componentId }`; const selectId = `wc-block-review-order-select__select-${ componentId }`;
return ( return (
@ -25,6 +25,7 @@ const ReviewOrderSelect = ( { componentId, onChange, readOnly, value } ) => {
<select // eslint-disable-line jsx-a11y/no-onchange <select // eslint-disable-line jsx-a11y/no-onchange
id={ selectId } id={ selectId }
className="wc-block-review-order-select__select" className="wc-block-review-order-select__select"
defaultValue={ defaultValue }
onChange={ onChange } onChange={ onChange }
readOnly={ readOnly } readOnly={ readOnly }
value={ value } value={ value }

View File

@ -0,0 +1,127 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withReviews from '../with-reviews';
import * as mockUtils from '../../../blocks/reviews/utils';
jest.mock( '../../../blocks/reviews/utils', () => ( {
getOrderArgs: () => ( {
order: 'desc',
orderby: 'date_gmt',
} ),
getReviews: jest.fn(),
} ) );
const mockReviews = [
{ reviewer: 'Alice', review: 'Lorem ipsum', rating: 2 },
{ reviewer: 'Bob', review: 'Dolor sit amet', rating: 3 },
{ reviewer: 'Carol', review: 'Consectetur adipiscing elit', rating: 5 },
];
const defaultArgs = {
offset: 0,
order: 'desc',
orderby: 'date_gmt',
per_page: 2,
product_id: 1,
};
const TestComponent = withReviews( ( props ) => {
return <div
error={ props.error }
getReviews={ props.getReviews }
appendReviews={ props.appendReviews }
onChangeArgs={ props.onChangeArgs }
isLoading={ props.isLoading }
reviews={ props.reviews }
totalReviews={ props.totalReviews }
/>;
} );
const render = () => {
return TestRenderer.create(
<TestComponent
order="desc"
orderby="date_gmt"
productId={ 1 }
reviewsToDisplay={ 2 }
/>
);
};
describe( 'withReviews Component', () => {
let renderer;
afterEach( () => {
mockUtils.getReviews.mockReset();
} );
describe( 'lifecycle events', () => {
beforeEach( () => {
mockUtils.getReviews.mockImplementationOnce(
() => Promise.resolve( { reviews: mockReviews.slice( 0, 2 ), totalReviews: mockReviews.length } )
).mockImplementationOnce(
() => Promise.resolve( { reviews: mockReviews.slice( 2, 3 ), totalReviews: mockReviews.length } )
);
renderer = render();
} );
it( 'getReviews is called on mount with default args', () => {
const { getReviews } = mockUtils;
expect( getReviews ).toHaveBeenCalledWith( defaultArgs );
expect( getReviews ).toHaveBeenCalledTimes( 1 );
} );
it( 'getReviews is called on component update', () => {
const { getReviews } = mockUtils;
renderer.update(
<TestComponent
order="desc"
orderby="date_gmt"
productId={ 1 }
reviewsToDisplay={ 3 }
/>
);
expect( getReviews ).toHaveBeenNthCalledWith( 2, { ...defaultArgs, offset: 2, per_page: 1 } );
expect( getReviews ).toHaveBeenCalledTimes( 2 );
} );
} );
describe( 'when the API returns product data', () => {
beforeEach( () => {
mockUtils.getReviews.mockImplementation(
() => Promise.resolve( { reviews: mockReviews.slice( 0, 2 ), totalReviews: mockReviews.length } )
);
renderer = render();
} );
it( 'sets reviews based on API response', () => {
const props = renderer.root.findByType( 'div' ).props;
expect( props.error ).toBeNull();
expect( props.isLoading ).toBe( false );
expect( props.reviews ).toEqual( mockReviews.slice( 0, 2 ) );
expect( props.totalReviews ).toEqual( mockReviews.length );
} );
} );
describe( 'when the API returns an error', () => {
beforeEach( () => {
mockUtils.getReviews.mockImplementation(
() => Promise.reject( { message: 'There was an error.' } )
);
renderer = render();
} );
it( 'sets the error prop', () => {
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( [] );
} );
} );
} );

View File

@ -0,0 +1,170 @@
/**
* External dependencies
*/
import { Component } from 'react';
import PropTypes from 'prop-types';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* Internal dependencies
*/
import { getReviews } from '../../blocks/reviews/utils';
const withReviews = ( OriginalComponent ) => {
class WrappedComponent extends Component {
constructor() {
super( ...arguments );
this.state = {
error: null,
loading: false,
reviews: [],
totalReviews: 0,
};
this.setError = this.setError.bind( this );
this.delayedAppendReviews = this.props.delayFunction( this.appendReviews );
}
componentDidMount() {
this.replaceReviews();
}
componentDidUpdate( prevProps ) {
if ( prevProps.reviewsToDisplay < this.props.reviewsToDisplay ) {
// Since this attribute might be controlled via something with
// short intervals between value changes, this allows for optionally
// delaying review fetches via the provided delay function.
this.delayedAppendReviews();
} else if (
this.shouldReplaceReviews( prevProps, this.props )
) {
this.replaceReviews();
}
}
shouldReplaceReviews( prevProps, nextProps ) {
return (
prevProps.orderby !== nextProps.orderby ||
prevProps.order !== nextProps.order ||
prevProps.productId !== nextProps.productId ||
! isShallowEqual( prevProps.categoryIds, nextProps.categoryIds )
);
}
getArgs( reviewsToSkip ) {
const { categoryIds, order, orderby, productId, reviewsToDisplay } = this.props;
const args = {
order,
orderby,
per_page: reviewsToDisplay - reviewsToSkip,
offset: reviewsToSkip,
};
if ( categoryIds && categoryIds.length ) {
args.category_id = Array.isArray( categoryIds ) ? categoryIds.join( ',' ) : categoryIds;
}
if ( productId ) {
args.product_id = productId;
}
return args;
}
replaceReviews() {
const { onReviewsReplaced } = this.props;
this.updateListOfReviews().then( onReviewsReplaced );
}
appendReviews() {
const { onReviewsAppended, reviewsToDisplay } = this.props;
const { reviews } = this.state;
// Given that this function is delayed, props might have been updated since
// it was called so we need to check again if fetching new reviews is necessary.
if ( reviewsToDisplay <= reviews.length ) {
return;
}
this.updateListOfReviews( reviews ).then( onReviewsAppended );
}
updateListOfReviews( oldReviews = [] ) {
const { reviewsToDisplay } = this.props;
const { totalReviews } = this.state;
const reviewsToLoad = Math.min( totalReviews, reviewsToDisplay ) - oldReviews.length;
this.setState( {
loading: true,
reviews: oldReviews.concat( Array( reviewsToLoad ).fill( {} ) ),
} );
return getReviews( this.getArgs( oldReviews.length ) )
.then( ( { reviews: newReviews, totalReviews: newTotalReviews } ) => {
this.setState( {
reviews: oldReviews.filter( ( review ) => Object.keys( review ).length ).concat( newReviews ),
totalReviews: newTotalReviews,
loading: false,
error: null,
} );
return { newReviews };
} )
.catch( this.setError );
}
setError( apiError ) {
const { onReviewsLoadError } = this.props;
const error = typeof apiError === 'object' && apiError.hasOwnProperty( 'message' ) ? {
apiMessage: apiError.message,
} : {
apiMessage: null,
};
this.setState( { reviews: [], loading: false, error } );
onReviewsLoadError();
}
render() {
const { reviewsToDisplay } = this.props;
const { error, loading, reviews, totalReviews } = this.state;
return <OriginalComponent
{ ...this.props }
error={ error }
isLoading={ loading }
reviews={ reviews.slice( 0, reviewsToDisplay ) }
totalReviews={ totalReviews }
/>;
}
}
WrappedComponent.propTypes = {
order: PropTypes.oneOf( [ 'asc', 'desc' ] ).isRequired,
orderby: PropTypes.string.isRequired,
reviewsToDisplay: PropTypes.number.isRequired,
categoryIds: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array ] ),
delayFunction: PropTypes.func,
onReviewsAppended: PropTypes.func,
onReviewsLoadError: PropTypes.func,
onReviewsReplaced: PropTypes.func,
productId: PropTypes.oneOfType( [ PropTypes.string, PropTypes.number ] ),
};
WrappedComponent.defaultProps = {
delayFunction: ( f ) => f,
onReviewsAppended: () => {},
onReviewsLoadError: () => {},
onReviewsReplaced: () => {},
};
const { displayName = OriginalComponent.name || 'Component' } = OriginalComponent;
WrappedComponent.displayName = `WithReviews( ${ displayName } )`;
return WrappedComponent;
};
export default withReviews;

View File

@ -0,0 +1,66 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Fragment } from 'react';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import LoadMoreButton from '../../base/components/load-more-button';
import ReviewOrderSelect from '../../base/components/review-order-select';
import ReviewList from '../../base/components/review-list';
import withComponentId from '../../base/hocs/with-component-id';
import withReviews from '../../base/hocs/with-reviews';
import { ENABLE_REVIEW_RATING } from '../../constants';
/**
* Block rendered in the frontend.
*/
const FrontendBlock = ( { attributes, componentId, onAppendReviews, onChangeOrderby, reviews, totalReviews } ) => {
const { orderby } = attributes;
if ( 0 === reviews.length ) {
return null;
}
return (
<Fragment>
{ ( attributes.showOrderby && ENABLE_REVIEW_RATING ) && (
<ReviewOrderSelect
componentId={ componentId }
defaultValue={ orderby }
onChange={ onChangeOrderby }
/>
) }
<ReviewList
attributes={ attributes }
componentId={ componentId }
reviews={ reviews }
/>
{ ( attributes.showLoadMore && totalReviews > reviews.length ) && (
<LoadMoreButton
onClick={ onAppendReviews }
screenReaderLabel={ __( 'Load more reviews', 'woo-gutenberg-products-block' ) }
/>
) }
</Fragment>
);
};
FrontendBlock.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
onAppendReviews: PropTypes.func,
onChangeArgs: PropTypes.func,
// from withComponentId
componentId: PropTypes.number,
// from withReviewsattributes
reviews: PropTypes.array,
totalReviews: PropTypes.number,
};
export default withComponentId( withReviews( FrontendBlock ) );

View File

@ -0,0 +1,103 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { speak } from '@wordpress/a11y';
import { Component } from 'react';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import { getOrderArgs } from './utils';
import FrontendBlock from './frontend-block';
/**
* Container of the block rendered in the frontend.
*/
class FrontendContainerBlock extends Component {
constructor() {
super( ...arguments );
const { attributes } = this.props;
this.state = {
orderby: attributes.orderby,
reviewsToDisplay: parseInt( attributes.reviewsOnPageLoad, 10 ),
};
this.onAppendReviews = this.onAppendReviews.bind( this );
this.onChangeOrderby = this.onChangeOrderby.bind( this );
}
onAppendReviews() {
const { attributes } = this.props;
const { reviewsToDisplay } = this.state;
this.setState( {
reviewsToDisplay: reviewsToDisplay + parseInt( attributes.reviewsOnLoadMore, 10 ),
} );
}
onChangeOrderby( event ) {
const { attributes } = this.props;
this.setState( {
orderby: event.target.value,
reviewsToDisplay: parseInt( attributes.reviewsOnPageLoad, 10 ),
} );
}
onReviewsAppended( { newReviews } ) {
speak(
sprintf(
_n(
'%d review loaded.',
'%d reviews loaded.',
newReviews.length,
'woo-gutenberg-products-block'
),
newReviews.length,
)
);
}
onReviewsReplaced() {
speak( __( 'Reviews list updated.', 'woo-gutenberg-products-block' ) );
}
onReviewsLoadError() {
speak( __( 'There was an error loading the reviews.', 'woo-gutenberg-products-block' ) );
}
render() {
const { attributes } = this.props;
const { categoryIds, productId } = attributes;
const { reviewsToDisplay } = this.state;
const { order, orderby } = getOrderArgs( this.state.orderby );
return (
<FrontendBlock
attributes={ attributes }
categoryIds={ categoryIds }
onAppendReviews={ this.onAppendReviews }
onChangeOrderby={ this.onChangeOrderby }
onReviewsAppended={ this.onReviewsAppended }
onReviewsLoadError={ this.onReviewsLoadError }
onReviewsReplaced={ this.onReviewsReplaced }
order={ order }
orderby={ orderby }
productId={ productId }
reviewsToDisplay={ reviewsToDisplay }
/>
);
}
}
FrontendContainerBlock.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
};
export default FrontendContainerBlock;

View File

@ -18,6 +18,7 @@ import { SearchListItem } from '@woocommerce/components';
import { Fragment } from '@wordpress/element'; import { Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose'; import { compose } from '@wordpress/compose';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { debounce } from 'lodash';
/** /**
* Internal dependencies * Internal dependencies
@ -26,7 +27,7 @@ import EditorBlock from './editor-block.js';
import ProductCategoryControl from '../../../components/product-category-control'; import ProductCategoryControl from '../../../components/product-category-control';
import { IconReviewsByCategory } from '../../../components/icons'; import { IconReviewsByCategory } from '../../../components/icons';
import { getSharedReviewContentControls, getSharedReviewListControls } from '../edit.js'; import { getSharedReviewContentControls, getSharedReviewListControls } from '../edit.js';
import { getBlockClassName } from '../utils.js'; import { getBlockClassName, getOrderArgs } from '../utils.js';
/** /**
* Component to handle edit mode of "Reviews by Category". * Component to handle edit mode of "Reviews by Category".
@ -167,9 +168,19 @@ const ReviewsByCategoryEditor = ( { attributes, debouncedSpeak, setAttributes }
return renderHiddenContentPlaceholder(); return renderHiddenContentPlaceholder();
} }
const { reviewsOnPageLoad } = attributes;
const { order, orderby } = getOrderArgs( attributes.orderby );
return ( return (
<div className={ getBlockClassName( 'wc-block-reviews-by-category', attributes ) }> <div className={ getBlockClassName( 'wc-block-reviews-by-category', attributes ) }>
<EditorBlock attributes={ attributes } /> <EditorBlock
attributes={ attributes }
categoryIds={ categoryIds }
delayFunction={ ( callback ) => debounce( callback, 400 ) }
orderby={ orderby }
order={ order }
reviewsToDisplay={ reviewsOnPageLoad }
/>
</div> </div>
); );
}; };

View File

@ -4,17 +4,16 @@
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { Component } from 'react'; import { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { debounce } from 'lodash';
import { Disabled, Placeholder } from '@wordpress/components'; import { Disabled, Placeholder } from '@wordpress/components';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { getOrderArgs, getReviews } from '../utils';
import LoadMoreButton from '../../../base/components/load-more-button'; import LoadMoreButton from '../../../base/components/load-more-button';
import ReviewList from '../../../base/components/review-list'; import ReviewList from '../../../base/components/review-list';
import ReviewOrderSelect from '../../../base/components/review-order-select'; import ReviewOrderSelect from '../../../base/components/review-order-select';
import withComponentId from '../../../base/hocs/with-component-id'; import withComponentId from '../../../base/hocs/with-component-id';
import withReviews from '../../../base/hocs/with-reviews';
import { IconReviewsByCategory } from '../../../components/icons'; import { IconReviewsByCategory } from '../../../components/icons';
import { ENABLE_REVIEW_RATING } from '../../../constants'; import { ENABLE_REVIEW_RATING } from '../../../constants';
@ -22,54 +21,6 @@ import { ENABLE_REVIEW_RATING } from '../../../constants';
* Block rendered in the editor. * Block rendered in the editor.
*/ */
class EditorBlock extends Component { class EditorBlock extends Component {
constructor() {
super( ...arguments );
this.state = {
reviews: [],
totalReviews: 0,
isLoading: true,
};
this.renderNoReviews = this.renderNoReviews.bind( this );
this.debouncedLoadFirstReviews = debounce( this.loadFirstReviews.bind( this ), 400 );
}
componentDidMount() {
this.loadFirstReviews();
}
componentDidUpdate( prevProps ) {
if (
prevProps.attributes.orderby !== this.props.attributes.orderby ||
prevProps.attributes.categoryIds !== this.props.attributes.categoryIds ||
prevProps.attributes.reviewsOnPageLoad !== this.props.attributes.reviewsOnPageLoad
) {
this.debouncedLoadFirstReviews();
}
}
getDefaultArgs() {
const { attributes } = this.props;
const { order, orderby } = getOrderArgs( attributes.orderby );
const { categoryIds, reviewsOnPageLoad } = attributes;
return {
order,
orderby,
per_page: reviewsOnPageLoad,
category_id: categoryIds.join( ',' ),
};
}
loadFirstReviews() {
getReviews( this.getDefaultArgs() ).then( ( { reviews, totalReviews } ) => {
this.setState( { reviews, totalReviews, isLoading: false } );
} ).catch( () => {
this.setState( { reviews: [], isLoading: false } );
} );
}
renderNoReviews() { renderNoReviews() {
return ( return (
<Placeholder <Placeholder
@ -83,8 +34,7 @@ class EditorBlock extends Component {
} }
render() { render() {
const { attributes, componentId } = this.props; const { attributes, componentId, isLoading, reviews, totalReviews } = this.props;
const { reviews, totalReviews, isLoading } = this.state;
if ( 0 === reviews.length && ! isLoading ) { if ( 0 === reviews.length && ! isLoading ) {
return this.renderNoReviews(); return this.renderNoReviews();
@ -123,6 +73,9 @@ EditorBlock.propTypes = {
* From withComponentId. * From withComponentId.
*/ */
componentId: PropTypes.number, componentId: PropTypes.number,
// from withReviews
reviews: PropTypes.array,
totalReviews: PropTypes.number,
}; };
export default withComponentId( EditorBlock ); export default withComponentId( withReviews( EditorBlock ) );

View File

@ -1,172 +0,0 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { speak } from '@wordpress/a11y';
/**
* Internal dependencies
*/
import { getOrderArgs, getReviews } from '../utils';
import LoadMoreButton from '../../../base/components/load-more-button';
import ReviewOrderSelect from '../../../base/components/review-order-select';
import ReviewList from '../../../base/components/review-list';
import withComponentId from '../../../base/hocs/with-component-id';
import { ENABLE_REVIEW_RATING } from '../../../constants';
/**
* Block rendered in the frontend.
*/
class FrontendBlock extends Component {
constructor() {
super( ...arguments );
const { attributes } = this.props;
this.state = {
orderby: attributes.orderby,
reviews: [],
totalReviews: 0,
};
this.onChangeOrderby = this.onChangeOrderby.bind( this );
this.appendReviews = this.appendReviews.bind( this );
}
componentDidMount() {
this.loadFirstReviews();
}
getDefaultArgs() {
const { attributes } = this.props;
const { order, orderby } = getOrderArgs( this.state.orderby );
const { categoryIds, reviewsOnPageLoad } = attributes;
return {
order,
orderby,
per_page: reviewsOnPageLoad,
category_id: categoryIds,
};
}
loadFirstReviews() {
getReviews( this.getDefaultArgs() ).then( ( { reviews, totalReviews } ) => {
this.setState( { reviews, totalReviews } );
} ).catch( () => {
this.setState( { reviews: [] } );
speak(
__( 'There was an error loading the reviews.', 'woo-gutenberg-products-block' )
);
} );
}
appendReviews() {
const { attributes } = this.props;
const { reviewsOnLoadMore } = attributes;
const { reviews, totalReviews } = this.state;
const reviewsToLoad = Math.min( totalReviews - reviews.length, reviewsOnLoadMore );
this.setState( { reviews: reviews.concat( Array( reviewsToLoad ).fill( {} ) ) } );
const args = {
...this.getDefaultArgs(),
offset: reviews.length,
per_page: reviewsOnLoadMore,
};
getReviews( args ).then( ( { reviews: newReviews, totalReviews: newTotalReviews } ) => {
this.setState( {
reviews: reviews.filter( ( review ) => Object.keys( review ).length ).concat( newReviews ),
totalReviews: newTotalReviews,
} );
speak(
sprintf(
_n(
'%d review loaded.',
'%d reviews loaded.',
'woo-gutenberg-products-block'
),
newReviews.length
)
);
} ).catch( () => {
this.setState( { reviews: [] } );
speak(
__( 'There was an error loading the reviews.', 'woo-gutenberg-products-block' )
);
} );
}
onChangeOrderby( event ) {
const { attributes } = this.props;
const { reviewsOnPageLoad } = attributes;
const { totalReviews } = this.state;
const { order, orderby } = getOrderArgs( event.target.value );
const newReviews = Math.min( totalReviews, reviewsOnPageLoad );
this.setState( {
reviews: Array( newReviews ).fill( {} ),
orderby: event.target.value,
} );
const args = {
...this.getDefaultArgs(),
order,
orderby,
per_page: reviewsOnPageLoad,
};
getReviews( args ).then( ( { reviews, totalReviews: newTotalReviews } ) => {
this.setState( { reviews, totalReviews: newTotalReviews } );
speak( __( 'Reviews order updated.', 'woo-gutenberg-products-block' ) );
} ).catch( () => {
this.setState( { reviews: [] } );
speak(
__( 'There was an error loading the reviews.', 'woo-gutenberg-products-block' )
);
} );
}
render() {
const { attributes, componentId } = this.props;
const { orderby, reviews, totalReviews } = this.state;
if ( 0 === reviews.length ) {
return null;
}
return (
<Fragment>
{ ( attributes.showOrderby && ENABLE_REVIEW_RATING ) && (
<ReviewOrderSelect
componentId={ componentId }
onChange={ this.onChangeOrderby }
value={ orderby }
/>
) }
<ReviewList
attributes={ attributes }
componentId={ componentId }
reviews={ reviews }
/>
{ ( attributes.showLoadMore && totalReviews > reviews.length ) && (
<LoadMoreButton
onClick={ this.appendReviews }
screenReaderLabel={ __( 'Load more reviews', 'woo-gutenberg-products-block' ) }
/>
) }
</Fragment>
);
}
}
FrontendBlock.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
// from withComponentId
componentId: PropTypes.number,
};
export default withComponentId( FrontendBlock );

View File

@ -6,7 +6,7 @@ import { render } from 'react-dom';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import FrontendBlock from './frontend-block.js'; import FrontendContainerBlock from '../frontend-container-block.js';
const containers = document.querySelectorAll( const containers = document.querySelectorAll(
'.wp-block-woocommerce-reviews-by-category' '.wp-block-woocommerce-reviews-by-category'
@ -25,6 +25,6 @@ if ( containers.length ) {
showProductName: el.classList.contains( 'has-product-name' ), showProductName: el.classList.contains( 'has-product-name' ),
}; };
render( <FrontendBlock attributes={ attributes } />, el ); render( <FrontendContainerBlock attributes={ attributes } />, el );
} ); } );
} }

View File

@ -16,6 +16,7 @@ import {
import { SearchListItem } from '@woocommerce/components'; import { SearchListItem } from '@woocommerce/components';
import { Fragment } from '@wordpress/element'; import { Fragment } from '@wordpress/element';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { debounce } from 'lodash';
/** /**
* Internal dependencies * Internal dependencies
@ -24,7 +25,7 @@ import EditorBlock from './editor-block.js';
import ProductControl from '../../../components/product-control'; import ProductControl from '../../../components/product-control';
import { IconReviewsByProduct } from '../../../components/icons'; import { IconReviewsByProduct } from '../../../components/icons';
import { getSharedReviewContentControls, getSharedReviewListControls } from '../edit.js'; import { getSharedReviewContentControls, getSharedReviewListControls } from '../edit.js';
import { getBlockClassName } from '../utils.js'; import { getBlockClassName, getOrderArgs } from '../utils.js';
/** /**
* Component to handle edit mode of "Reviews by Product". * Component to handle edit mode of "Reviews by Product".
@ -160,9 +161,19 @@ const ReviewsByProductEditor = ( { attributes, debouncedSpeak, setAttributes } )
return renderHiddenContentPlaceholder(); return renderHiddenContentPlaceholder();
} }
const { reviewsOnPageLoad } = attributes;
const { order, orderby } = getOrderArgs( attributes.orderby );
return ( return (
<div className={ getBlockClassName( 'wc-block-reviews-by-product', attributes ) }> <div className={ getBlockClassName( 'wc-block-reviews-by-product', attributes ) }>
<EditorBlock attributes={ attributes } /> <EditorBlock
attributes={ attributes }
delayFunction={ ( callback ) => debounce( callback, 400 ) }
orderby={ orderby }
order={ order }
productId={ productId }
reviewsToDisplay={ reviewsOnPageLoad }
/>
</div> </div>
); );
}; };

View File

@ -1,76 +1,52 @@
/** /**
* External dependencies * External dependencies
*/ */
import { __ } from '@wordpress/i18n'; import { __, sprintf } from '@wordpress/i18n';
import { Component } from 'react'; import { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { debounce } from 'lodash'; import { Disabled, Placeholder } from '@wordpress/components';
import { Disabled } from '@wordpress/components';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { getOrderArgs, getReviews } from '../utils';
import LoadMoreButton from '../../../base/components/load-more-button'; import LoadMoreButton from '../../../base/components/load-more-button';
import ReviewList from '../../../base/components/review-list'; import ReviewList from '../../../base/components/review-list';
import ReviewOrderSelect from '../../../base/components/review-order-select'; import ReviewOrderSelect from '../../../base/components/review-order-select';
import withComponentId from '../../../base/hocs/with-component-id'; import withComponentId from '../../../base/hocs/with-component-id';
import withReviews from '../../../base/hocs/with-reviews';
import { IconReviewsByProduct } from '../../../components/icons';
import NoReviewsPlaceholder from './no-reviews-placeholder.js'; import NoReviewsPlaceholder from './no-reviews-placeholder.js';
import { ENABLE_REVIEW_RATING } from '../../../constants'; import { ENABLE_REVIEW_RATING } from '../../../constants';
import { escapeHTML } from '@wordpress/escape-html';
/** /**
* Block rendered in the editor. * Block rendered in the editor.
*/ */
class EditorBlock extends Component { class EditorBlock extends Component {
constructor() { renderNoReviews() {
super( ...arguments );
this.state = {
reviews: [],
totalReviews: 0,
isLoading: true,
};
this.debouncedLoadFirstReviews = debounce( this.loadFirstReviews.bind( this ), 400 );
}
componentDidMount() {
this.loadFirstReviews();
}
componentDidUpdate( prevProps ) {
if (
prevProps.attributes.orderby !== this.props.attributes.orderby ||
prevProps.attributes.productId !== this.props.attributes.productId ||
prevProps.attributes.reviewsOnPageLoad !== this.props.attributes.reviewsOnPageLoad
) {
this.debouncedLoadFirstReviews();
}
}
getDefaultArgs() {
const { attributes } = this.props; const { attributes } = this.props;
const { order, orderby } = getOrderArgs( attributes.orderby ); const { product } = attributes;
const { productId, reviewsOnPageLoad } = attributes; return (
<Placeholder
return { className="wc-block-reviews-by-product"
order, icon={ <IconReviewsByProduct className="block-editor-block-icon" /> }
orderby, label={ __( 'Reviews by Product', 'woo-gutenberg-products-block' ) }
per_page: reviewsOnPageLoad, >
product_id: productId, <div dangerouslySetInnerHTML={ {
}; __html: sprintf(
} __(
"This block lists reviews for a selected product. %s doesn't have any reviews yet, but they will show up here when it does.",
loadFirstReviews() { 'woo-gutenberg-products-block'
getReviews( this.getDefaultArgs() ).then( ( { reviews, totalReviews } ) => { ),
this.setState( { reviews, totalReviews, isLoading: false } ); '<strong>' + escapeHTML( product.name ) + '</strong>'
} ).catch( () => { ),
this.setState( { reviews: [], isLoading: false } ); } } />
} ); </Placeholder>
);
} }
render() { render() {
const { attributes, componentId } = this.props; const { attributes, componentId, isLoading, reviews, totalReviews } = this.props;
const { reviews, totalReviews, isLoading } = this.state;
if ( 0 === reviews.length && ! isLoading ) { if ( 0 === reviews.length && ! isLoading ) {
return <NoReviewsPlaceholder attributes={ attributes } />; return <NoReviewsPlaceholder attributes={ attributes } />;
@ -107,6 +83,9 @@ EditorBlock.propTypes = {
attributes: PropTypes.object.isRequired, attributes: PropTypes.object.isRequired,
// from withComponentId // from withComponentId
componentId: PropTypes.number, componentId: PropTypes.number,
// from withReviews
reviews: PropTypes.array,
totalReviews: PropTypes.number,
}; };
export default withComponentId( EditorBlock ); export default withComponentId( withReviews( EditorBlock ) );

View File

@ -1,172 +0,0 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { speak } from '@wordpress/a11y';
/**
* Internal dependencies
*/
import { getOrderArgs, getReviews } from '../utils';
import LoadMoreButton from '../../../base/components/load-more-button';
import ReviewOrderSelect from '../../../base/components/review-order-select';
import ReviewList from '../../../base/components/review-list';
import withComponentId from '../../../base/hocs/with-component-id';
import { ENABLE_REVIEW_RATING } from '../../../constants';
/**
* Block rendered in the frontend.
*/
class FrontendBlock extends Component {
constructor() {
super( ...arguments );
const { attributes } = this.props;
this.state = {
orderby: attributes.orderby,
reviews: [],
totalReviews: 0,
};
this.onChangeOrderby = this.onChangeOrderby.bind( this );
this.appendReviews = this.appendReviews.bind( this );
}
componentDidMount() {
this.loadFirstReviews();
}
getDefaultArgs() {
const { attributes } = this.props;
const { order, orderby } = getOrderArgs( this.state.orderby );
const { productId, reviewsOnPageLoad } = attributes;
return {
order,
orderby,
per_page: reviewsOnPageLoad,
product_id: productId,
};
}
loadFirstReviews() {
getReviews( this.getDefaultArgs() ).then( ( { reviews, totalReviews } ) => {
this.setState( { reviews, totalReviews } );
} ).catch( () => {
this.setState( { reviews: [] } );
speak(
__( 'There was an error loading the reviews.', 'woo-gutenberg-products-block' )
);
} );
}
appendReviews() {
const { attributes } = this.props;
const { reviewsOnLoadMore } = attributes;
const { reviews, totalReviews } = this.state;
const reviewsToLoad = Math.min( totalReviews - reviews.length, reviewsOnLoadMore );
this.setState( { reviews: reviews.concat( Array( reviewsToLoad ).fill( {} ) ) } );
const args = {
...this.getDefaultArgs(),
offset: reviews.length,
per_page: reviewsOnLoadMore,
};
getReviews( args ).then( ( { reviews: newReviews, totalReviews: newTotalReviews } ) => {
this.setState( {
reviews: reviews.filter( ( review ) => Object.keys( review ).length ).concat( newReviews ),
totalReviews: newTotalReviews,
} );
speak(
sprintf(
_n(
'%d review loaded.',
'%d reviews loaded.',
'woo-gutenberg-products-block'
),
newReviews.length
)
);
} ).catch( () => {
this.setState( { reviews: [] } );
speak(
__( 'There was an error loading the reviews.', 'woo-gutenberg-products-block' )
);
} );
}
onChangeOrderby( event ) {
const { attributes } = this.props;
const { reviewsOnPageLoad } = attributes;
const { totalReviews } = this.state;
const { order, orderby } = getOrderArgs( event.target.value );
const newReviews = Math.min( totalReviews, reviewsOnPageLoad );
this.setState( {
reviews: Array( newReviews ).fill( {} ),
orderby: event.target.value,
} );
const args = {
...this.getDefaultArgs(),
order,
orderby,
per_page: reviewsOnPageLoad,
};
getReviews( args ).then( ( { reviews, totalReviews: newTotalReviews } ) => {
this.setState( { reviews, totalReviews: newTotalReviews } );
speak( __( 'Reviews order updated.', 'woo-gutenberg-products-block' ) );
} ).catch( () => {
this.setState( { reviews: [] } );
speak(
__( 'There was an error loading the reviews.', 'woo-gutenberg-products-block' )
);
} );
}
render() {
const { attributes, componentId } = this.props;
const { orderby, reviews, totalReviews } = this.state;
if ( 0 === reviews.length ) {
return null;
}
return (
<Fragment>
{ ( attributes.showOrderby && ENABLE_REVIEW_RATING ) && (
<ReviewOrderSelect
componentId={ componentId }
onChange={ this.onChangeOrderby }
value={ orderby }
/>
) }
<ReviewList
attributes={ attributes }
componentId={ componentId }
reviews={ reviews }
/>
{ ( attributes.showLoadMore && totalReviews > reviews.length ) && (
<LoadMoreButton
onClick={ this.appendReviews }
screenReaderLabel={ __( 'Load more reviews', 'woo-gutenberg-products-block' ) }
/>
) }
</Fragment>
);
}
}
FrontendBlock.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
// from withComponentId
componentId: PropTypes.number,
};
export default withComponentId( FrontendBlock );

View File

@ -6,7 +6,7 @@ import { render } from 'react-dom';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import FrontendBlock from './frontend-block.js'; import FrontendContainerBlock from '../frontend-container-block.js';
const containers = document.querySelectorAll( const containers = document.querySelectorAll(
'.wp-block-woocommerce-reviews-by-product' '.wp-block-woocommerce-reviews-by-product'
@ -24,6 +24,6 @@ if ( containers.length ) {
showReviewContent: el.classList.contains( 'has-content' ), showReviewContent: el.classList.contains( 'has-content' ),
}; };
render( <FrontendBlock attributes={ attributes } />, el ); render( <FrontendContainerBlock attributes={ attributes } />, el );
} ); } );
} }

View File

@ -50,6 +50,7 @@
"@wordpress/editor": "9.5.0", "@wordpress/editor": "9.5.0",
"@wordpress/element": "2.6.0", "@wordpress/element": "2.6.0",
"@wordpress/i18n": "3.6.0", "@wordpress/i18n": "3.6.0",
"@wordpress/is-shallow-equal": "^1.5.0",
"@wordpress/jest-preset-default": "4.3.0", "@wordpress/jest-preset-default": "4.3.0",
"@wordpress/rich-text": "3.5.0", "@wordpress/rich-text": "3.5.0",
"@wordpress/scripts": "3.4.0", "@wordpress/scripts": "3.4.0",